//! [`FsSkillStore`] — file implementation of the [`SkillStore`] port //! (ARCHITECTURE §14.2, L12). //! //! Skills are reusable, model-agnostic workflows. They live in **two isolated //! scopes**, each backed by its own directory but the same on-disk shape //! (mirroring [`crate::store::FsTemplateStore`]): a small JSON index carrying the //! metadata needed to list without parsing every `.md`, plus the Markdown bodies //! under `md/`: //! //! ```text //! /skills/ # SkillScope::Global (shared across projects) //! /.ideai/skills/ # SkillScope::Project (travels with the code) //! ├── index.json # { version, skills: [{ id, name, contentHash }] } //! └── md/ //! └── .md # a skill's Markdown content //! ``` //! //! The two scopes never bleed into one another: a `Project` skill is invisible to //! a `Global` listing and vice-versa, because each resolves to a different //! directory. All I/O goes through the [`FileSystem`] port, so the adapter is //! location-neutral (a project hosted over SSH/WSL works unchanged) and //! Tauri-agnostic. //! //! Like the template store, [`delete`](SkillStore::delete) drops the index row //! and leaves the orphaned `md/.md` on disk (the [`FileSystem`] port exposes //! no remove); since listing is index-driven, the skill is effectively gone. use std::hash::{Hash, Hasher}; use std::sync::Arc; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use domain::ids::SkillId; use domain::markdown::MarkdownDoc; use domain::ports::{FileSystem, RemotePath, SkillStore, StoreError}; use domain::project::ProjectPath; use domain::skill::{Skill, SkillScope}; /// Directory (under app-data) holding the global skills store. const GLOBAL_SKILLS_DIR: &str = "skills"; /// The `.ideai/` directory name inside a project root. const IDEAI_DIR: &str = ".ideai"; /// Sub-path of the project-scoped skills store inside `.ideai/`. const PROJECT_SKILLS_DIR: &str = "skills"; /// Index file name inside a skills 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: SkillId, name: String, /// One-line affordance description (feature « skills à la MCP »). `#[serde(default)]` /// is **mandatory** for backward-compat: legacy `index.json` written before this /// field existed deserialise with `None` instead of failing. #[serde(default)] description: Option, content_hash: String, } /// On-disk shape of a scope's `index.json`. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct IndexDoc { version: u32, skills: Vec, } impl Default for IndexDoc { fn default() -> Self { Self { version: INDEX_VERSION, skills: Vec::new(), } } } /// A stable, dependency-free digest of Markdown content for out-of-app edit /// detection — deterministic across runs and platforms (fixed-key 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 [`SkillStore`], composing a [`FileSystem`] port. Holds only the /// machine-global app-data dir; the project root for [`SkillScope::Project`] is /// supplied **per call**, so a single instance serves every open project /// (mirroring [`crate::store::IdeaiContextStore`]). #[derive(Clone)] pub struct FsSkillStore { fs: Arc, app_data_dir: String, } impl FsSkillStore { /// Builds the store from an injected [`FileSystem`] and the global app-data /// dir (used for [`SkillScope::Global`]). 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(), } } /// Root directory of a scope's store. `root` is ignored for `Global`. fn dir(&self, scope: SkillScope, root: &ProjectPath) -> String { match scope { SkillScope::Global => { let base = self.app_data_dir.trim_end_matches(['/', '\\']); format!("{base}/{GLOBAL_SKILLS_DIR}") } SkillScope::Project => { let base = root.as_str().trim_end_matches(['/', '\\']); format!("{base}/{IDEAI_DIR}/{PROJECT_SKILLS_DIR}") } } } /// `/index.json`. fn index_path(&self, scope: SkillScope, root: &ProjectPath) -> RemotePath { RemotePath::new(format!("{}/{INDEX_FILE}", self.dir(scope, root))) } /// `/md/.md`. fn md_path(&self, scope: SkillScope, root: &ProjectPath, id: SkillId) -> RemotePath { RemotePath::new(format!("{}/md/{id}.md", self.dir(scope, root))) } /// Reads a scope's index, returning an empty default if absent. async fn read_index( &self, scope: SkillScope, root: &ProjectPath, ) -> Result { match self.fs.read(&self.index_path(scope, root)).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 a scope's index, ensuring its directory exists. async fn write_index( &self, scope: SkillScope, root: &ProjectPath, doc: &IndexDoc, ) -> Result<(), StoreError> { self.fs .create_dir_all(&RemotePath::new(self.dir(scope, root))) .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(scope, root), &bytes) .await .map_err(|e| StoreError::Io(e.to_string())) } /// Reconstructs the [`Skill`] for an index entry by reading its `.md`. async fn load( &self, scope: SkillScope, root: &ProjectPath, entry: &IndexEntry, ) -> Result { let bytes = self .fs .read(&self.md_path(scope, root, 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()))?; Skill::new( entry.id, entry.name.clone(), MarkdownDoc::new(content), scope, ) .map(|skill| skill.with_description(entry.description.clone())) .map_err(|e| StoreError::Serialization(e.to_string())) } } #[async_trait] impl SkillStore for FsSkillStore { async fn list(&self, scope: SkillScope, root: &ProjectPath) -> Result, StoreError> { let index = self.read_index(scope, root).await?; let mut out = Vec::with_capacity(index.skills.len()); for entry in &index.skills { out.push(self.load(scope, root, entry).await?); } Ok(out) } async fn get( &self, scope: SkillScope, root: &ProjectPath, id: SkillId, ) -> Result { let index = self.read_index(scope, root).await?; let entry = index .skills .iter() .find(|e| e.id == id) .ok_or(StoreError::NotFound)?; self.load(scope, root, entry).await } async fn save(&self, skill: &Skill, root: &ProjectPath) -> Result<(), StoreError> { let scope = skill.scope; // (1) Write the Markdown content. self.fs .create_dir_all(&RemotePath::new(format!("{}/md", self.dir(scope, root)))) .await .map_err(|e| StoreError::Io(e.to_string()))?; self.fs .write( &self.md_path(scope, root, skill.id), skill.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(scope, root).await?; let row = IndexEntry { id: skill.id, name: skill.name.clone(), description: skill.description.clone(), content_hash: content_hash(&skill.content_md), }; if let Some(slot) = index.skills.iter_mut().find(|e| e.id == skill.id) { *slot = row; } else { index.skills.push(row); } self.write_index(scope, root, &index).await } async fn delete( &self, scope: SkillScope, root: &ProjectPath, id: SkillId, ) -> Result<(), StoreError> { let mut index = self.read_index(scope, root).await?; let before = index.skills.len(); index.skills.retain(|e| e.id != id); if index.skills.len() == before { return Err(StoreError::NotFound); } // The orphaned `md/.md` is left on disk (no FileSystem delete); the // index no longer references it, so it is effectively gone. self.write_index(scope, root, &index).await } } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; use std::sync::Mutex; use domain::ports::{DirEntry, FsError}; /// In-memory [`FileSystem`] mock (same minimal shape as the memory-store tests). #[derive(Default)] struct MemFs { files: Mutex>>, } impl MemFs { fn arc() -> Arc { Arc::new(Self::default()) } } #[async_trait] impl FileSystem for MemFs { async fn read(&self, path: &RemotePath) -> Result, FsError> { self.files .lock() .unwrap() .get(path.as_str()) .cloned() .ok_or_else(|| FsError::NotFound(path.as_str().to_string())) } async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { self.files .lock() .unwrap() .insert(path.as_str().to_string(), data.to_vec()); Ok(()) } async fn exists(&self, path: &RemotePath) -> Result { Ok(self.files.lock().unwrap().contains_key(path.as_str())) } async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> { Ok(()) } async fn list(&self, _path: &RemotePath) -> Result, FsError> { Ok(Vec::new()) } async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { Ok(()) } } fn root() -> ProjectPath { ProjectPath::new("/proj").unwrap() } fn skill(id: u128, name: &str, body: &str) -> Skill { Skill::new( SkillId::from_uuid(uuid::Uuid::from_u128(id)), name, MarkdownDoc::new(body), SkillScope::Global, ) .unwrap() } #[tokio::test] async fn legacy_index_without_description_loads_with_none() { // A legacy `index.json` written before the `description` field existed must // deserialise (thanks to `#[serde(default)]`) and load with `description: None`. let fs = MemFs::arc(); let id = uuid::Uuid::from_u128(42); let legacy_index = format!( r#"{{"version":1,"skills":[{{"id":"{id}","name":"legacy","contentHash":"abc"}}]}}"# ); fs.write( &RemotePath::new("/app/skills/index.json".to_owned()), legacy_index.as_bytes(), ) .await .unwrap(); fs.write( &RemotePath::new(format!("/app/skills/md/{id}.md")), b"# legacy body", ) .await .unwrap(); let store = FsSkillStore::new(fs, "/app"); let listed = store.list(SkillScope::Global, &root()).await.unwrap(); assert_eq!(listed.len(), 1); assert_eq!(listed[0].name, "legacy"); assert_eq!(listed[0].description, None); assert_eq!(listed[0].content_md.as_str(), "# legacy body"); } #[tokio::test] async fn save_then_load_round_trips_description() { let store = FsSkillStore::new(MemFs::arc(), "/app"); let s = skill(1, "refactor", "# Refactor\n\nbody") .with_description(Some("Refactors code".to_owned())); store.save(&s, &root()).await.unwrap(); let got = store.get(SkillScope::Global, &root(), s.id).await.unwrap(); assert_eq!(got.description.as_deref(), Some("Refactors code")); assert_eq!(got, s); // Also surfaces through `list`. let listed = store.list(SkillScope::Global, &root()).await.unwrap(); assert_eq!(listed.len(), 1); assert_eq!(listed[0].description.as_deref(), Some("Refactors code")); } #[tokio::test] async fn save_without_description_loads_as_none() { let store = FsSkillStore::new(MemFs::arc(), "/app"); let s = skill(2, "plain", "# Plain\n\nbody"); store.save(&s, &root()).await.unwrap(); let got = store.get(SkillScope::Global, &root(), s.id).await.unwrap(); assert_eq!(got.description, None); } }