//! [`FsProfileStore`] — JSON file implementation of the [`ProfileStore`] port. //! //! Persists the configured [`AgentProfile`]s in the global IDE store //! (ARCHITECTURE §9.2): //! //! ```text //! / //! └── profiles.json # { version, profiles: [AgentProfile, ...] } //! ``` //! //! Each profile item is exactly the declarative profile of CONTEXT §9 //! (`id, name, command, args, contextInjection{strategy,…}, detect, cwd`). The //! existence of `profiles.json` is what marks the first run as *done* (see //! [`ProfileStore::is_configured`]). //! //! Like [`super::FsProjectStore`], the store is Tauri-agnostic: the app-data //! directory is resolved by the composition root and injected as a plain path, //! and all I/O goes through the [`FileSystem`] port. use std::sync::Arc; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use domain::ids::ProfileId; use domain::ports::{EmbedderProfileStore, FileSystem, ProfileStore, RemotePath, StoreError}; use domain::profile::{AgentProfile, EmbedderProfile}; /// File name of the profiles store inside the app-data dir. const PROFILES_FILE: &str = "profiles.json"; /// Current schema version of the profiles file. const PROFILES_VERSION: u32 = 1; /// On-disk shape of `profiles.json`. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct ProfilesDoc { /// Schema version. version: u32, /// All configured profiles. profiles: Vec, } impl Default for ProfilesDoc { fn default() -> Self { Self { version: PROFILES_VERSION, profiles: Vec::new(), } } } /// JSON-file implementation of the [`ProfileStore`] port. /// /// Cheap to clone (everything is behind `Arc`); built once at the composition /// root and shared across use cases. #[derive(Clone)] pub struct FsProfileStore { fs: Arc, app_data_dir: String, } impl FsProfileStore { /// Builds the store from an injected [`FileSystem`] and the app-data /// directory path (resolved by the composition root). The directory is /// created lazily 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(), } } /// Joins the app-data dir with the profiles file name (POSIX separator, valid /// on every target — `tokio::fs` accepts `/` on Windows too). fn path(&self) -> RemotePath { let base = self.app_data_dir.trim_end_matches(['/', '\\']); RemotePath::new(format!("{base}/{PROFILES_FILE}")) } /// Reads and parses the doc, returning an empty default if the file is absent. async fn read_doc(&self) -> Result { match self.fs.read(&self.path()).await { Ok(bytes) => { serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string())) } Err(domain::ports::FsError::NotFound(_)) => Ok(ProfilesDoc::default()), Err(e) => Err(StoreError::Io(e.to_string())), } } /// Writes the doc, ensuring the app-data dir exists first. async fn write_doc(&self, doc: &ProfilesDoc) -> Result<(), StoreError> { let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned()); self.fs .create_dir_all(&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.path(), &bytes) .await .map_err(|e| StoreError::Io(e.to_string())) } } #[async_trait] impl ProfileStore for FsProfileStore { async fn list(&self) -> Result, StoreError> { Ok(self.read_doc().await?.profiles) } async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> { let mut doc = self.read_doc().await?; if let Some(slot) = doc.profiles.iter_mut().find(|p| p.id == profile.id) { *slot = profile.clone(); } else { doc.profiles.push(profile.clone()); } self.write_doc(&doc).await } async fn delete(&self, id: ProfileId) -> Result<(), StoreError> { let mut doc = self.read_doc().await?; let before = doc.profiles.len(); doc.profiles.retain(|p| p.id != id); if doc.profiles.len() == before { return Err(StoreError::NotFound); } self.write_doc(&doc).await } async fn is_configured(&self) -> Result { self.fs .exists(&self.path()) .await .map_err(|e| StoreError::Io(e.to_string())) } async fn mark_configured(&self) -> Result<(), StoreError> { // Write the current doc back (an empty default when nothing exists), // which materialises `profiles.json` and records first-run completion. let doc = self.read_doc().await?; self.write_doc(&doc).await } } /// **Merge-on-read decorator** over any [`ProfileStore`]: backfills *internal* /// reference defaults that a stored profile is missing, without ever persisting /// anything (finding A — propagate the measured `prompt_ready_pattern` to profiles /// saved before that field existed). /// /// Only [`list`](ProfileStore::list) is transformed — each returned profile is run /// through [`application::overlay_reference_defaults`] (pure, allowlisted, never /// clobbers a user value, idempotent). `save`/`delete`/`is_configured`/ /// `mark_configured` **delegate verbatim** so persistence and first-run detection are /// byte-for-byte unchanged. Wrap the concrete [`FsProfileStore`] once at the /// composition root, before injecting the store into use cases and the orchestrator, /// so every reader (including direct `.list()` calls) sees the overlaid reads. pub struct ReferenceBackfillProfileStore { inner: Arc, } impl ReferenceBackfillProfileStore { /// Wraps an inner [`ProfileStore`]; reads are overlaid, writes pass through. #[must_use] pub fn new(inner: Arc) -> Self { Self { inner } } } #[async_trait] impl ProfileStore for ReferenceBackfillProfileStore { async fn list(&self) -> Result, StoreError> { Ok(self .inner .list() .await? .into_iter() .map(|p| application::overlay_reference_defaults(&p)) .collect()) } async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> { self.inner.save(profile).await } async fn delete(&self, id: ProfileId) -> Result<(), StoreError> { self.inner.delete(id).await } async fn is_configured(&self) -> Result { self.inner.is_configured().await } async fn mark_configured(&self) -> Result<(), StoreError> { self.inner.mark_configured().await } } // --------------------------------------------------------------------------- // FsEmbedderProfileStore — declarative embedder profiles (`embedder.json`, LOT C). // --------------------------------------------------------------------------- /// File name of the embedder-profiles store inside the app-data dir. const EMBEDDER_FILE: &str = "embedder.json"; /// Current schema version of the embedder-profiles file. const EMBEDDER_VERSION: u32 = 1; /// On-disk shape of `embedder.json` (LOT C, §14.5.3), mirroring [`ProfilesDoc`]. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct EmbedderDoc { /// Schema version. version: u32, /// All configured embedder profiles. profiles: Vec, } impl Default for EmbedderDoc { fn default() -> Self { Self { version: EMBEDDER_VERSION, profiles: Vec::new(), } } } /// JSON-file store for declarative [`EmbedderProfile`]s (LOT C, étage 2 vectoriel), /// calqué on [`FsProfileStore`]: `embedder.json` in the global IDE app-data dir, /// mirroring `profiles.json`. /// /// Implements the domain [`EmbedderProfileStore`] port (parallel to [`ProfileStore`]). /// The inherent `list`/`save`/`delete` methods are kept so the composition root can /// load the configured profile *before* type-erasing to `Arc` /// (e.g. inside a one-shot blocking runtime in `state.rs`); the trait impl simply /// delegates to them. /// /// Cheap to clone (everything behind `Arc`). #[derive(Clone)] pub struct FsEmbedderProfileStore { fs: Arc, app_data_dir: String, } impl FsEmbedderProfileStore { /// Builds the store from an injected [`FileSystem`] and the app-data dir. #[must_use] pub fn new(fs: Arc, app_data_dir: impl Into) -> Self { Self { fs, app_data_dir: app_data_dir.into(), } } /// `/embedder.json`. fn path(&self) -> RemotePath { let base = self.app_data_dir.trim_end_matches(['/', '\\']); RemotePath::new(format!("{base}/{EMBEDDER_FILE}")) } async fn read_doc(&self) -> Result { match self.fs.read(&self.path()).await { Ok(bytes) => { serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string())) } Err(domain::ports::FsError::NotFound(_)) => Ok(EmbedderDoc::default()), Err(e) => Err(StoreError::Io(e.to_string())), } } async fn write_doc(&self, doc: &EmbedderDoc) -> Result<(), StoreError> { let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned()); self.fs .create_dir_all(&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.path(), &bytes) .await .map_err(|e| StoreError::Io(e.to_string())) } /// Lists all configured embedder profiles (empty when `embedder.json` is /// absent — the default `none` posture). /// /// # Errors /// [`StoreError`] on an I/O or deserialisation failure. pub async fn list(&self) -> Result, StoreError> { Ok(self.read_doc().await?.profiles) } /// Saves (creates or replaces by id) an embedder profile. /// /// # Errors /// [`StoreError`] on failure. pub async fn save(&self, profile: &EmbedderProfile) -> Result<(), StoreError> { let mut doc = self.read_doc().await?; if let Some(slot) = doc.profiles.iter_mut().find(|p| p.id == profile.id) { *slot = profile.clone(); } else { doc.profiles.push(profile.clone()); } self.write_doc(&doc).await } /// Deletes an embedder profile by id. /// /// # Errors /// [`StoreError::NotFound`] if no profile carries that id. pub async fn delete(&self, id: &str) -> Result<(), StoreError> { let mut doc = self.read_doc().await?; let before = doc.profiles.len(); doc.profiles.retain(|p| p.id != id); if doc.profiles.len() == before { return Err(StoreError::NotFound); } self.write_doc(&doc).await } } #[async_trait] impl EmbedderProfileStore for FsEmbedderProfileStore { async fn list(&self) -> Result, StoreError> { FsEmbedderProfileStore::list(self).await } async fn save(&self, profile: &EmbedderProfile) -> Result<(), StoreError> { FsEmbedderProfileStore::save(self, profile).await } async fn delete(&self, id: &str) -> Result<(), StoreError> { FsEmbedderProfileStore::delete(self, id).await } } #[cfg(test)] mod backfill_tests { use super::*; use std::sync::Mutex; /// In-memory [`ProfileStore`] fake recording mutating/query calls, so we can prove /// the decorator transforms ONLY `list` and delegates the rest verbatim. #[derive(Default)] struct FakeStore { profiles: Mutex>, saved: Mutex>, deleted: Mutex>, is_configured_calls: Mutex, mark_configured_calls: Mutex, } #[async_trait] impl ProfileStore for FakeStore { async fn list(&self) -> Result, StoreError> { Ok(self.profiles.lock().unwrap().clone()) } async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> { self.saved.lock().unwrap().push(profile.clone()); Ok(()) } async fn delete(&self, id: ProfileId) -> Result<(), StoreError> { self.deleted.lock().unwrap().push(id); Ok(()) } async fn is_configured(&self) -> Result { *self.is_configured_calls.lock().unwrap() += 1; Ok(true) } async fn mark_configured(&self) -> Result<(), StoreError> { *self.mark_configured_calls.lock().unwrap() += 1; Ok(()) } } /// Claude reference profile with the internal field cleared — the persisted-before /// shape the overlay backfills. fn claude_without_pattern() -> AgentProfile { let id = application::reference_profile_id("claude"); let mut p = application::reference_profiles() .into_iter() .find(|p| p.id == id) .expect("claude reference exists"); p.prompt_ready_pattern = None; p } #[tokio::test] async fn list_overlays_reference_defaults() { let fake = Arc::new(FakeStore::default()); fake.profiles.lock().unwrap().push(claude_without_pattern()); let store = ReferenceBackfillProfileStore::new(Arc::clone(&fake) as Arc); let listed = store.list().await.expect("list ok"); assert_eq!(listed.len(), 1); assert_eq!( listed[0].prompt_ready_pattern.as_deref(), Some("? for shortcuts"), "a stored Claude profile WITHOUT the pattern is backfilled on read" ); } #[tokio::test] async fn save_delete_and_config_delegate_verbatim() { let fake = Arc::new(FakeStore::default()); let store = ReferenceBackfillProfileStore::new(Arc::clone(&fake) as Arc); let p = claude_without_pattern(); store.save(&p).await.expect("save ok"); store.delete(p.id).await.expect("delete ok"); assert!(store.is_configured().await.expect("is_configured ok")); store.mark_configured().await.expect("mark_configured ok"); // save passes the profile through UNCHANGED (no overlay on the write path). assert_eq!(*fake.saved.lock().unwrap(), vec![p.clone()]); assert_eq!(*fake.deleted.lock().unwrap(), vec![p.id]); assert_eq!(*fake.is_configured_calls.lock().unwrap(), 1); assert_eq!(*fake.mark_configured_calls.lock().unwrap(), 1); } }