//! L12 integration tests for [`FsSkillStore`] against a real temp directory and a //! real [`LocalFileSystem`]: md + `index.json` round-trip in **both** scopes, //! scope isolation (a `Project` skill never appears under `Global` and vice //! versa), upsert, delete, tolerant reads, and the on-disk layout. use std::path::PathBuf; use std::sync::Arc; use domain::ids::SkillId; use domain::markdown::MarkdownDoc; use domain::ports::{FileSystem, RemotePath, SkillStore, StoreError}; use domain::project::ProjectPath; use domain::skill::{Skill, SkillScope}; use infrastructure::{FsSkillStore, LocalFileSystem}; use uuid::Uuid; /// A unique scratch directory under the OS temp dir, cleaned up on drop. It plays /// **both** roles: its own path is the global app-data dir, and a `project/` /// child is the project root (so the two scopes resolve to disjoint subtrees). struct TempDir(PathBuf); impl TempDir { fn new() -> Self { let p = std::env::temp_dir().join(format!("idea-l12-skill-{}", Uuid::new_v4())); std::fs::create_dir_all(&p).unwrap(); Self(p) } fn app_data_dir(&self) -> String { self.0.to_string_lossy().into_owned() } fn project_root(&self) -> ProjectPath { ProjectPath::new(self.0.join("project").to_string_lossy().into_owned()).unwrap() } fn child(&self, rel: &str) -> RemotePath { RemotePath::new(self.0.join(rel).to_string_lossy().into_owned()) } } impl Drop for TempDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } fn store(tmp: &TempDir) -> FsSkillStore { let fs: Arc = Arc::new(LocalFileSystem::new()); FsSkillStore::new(fs, tmp.app_data_dir()) } fn sid(n: u128) -> SkillId { SkillId::from_uuid(Uuid::from_u128(n)) } fn skill(id: SkillId, name: &str, content: &str, scope: SkillScope) -> Skill { Skill::new(id, name, MarkdownDoc::new(content), scope).unwrap() } #[tokio::test] async fn missing_index_lists_empty_in_both_scopes() { let tmp = TempDir::new(); let s = store(&tmp); let root = tmp.project_root(); assert!(s.list(SkillScope::Global, &root).await.unwrap().is_empty()); assert!(s.list(SkillScope::Project, &root).await.unwrap().is_empty()); } #[tokio::test] async fn global_roundtrip_save_get_list() { let tmp = TempDir::new(); let s = store(&tmp); let root = tmp.project_root(); let sk = skill(sid(1), "refactor", "# Refactor workflow", SkillScope::Global); s.save(&sk, &root).await.unwrap(); assert_eq!(s.get(SkillScope::Global, &root, sid(1)).await.unwrap(), sk); assert_eq!( s.list(SkillScope::Global, &root).await.unwrap(), vec![sk.clone()] ); // The Markdown landed under the global skills dir. let fs = LocalFileSystem::new(); let bytes = fs .read(&tmp.child(&format!("skills/md/{}.md", sid(1)))) .await .unwrap(); assert_eq!(String::from_utf8(bytes).unwrap(), sk.content_md.as_str()); } #[tokio::test] async fn project_roundtrip_lands_under_ideai() { let tmp = TempDir::new(); let s = store(&tmp); let root = tmp.project_root(); let sk = skill(sid(1), "review", "# Review workflow", SkillScope::Project); s.save(&sk, &root).await.unwrap(); assert_eq!(s.get(SkillScope::Project, &root, sid(1)).await.unwrap(), sk); let fs = LocalFileSystem::new(); let bytes = fs .read(&tmp.child(&format!("project/.ideai/skills/md/{}.md", sid(1)))) .await .unwrap(); assert_eq!(String::from_utf8(bytes).unwrap(), sk.content_md.as_str()); } #[tokio::test] async fn scopes_are_isolated() { let tmp = TempDir::new(); let s = store(&tmp); let root = tmp.project_root(); s.save(&skill(sid(1), "g", "global body", SkillScope::Global), &root) .await .unwrap(); s.save( &skill(sid(2), "p", "project body", SkillScope::Project), &root, ) .await .unwrap(); // A global skill never surfaces in the project scope and vice-versa. let globals = s.list(SkillScope::Global, &root).await.unwrap(); let projects = s.list(SkillScope::Project, &root).await.unwrap(); assert_eq!(globals.len(), 1); assert_eq!(projects.len(), 1); assert_eq!(globals[0].id, sid(1)); assert_eq!(projects[0].id, sid(2)); assert!(matches!( s.get(SkillScope::Global, &root, sid(2)).await.unwrap_err(), StoreError::NotFound )); assert!(matches!( s.get(SkillScope::Project, &root, sid(1)).await.unwrap_err(), StoreError::NotFound )); } #[tokio::test] async fn save_upserts_content() { let tmp = TempDir::new(); let s = store(&tmp); let root = tmp.project_root(); s.save(&skill(sid(1), "k", "v1", SkillScope::Global), &root) .await .unwrap(); s.save(&skill(sid(1), "k", "v2", SkillScope::Global), &root) .await .unwrap(); assert_eq!( s.get(SkillScope::Global, &root, sid(1)) .await .unwrap() .content_md .as_str(), "v2" ); assert_eq!( s.list(SkillScope::Global, &root).await.unwrap().len(), 1, "upsert, not append" ); } #[tokio::test] async fn delete_removes_from_index_and_is_not_found_twice() { let tmp = TempDir::new(); let s = store(&tmp); let root = tmp.project_root(); s.save(&skill(sid(1), "k", "x", SkillScope::Project), &root) .await .unwrap(); s.delete(SkillScope::Project, &root, sid(1)).await.unwrap(); assert!(s.list(SkillScope::Project, &root).await.unwrap().is_empty()); assert!(matches!( s.delete(SkillScope::Project, &root, sid(1)) .await .unwrap_err(), StoreError::NotFound )); } #[tokio::test] async fn index_is_camelcase_with_content_hash() { let tmp = TempDir::new(); let s = store(&tmp); let root = tmp.project_root(); s.save(&skill(sid(1), "refactor", "hello", SkillScope::Global), &root) .await .unwrap(); let fs = LocalFileSystem::new(); let bytes = fs.read(&tmp.child("skills/index.json")).await.unwrap(); let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); let entry = &json.get("skills").unwrap().as_array().unwrap()[0]; assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("refactor")); assert!( entry.get("contentHash").is_some(), "camelCase contentHash present" ); assert!( entry.get("content_hash").is_none(), "no snake_case leak" ); }