//! [`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 //! / //! └── .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 //! `/.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, } impl IdeaiContextStore { /// Builds the store from an injected [`FileSystem`] (the project's /// `RemoteHost` filesystem). #[must_use] pub fn new(fs: Arc) -> 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 { 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 { 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 { 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(), orchestrator: None, }), 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())) } }