//! [`FsProjectStore`] — JSON file implementation of the [`ProjectStore`] port. //! //! Persistence layout (under the injected app-data directory, ARCHITECTURE §9.2): //! //! ```text //! / //! ├── projects.json # the known-projects registry { version, projects: [Project, ...] } //! └── workspace.json # the persisted Workspace (windows/tabs/layouts) //! ``` //! //! The store does **not** know about Tauri: the app-data directory is resolved //! by the composition root and handed in as a plain path (Dependency Inversion). //! All I/O goes through the [`FileSystem`] port (here [`LocalFileSystem`]) so the //! store stays decoupled from `tokio::fs` directly and is reusable as-is. use std::sync::Arc; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use domain::ids::ProjectId; use domain::layout::Workspace; use domain::ports::{FileSystem, ProjectStore, RemotePath, StoreError}; use domain::project::Project; /// File name of the known-projects registry inside the app-data dir. const REGISTRY_FILE: &str = "projects.json"; /// File name of the persisted workspace inside the app-data dir. const WORKSPACE_FILE: &str = "workspace.json"; /// Current schema version of the registry file. const REGISTRY_VERSION: u32 = 1; /// On-disk shape of the registry file. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct Registry { /// Schema version. version: u32, /// All known projects. projects: Vec, } /// JSON-file implementation of the [`ProjectStore`] port. /// /// Cheap to clone (everything is behind `Arc`); the composition root constructs /// it once and shares it across use cases. #[derive(Clone)] pub struct FsProjectStore { fs: Arc, app_data_dir: String, } impl FsProjectStore { /// Builds the store from an injected [`FileSystem`] and the app-data /// directory path (resolved by the composition root, e.g. via the Tauri path /// API). 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 a file name using a POSIX separator (valid on /// every target — `tokio::fs` accepts `/` on Windows too). fn path(&self, file: &str) -> RemotePath { let base = self.app_data_dir.trim_end_matches(['/', '\\']); RemotePath::new(format!("{base}/{file}")) } /// Reads and parses the registry, returning an empty one if the file does /// not exist yet. async fn read_registry(&self) -> Result { let path = self.path(REGISTRY_FILE); match self.fs.read(&path).await { Ok(bytes) => { serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string())) } Err(domain::ports::FsError::NotFound(_)) => Ok(Registry { version: REGISTRY_VERSION, projects: Vec::new(), }), Err(e) => Err(StoreError::Io(e.to_string())), } } /// Writes the registry, ensuring the app-data dir exists first. async fn write_registry(&self, registry: &Registry) -> Result<(), StoreError> { self.ensure_dir().await?; let bytes = serde_json::to_vec_pretty(registry) .map_err(|e| StoreError::Serialization(e.to_string()))?; self.fs .write(&self.path(REGISTRY_FILE), &bytes) .await .map_err(|e| StoreError::Io(e.to_string())) } /// Creates the app-data directory and all missing parents. async fn ensure_dir(&self) -> 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())) } } #[async_trait] impl ProjectStore for FsProjectStore { async fn list_projects(&self) -> Result, StoreError> { Ok(self.read_registry().await?.projects) } async fn load_project(&self, id: ProjectId) -> Result { self.read_registry() .await? .projects .into_iter() .find(|p| p.id == id) .ok_or(StoreError::NotFound) } async fn save_project(&self, project: &Project) -> Result<(), StoreError> { let mut registry = self.read_registry().await?; if let Some(slot) = registry.projects.iter_mut().find(|p| p.id == project.id) { *slot = project.clone(); } else { registry.projects.push(project.clone()); } self.write_registry(®istry).await } async fn save_workspace(&self, workspace: &Workspace) -> Result<(), StoreError> { self.ensure_dir().await?; let bytes = serde_json::to_vec_pretty(workspace) .map_err(|e| StoreError::Serialization(e.to_string()))?; self.fs .write(&self.path(WORKSPACE_FILE), &bytes) .await .map_err(|e| StoreError::Io(e.to_string())) } async fn load_workspace(&self) -> Result { let path = self.path(WORKSPACE_FILE); match self.fs.read(&path).await { Ok(bytes) => { serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string())) } // No workspace persisted yet: return the empty default. Err(domain::ports::FsError::NotFound(_)) => Ok(Workspace::default()), Err(e) => Err(StoreError::Io(e.to_string())), } } }