//! [`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 //! /templates/ //! ├── index.json # { version, templates: [{ id, name, version, contentHash, defaultProfileId }] } //! └── md/ //! └── .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, } 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, 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, app_data_dir: impl Into) -> Self { Self { fs, app_data_dir: app_data_dir.into(), } } /// `/templates`. fn dir(&self) -> String { let base = self.app_data_dir.trim_end_matches(['/', '\\']); format!("{base}/{TEMPLATES_DIR}") } /// `/templates/index.json`. fn index_path(&self) -> RemotePath { RemotePath::new(format!("{}/{INDEX_FILE}", self.dir())) } /// `/templates/md/.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 { 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 { 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, 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 { 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/.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 } }