feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
172
crates/infrastructure/src/store/context.rs
Normal file
172
crates/infrastructure/src/store/context.rs
Normal file
@ -0,0 +1,172 @@
|
||||
//! [`IdeaiContextStore`] — file implementation of the [`AgentContextStore`] port
|
||||
//! (ARCHITECTURE §5, §9.1).
|
||||
//!
|
||||
//! Persists, **inside the project root**, the agent contexts and the manifest
|
||||
//! that together make a project's agents travel with the code:
|
||||
//!
|
||||
//! ```text
|
||||
//! <project_root>/
|
||||
//! └── .ideai/
|
||||
//! ├── agents.json # AgentManifest { version, agents: [ManifestEntry, ...] }
|
||||
//! └── agents/
|
||||
//! ├── backend-dev.md # an agent's context (md_path = "agents/backend-dev.md")
|
||||
//! └── ...
|
||||
//! ```
|
||||
//!
|
||||
//! All I/O goes through the [`FileSystem`] port (here the `RemoteHost`'s
|
||||
//! filesystem — [`crate::LocalFileSystem`] locally, an SFTP/WSL one later), so the
|
||||
//! store is **location-neutral**: the very same adapter serves a local project or
|
||||
//! a project hosted over SSH/WSL (Liskov, ARCHITECTURE §1.2).
|
||||
//!
|
||||
//! `md_path` values are interpreted **relative to `.ideai/`** (so a manifest
|
||||
//! `md_path` of `agents/backend-dev.md` maps to
|
||||
//! `<root>/.ideai/agents/backend-dev.md`), matching the documented schema.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::agent::AgentManifest;
|
||||
use domain::ids::AgentId;
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{AgentContextStore, FileSystem, FsError, RemotePath, StoreError};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
|
||||
/// The `.ideai/` directory name inside a project root.
|
||||
const IDEAI_DIR: &str = ".ideai";
|
||||
|
||||
/// The agent-manifest file name inside `.ideai/`.
|
||||
const AGENTS_FILE: &str = "agents.json";
|
||||
|
||||
/// Current schema version written into a freshly-created manifest.
|
||||
const MANIFEST_VERSION: u32 = 1;
|
||||
|
||||
/// File-backed [`AgentContextStore`], composing a [`FileSystem`] port.
|
||||
///
|
||||
/// Cheap to clone (everything is behind `Arc`); the composition root constructs
|
||||
/// one per resolved `RemoteHost` and shares it across the agent use cases.
|
||||
#[derive(Clone)]
|
||||
pub struct IdeaiContextStore {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
}
|
||||
|
||||
impl IdeaiContextStore {
|
||||
/// Builds the store from an injected [`FileSystem`] (the project's
|
||||
/// `RemoteHost` filesystem).
|
||||
#[must_use]
|
||||
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
|
||||
Self { fs }
|
||||
}
|
||||
|
||||
/// Joins a project root with a relative segment using a POSIX separator
|
||||
/// (valid on every target; `tokio::fs` accepts `/` on Windows too).
|
||||
fn join(root: &ProjectPath, rel: &str) -> String {
|
||||
let base = root.as_str().trim_end_matches(['/', '\\']);
|
||||
format!("{base}/{rel}")
|
||||
}
|
||||
|
||||
/// Absolute path of the manifest file for a project.
|
||||
fn manifest_path(project: &Project) -> RemotePath {
|
||||
RemotePath::new(Self::join(&project.root, &format!("{IDEAI_DIR}/{AGENTS_FILE}")))
|
||||
}
|
||||
|
||||
/// Absolute path of an agent context `.md` from its (`.ideai/`-relative)
|
||||
/// `md_path`.
|
||||
fn context_path(project: &Project, md_path: &str) -> RemotePath {
|
||||
RemotePath::new(Self::join(&project.root, &format!("{IDEAI_DIR}/{md_path}")))
|
||||
}
|
||||
|
||||
/// Resolves the `md_path` of an agent from the manifest, or
|
||||
/// [`StoreError::NotFound`] if the agent is unknown to this project.
|
||||
async fn md_path_of(&self, project: &Project, agent: &AgentId) -> Result<String, StoreError> {
|
||||
let manifest = self.load_manifest(project).await?;
|
||||
manifest
|
||||
.entries
|
||||
.into_iter()
|
||||
.find(|e| &e.agent_id == agent)
|
||||
.map(|e| e.md_path)
|
||||
.ok_or(StoreError::NotFound)
|
||||
}
|
||||
|
||||
/// Ensures the directory holding `path` exists (creates `.ideai/` and any
|
||||
/// nested `agents/` parent before a write).
|
||||
async fn ensure_parent(&self, path: &RemotePath) -> Result<(), StoreError> {
|
||||
let raw = path.as_str();
|
||||
let dir = match raw.rfind(['/', '\\']) {
|
||||
Some(idx) => &raw[..idx],
|
||||
None => return Ok(()),
|
||||
};
|
||||
self.fs
|
||||
.create_dir_all(&RemotePath::new(dir.to_owned()))
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentContextStore for IdeaiContextStore {
|
||||
async fn read_context(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent: &AgentId,
|
||||
) -> Result<MarkdownDoc, StoreError> {
|
||||
let md_path = self.md_path_of(project, agent).await?;
|
||||
let path = Self::context_path(project, &md_path);
|
||||
match self.fs.read(&path).await {
|
||||
Ok(bytes) => {
|
||||
let content = String::from_utf8(bytes)
|
||||
.map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||
Ok(MarkdownDoc::new(content))
|
||||
}
|
||||
Err(FsError::NotFound(_)) => Err(StoreError::NotFound),
|
||||
Err(e) => Err(StoreError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_context(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent: &AgentId,
|
||||
md: &MarkdownDoc,
|
||||
) -> Result<(), StoreError> {
|
||||
let md_path = self.md_path_of(project, agent).await?;
|
||||
let path = Self::context_path(project, &md_path);
|
||||
self.ensure_parent(&path).await?;
|
||||
self.fs
|
||||
.write(&path, md.as_str().as_bytes())
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
async fn load_manifest(&self, project: &Project) -> Result<AgentManifest, StoreError> {
|
||||
let path = Self::manifest_path(project);
|
||||
match self.fs.read(&path).await {
|
||||
Ok(bytes) => {
|
||||
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
|
||||
}
|
||||
// No manifest yet: a project simply has no agents — return the empty
|
||||
// default rather than erroring (mirrors the project-store policy).
|
||||
Err(FsError::NotFound(_)) => Ok(AgentManifest {
|
||||
version: MANIFEST_VERSION,
|
||||
entries: Vec::new(),
|
||||
}),
|
||||
Err(e) => Err(StoreError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_manifest(
|
||||
&self,
|
||||
project: &Project,
|
||||
manifest: &AgentManifest,
|
||||
) -> Result<(), StoreError> {
|
||||
let path = Self::manifest_path(project);
|
||||
self.ensure_parent(&path).await?;
|
||||
let mut bytes = serde_json::to_vec_pretty(manifest)
|
||||
.map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||
bytes.push(b'\n');
|
||||
self.fs
|
||||
.write(&path, &bytes)
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))
|
||||
}
|
||||
}
|
||||
15
crates/infrastructure/src/store/mod.rs
Normal file
15
crates/infrastructure/src/store/mod.rs
Normal file
@ -0,0 +1,15 @@
|
||||
//! Filesystem-backed persistence stores (ARCHITECTURE §5, §9.2).
|
||||
//!
|
||||
//! L2 ships [`FsProjectStore`], implementing the domain [`ProjectStore`] port:
|
||||
//! the known-projects **registry** and the **workspace** are stored as plain
|
||||
//! JSON files in the app data directory (machine-local, outside any project).
|
||||
|
||||
mod context;
|
||||
mod profile;
|
||||
mod project;
|
||||
mod template;
|
||||
|
||||
pub use context::IdeaiContextStore;
|
||||
pub use profile::FsProfileStore;
|
||||
pub use project::FsProjectStore;
|
||||
pub use template::FsTemplateStore;
|
||||
149
crates/infrastructure/src/store/profile.rs
Normal file
149
crates/infrastructure/src/store/profile.rs
Normal file
@ -0,0 +1,149 @@
|
||||
//! [`FsProfileStore`] — JSON file implementation of the [`ProfileStore`] port.
|
||||
//!
|
||||
//! Persists the configured [`AgentProfile`]s in the global IDE store
|
||||
//! (ARCHITECTURE §9.2):
|
||||
//!
|
||||
//! ```text
|
||||
//! <app_data_dir>/
|
||||
//! └── profiles.json # { version, profiles: [AgentProfile, ...] }
|
||||
//! ```
|
||||
//!
|
||||
//! Each profile item is exactly the declarative profile of CONTEXT §9
|
||||
//! (`id, name, command, args, contextInjection{strategy,…}, detect, cwd`). The
|
||||
//! existence of `profiles.json` is what marks the first run as *done* (see
|
||||
//! [`ProfileStore::is_configured`]).
|
||||
//!
|
||||
//! Like [`super::FsProjectStore`], the store is Tauri-agnostic: the app-data
|
||||
//! directory is resolved by the composition root and injected as a plain path,
|
||||
//! and all I/O goes through the [`FileSystem`] port.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::ports::{FileSystem, ProfileStore, RemotePath, StoreError};
|
||||
use domain::profile::AgentProfile;
|
||||
|
||||
/// File name of the profiles store inside the app-data dir.
|
||||
const PROFILES_FILE: &str = "profiles.json";
|
||||
|
||||
/// Current schema version of the profiles file.
|
||||
const PROFILES_VERSION: u32 = 1;
|
||||
|
||||
/// On-disk shape of `profiles.json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProfilesDoc {
|
||||
/// Schema version.
|
||||
version: u32,
|
||||
/// All configured profiles.
|
||||
profiles: Vec<AgentProfile>,
|
||||
}
|
||||
|
||||
impl Default for ProfilesDoc {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: PROFILES_VERSION,
|
||||
profiles: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON-file implementation of the [`ProfileStore`] port.
|
||||
///
|
||||
/// Cheap to clone (everything is behind `Arc`); built once at the composition
|
||||
/// root and shared across use cases.
|
||||
#[derive(Clone)]
|
||||
pub struct FsProfileStore {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
app_data_dir: String,
|
||||
}
|
||||
|
||||
impl FsProfileStore {
|
||||
/// Builds the store from an injected [`FileSystem`] and the app-data
|
||||
/// directory path (resolved by the composition root). The directory is
|
||||
/// created lazily on first write.
|
||||
#[must_use]
|
||||
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
|
||||
Self {
|
||||
fs,
|
||||
app_data_dir: app_data_dir.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Joins the app-data dir with the profiles file name (POSIX separator, valid
|
||||
/// on every target — `tokio::fs` accepts `/` on Windows too).
|
||||
fn path(&self) -> RemotePath {
|
||||
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
|
||||
RemotePath::new(format!("{base}/{PROFILES_FILE}"))
|
||||
}
|
||||
|
||||
/// Reads and parses the doc, returning an empty default if the file is absent.
|
||||
async fn read_doc(&self) -> Result<ProfilesDoc, StoreError> {
|
||||
match self.fs.read(&self.path()).await {
|
||||
Ok(bytes) => {
|
||||
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
|
||||
}
|
||||
Err(domain::ports::FsError::NotFound(_)) => Ok(ProfilesDoc::default()),
|
||||
Err(e) => Err(StoreError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the doc, ensuring the app-data dir exists first.
|
||||
async fn write_doc(&self, doc: &ProfilesDoc) -> Result<(), StoreError> {
|
||||
let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
|
||||
self.fs
|
||||
.create_dir_all(&dir)
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||
let bytes =
|
||||
serde_json::to_vec_pretty(doc).map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||
self.fs
|
||||
.write(&self.path(), &bytes)
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProfileStore for FsProfileStore {
|
||||
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
||||
Ok(self.read_doc().await?.profiles)
|
||||
}
|
||||
|
||||
async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> {
|
||||
let mut doc = self.read_doc().await?;
|
||||
if let Some(slot) = doc.profiles.iter_mut().find(|p| p.id == profile.id) {
|
||||
*slot = profile.clone();
|
||||
} else {
|
||||
doc.profiles.push(profile.clone());
|
||||
}
|
||||
self.write_doc(&doc).await
|
||||
}
|
||||
|
||||
async fn delete(&self, id: ProfileId) -> Result<(), StoreError> {
|
||||
let mut doc = self.read_doc().await?;
|
||||
let before = doc.profiles.len();
|
||||
doc.profiles.retain(|p| p.id != id);
|
||||
if doc.profiles.len() == before {
|
||||
return Err(StoreError::NotFound);
|
||||
}
|
||||
self.write_doc(&doc).await
|
||||
}
|
||||
|
||||
async fn is_configured(&self) -> Result<bool, StoreError> {
|
||||
self.fs
|
||||
.exists(&self.path())
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
async fn mark_configured(&self) -> Result<(), StoreError> {
|
||||
// Write the current doc back (an empty default when nothing exists),
|
||||
// which materialises `profiles.json` and records first-run completion.
|
||||
let doc = self.read_doc().await?;
|
||||
self.write_doc(&doc).await
|
||||
}
|
||||
}
|
||||
156
crates/infrastructure/src/store/project.rs
Normal file
156
crates/infrastructure/src/store/project.rs
Normal file
@ -0,0 +1,156 @@
|
||||
//! [`FsProjectStore`] — JSON file implementation of the [`ProjectStore`] port.
|
||||
//!
|
||||
//! Persistence layout (under the injected app-data directory, ARCHITECTURE §9.2):
|
||||
//!
|
||||
//! ```text
|
||||
//! <app_data_dir>/
|
||||
//! ├── projects.json # the known-projects registry { version, projects: [Project, ...] }
|
||||
//! └── workspace.json # the persisted Workspace (windows/tabs/layouts)
|
||||
//! ```
|
||||
//!
|
||||
//! The store does **not** know about Tauri: the app-data directory is resolved
|
||||
//! by the composition root and handed in as a plain path (Dependency Inversion).
|
||||
//! All I/O goes through the [`FileSystem`] port (here [`LocalFileSystem`]) so the
|
||||
//! store stays decoupled from `tokio::fs` directly and is reusable as-is.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use domain::ids::ProjectId;
|
||||
use domain::layout::Workspace;
|
||||
use domain::ports::{FileSystem, ProjectStore, RemotePath, StoreError};
|
||||
use domain::project::Project;
|
||||
|
||||
/// File name of the known-projects registry inside the app-data dir.
|
||||
const REGISTRY_FILE: &str = "projects.json";
|
||||
|
||||
/// File name of the persisted workspace inside the app-data dir.
|
||||
const WORKSPACE_FILE: &str = "workspace.json";
|
||||
|
||||
/// Current schema version of the registry file.
|
||||
const REGISTRY_VERSION: u32 = 1;
|
||||
|
||||
/// On-disk shape of the registry file.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Registry {
|
||||
/// Schema version.
|
||||
version: u32,
|
||||
/// All known projects.
|
||||
projects: Vec<Project>,
|
||||
}
|
||||
|
||||
/// JSON-file implementation of the [`ProjectStore`] port.
|
||||
///
|
||||
/// Cheap to clone (everything is behind `Arc`); the composition root constructs
|
||||
/// it once and shares it across use cases.
|
||||
#[derive(Clone)]
|
||||
pub struct FsProjectStore {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
app_data_dir: String,
|
||||
}
|
||||
|
||||
impl FsProjectStore {
|
||||
/// Builds the store from an injected [`FileSystem`] and the app-data
|
||||
/// directory path (resolved by the composition root, e.g. via the Tauri path
|
||||
/// API). The directory is created lazily on first write.
|
||||
#[must_use]
|
||||
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
|
||||
Self {
|
||||
fs,
|
||||
app_data_dir: app_data_dir.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Joins the app-data dir with a file name using a POSIX separator (valid on
|
||||
/// every target — `tokio::fs` accepts `/` on Windows too).
|
||||
fn path(&self, file: &str) -> RemotePath {
|
||||
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
|
||||
RemotePath::new(format!("{base}/{file}"))
|
||||
}
|
||||
|
||||
/// Reads and parses the registry, returning an empty one if the file does
|
||||
/// not exist yet.
|
||||
async fn read_registry(&self) -> Result<Registry, StoreError> {
|
||||
let path = self.path(REGISTRY_FILE);
|
||||
match self.fs.read(&path).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes)
|
||||
.map_err(|e| StoreError::Serialization(e.to_string())),
|
||||
Err(domain::ports::FsError::NotFound(_)) => Ok(Registry {
|
||||
version: REGISTRY_VERSION,
|
||||
projects: Vec::new(),
|
||||
}),
|
||||
Err(e) => Err(StoreError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the registry, ensuring the app-data dir exists first.
|
||||
async fn write_registry(&self, registry: &Registry) -> Result<(), StoreError> {
|
||||
self.ensure_dir().await?;
|
||||
let bytes = serde_json::to_vec_pretty(registry)
|
||||
.map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||
self.fs
|
||||
.write(&self.path(REGISTRY_FILE), &bytes)
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
/// Creates the app-data directory and all missing parents.
|
||||
async fn ensure_dir(&self) -> Result<(), StoreError> {
|
||||
let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
|
||||
self.fs
|
||||
.create_dir_all(&dir)
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProjectStore for FsProjectStore {
|
||||
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
||||
Ok(self.read_registry().await?.projects)
|
||||
}
|
||||
|
||||
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
|
||||
self.read_registry()
|
||||
.await?
|
||||
.projects
|
||||
.into_iter()
|
||||
.find(|p| p.id == id)
|
||||
.ok_or(StoreError::NotFound)
|
||||
}
|
||||
|
||||
async fn save_project(&self, project: &Project) -> Result<(), StoreError> {
|
||||
let mut registry = self.read_registry().await?;
|
||||
if let Some(slot) = registry.projects.iter_mut().find(|p| p.id == project.id) {
|
||||
*slot = project.clone();
|
||||
} else {
|
||||
registry.projects.push(project.clone());
|
||||
}
|
||||
self.write_registry(®istry).await
|
||||
}
|
||||
|
||||
async fn save_workspace(&self, workspace: &Workspace) -> Result<(), StoreError> {
|
||||
self.ensure_dir().await?;
|
||||
let bytes = serde_json::to_vec_pretty(workspace)
|
||||
.map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||
self.fs
|
||||
.write(&self.path(WORKSPACE_FILE), &bytes)
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
|
||||
let path = self.path(WORKSPACE_FILE);
|
||||
match self.fs.read(&path).await {
|
||||
Ok(bytes) => {
|
||||
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
|
||||
}
|
||||
// No workspace persisted yet: return the empty default.
|
||||
Err(domain::ports::FsError::NotFound(_)) => Ok(Workspace::default()),
|
||||
Err(e) => Err(StoreError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
225
crates/infrastructure/src/store/template.rs
Normal file
225
crates/infrastructure/src/store/template.rs
Normal file
@ -0,0 +1,225 @@
|
||||
//! [`FsTemplateStore`] — file implementation of the [`TemplateStore`] port
|
||||
//! (ARCHITECTURE §5, §9.2).
|
||||
//!
|
||||
//! Templates live in the **global IDE store** (machine-local app-data dir, *not*
|
||||
//! inside any project): the Markdown content travels as a diffable `.md`, with a
|
||||
//! small JSON index carrying the metadata (version, hash) needed to list and
|
||||
//! version templates without parsing every `.md`:
|
||||
//!
|
||||
//! ```text
|
||||
//! <app_data_dir>/templates/
|
||||
//! ├── index.json # { version, templates: [{ id, name, version, contentHash, defaultProfileId }] }
|
||||
//! └── md/
|
||||
//! └── <id>.md # a template's Markdown content
|
||||
//! ```
|
||||
//!
|
||||
//! `contentHash` is a stable digest of the `.md` content, recorded so a future
|
||||
//! spike can detect **out-of-app edits** (ARCHITECTURE §13.9); it is not part of
|
||||
//! the [`AgentTemplate`] domain entity. Like the other stores, all I/O goes
|
||||
//! through the [`FileSystem`] port, so the adapter is Tauri-agnostic.
|
||||
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use domain::ids::{ProfileId, TemplateId};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{FileSystem, RemotePath, StoreError, TemplateStore};
|
||||
use domain::template::{AgentTemplate, TemplateVersion};
|
||||
|
||||
/// Directory (under app-data) holding the templates store.
|
||||
const TEMPLATES_DIR: &str = "templates";
|
||||
|
||||
/// Index file name inside the templates dir.
|
||||
const INDEX_FILE: &str = "index.json";
|
||||
|
||||
/// Current schema version of the index file.
|
||||
const INDEX_VERSION: u32 = 1;
|
||||
|
||||
/// One metadata row in `index.json` (the `.md` content lives separately).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct IndexEntry {
|
||||
id: TemplateId,
|
||||
name: String,
|
||||
version: TemplateVersion,
|
||||
content_hash: String,
|
||||
default_profile_id: ProfileId,
|
||||
}
|
||||
|
||||
/// On-disk shape of `templates/index.json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct IndexDoc {
|
||||
version: u32,
|
||||
templates: Vec<IndexEntry>,
|
||||
}
|
||||
|
||||
impl Default for IndexDoc {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: INDEX_VERSION,
|
||||
templates: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stable, dependency-free digest of Markdown content for out-of-app edit
|
||||
/// detection. `DefaultHasher::new()` uses fixed keys, so this is deterministic
|
||||
/// across runs and platforms (unlike a `RandomState`-seeded hasher).
|
||||
fn content_hash(md: &MarkdownDoc) -> String {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
md.as_str().hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
}
|
||||
|
||||
/// File-backed [`TemplateStore`], composing a [`FileSystem`] port.
|
||||
#[derive(Clone)]
|
||||
pub struct FsTemplateStore {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
app_data_dir: String,
|
||||
}
|
||||
|
||||
impl FsTemplateStore {
|
||||
/// Builds the store from an injected [`FileSystem`] and the app-data dir
|
||||
/// (resolved by the composition root). Directories are created on first write.
|
||||
#[must_use]
|
||||
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
|
||||
Self {
|
||||
fs,
|
||||
app_data_dir: app_data_dir.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `<app>/templates`.
|
||||
fn dir(&self) -> String {
|
||||
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
|
||||
format!("{base}/{TEMPLATES_DIR}")
|
||||
}
|
||||
|
||||
/// `<app>/templates/index.json`.
|
||||
fn index_path(&self) -> RemotePath {
|
||||
RemotePath::new(format!("{}/{INDEX_FILE}", self.dir()))
|
||||
}
|
||||
|
||||
/// `<app>/templates/md/<id>.md`.
|
||||
fn md_path(&self, id: TemplateId) -> RemotePath {
|
||||
RemotePath::new(format!("{}/md/{id}.md", self.dir()))
|
||||
}
|
||||
|
||||
/// Reads the index, returning an empty default if absent.
|
||||
async fn read_index(&self) -> Result<IndexDoc, StoreError> {
|
||||
match self.fs.read(&self.index_path()).await {
|
||||
Ok(bytes) => {
|
||||
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
|
||||
}
|
||||
Err(domain::ports::FsError::NotFound(_)) => Ok(IndexDoc::default()),
|
||||
Err(e) => Err(StoreError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the index, ensuring `templates/` exists.
|
||||
async fn write_index(&self, doc: &IndexDoc) -> Result<(), StoreError> {
|
||||
self.fs
|
||||
.create_dir_all(&RemotePath::new(self.dir()))
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||
let bytes =
|
||||
serde_json::to_vec_pretty(doc).map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||
self.fs
|
||||
.write(&self.index_path(), &bytes)
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
/// Reconstructs the [`AgentTemplate`] for an index entry by reading its `.md`.
|
||||
async fn load(&self, entry: &IndexEntry) -> Result<AgentTemplate, StoreError> {
|
||||
let bytes = self
|
||||
.fs
|
||||
.read(&self.md_path(entry.id))
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
domain::ports::FsError::NotFound(_) => StoreError::NotFound,
|
||||
other => StoreError::Io(other.to_string()),
|
||||
})?;
|
||||
let content =
|
||||
String::from_utf8(bytes).map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||
// The domain entity carries the authoritative version/name from the index;
|
||||
// we reconstruct it directly (no public mutator needed) since every field
|
||||
// is known and already validated when it was first saved.
|
||||
Ok(AgentTemplate {
|
||||
id: entry.id,
|
||||
name: entry.name.clone(),
|
||||
content_md: MarkdownDoc::new(content),
|
||||
version: entry.version,
|
||||
default_profile_id: entry.default_profile_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TemplateStore for FsTemplateStore {
|
||||
async fn list(&self) -> Result<Vec<AgentTemplate>, StoreError> {
|
||||
let index = self.read_index().await?;
|
||||
let mut out = Vec::with_capacity(index.templates.len());
|
||||
for entry in &index.templates {
|
||||
out.push(self.load(entry).await?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn get(&self, id: TemplateId) -> Result<AgentTemplate, StoreError> {
|
||||
let index = self.read_index().await?;
|
||||
let entry = index
|
||||
.templates
|
||||
.iter()
|
||||
.find(|e| e.id == id)
|
||||
.ok_or(StoreError::NotFound)?;
|
||||
self.load(entry).await
|
||||
}
|
||||
|
||||
async fn save(&self, template: &AgentTemplate) -> Result<(), StoreError> {
|
||||
// (1) Write the Markdown content.
|
||||
self.fs
|
||||
.create_dir_all(&RemotePath::new(format!("{}/md", self.dir())))
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||
self.fs
|
||||
.write(
|
||||
&self.md_path(template.id),
|
||||
template.content_md.as_str().as_bytes(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||
|
||||
// (2) Upsert the index metadata.
|
||||
let mut index = self.read_index().await?;
|
||||
let row = IndexEntry {
|
||||
id: template.id,
|
||||
name: template.name.clone(),
|
||||
version: template.version,
|
||||
content_hash: content_hash(&template.content_md),
|
||||
default_profile_id: template.default_profile_id,
|
||||
};
|
||||
if let Some(slot) = index.templates.iter_mut().find(|e| e.id == template.id) {
|
||||
*slot = row;
|
||||
} else {
|
||||
index.templates.push(row);
|
||||
}
|
||||
self.write_index(&index).await
|
||||
}
|
||||
|
||||
async fn delete(&self, id: TemplateId) -> Result<(), StoreError> {
|
||||
let mut index = self.read_index().await?;
|
||||
let before = index.templates.len();
|
||||
index.templates.retain(|e| e.id != id);
|
||||
if index.templates.len() == before {
|
||||
return Err(StoreError::NotFound);
|
||||
}
|
||||
// The orphaned `md/<id>.md` is left on disk (the FileSystem port exposes no
|
||||
// delete); the index no longer references it, so it is effectively gone.
|
||||
self.write_index(&index).await
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user