fix: fix some displays and features
This commit is contained in:
@ -32,4 +32,6 @@ pub use process::LocalProcessSpawner;
|
||||
pub use pty::PortablePtyAdapter;
|
||||
pub use remote::{remote_host, LocalHost};
|
||||
pub use runtime::CliAgentRuntime;
|
||||
pub use store::{FsProfileStore, FsProjectStore, FsTemplateStore, IdeaiContextStore};
|
||||
pub use store::{
|
||||
FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, IdeaiContextStore,
|
||||
};
|
||||
|
||||
@ -7,9 +7,11 @@
|
||||
mod context;
|
||||
mod profile;
|
||||
mod project;
|
||||
mod skill;
|
||||
mod template;
|
||||
|
||||
pub use context::IdeaiContextStore;
|
||||
pub use profile::FsProfileStore;
|
||||
pub use project::FsProjectStore;
|
||||
pub use skill::FsSkillStore;
|
||||
pub use template::FsTemplateStore;
|
||||
|
||||
263
crates/infrastructure/src/store/skill.rs
Normal file
263
crates/infrastructure/src/store/skill.rs
Normal file
@ -0,0 +1,263 @@
|
||||
//! [`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
|
||||
//! <app_data_dir>/skills/ # SkillScope::Global (shared across projects)
|
||||
//! <project_root>/.ideai/skills/ # SkillScope::Project (travels with the code)
|
||||
//! ├── index.json # { version, skills: [{ id, name, contentHash }] }
|
||||
//! └── md/
|
||||
//! └── <id>.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/<id>.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,
|
||||
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<IndexEntry>,
|
||||
}
|
||||
|
||||
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<dyn FileSystem>,
|
||||
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<dyn FileSystem>, app_data_dir: impl Into<String>) -> 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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `<scope-dir>/index.json`.
|
||||
fn index_path(&self, scope: SkillScope, root: &ProjectPath) -> RemotePath {
|
||||
RemotePath::new(format!("{}/{INDEX_FILE}", self.dir(scope, root)))
|
||||
}
|
||||
|
||||
/// `<scope-dir>/md/<id>.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<IndexDoc, StoreError> {
|
||||
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<Skill, StoreError> {
|
||||
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_err(|e| StoreError::Serialization(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SkillStore for FsSkillStore {
|
||||
async fn list(&self, scope: SkillScope, root: &ProjectPath) -> Result<Vec<Skill>, 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<Skill, StoreError> {
|
||||
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(),
|
||||
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/<id>.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
|
||||
}
|
||||
}
|
||||
211
crates/infrastructure/tests/skill_store.rs
Normal file
211
crates/infrastructure/tests/skill_store.rs
Normal file
@ -0,0 +1,211 @@
|
||||
//! 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<dyn FileSystem> = 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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user