- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés, environnement local détecté, stratégies compilées). UI EmbedderSettings. - LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la mémoire dépasse le budget de recall sans embedder configuré (event EmbedderSuggested, anti-spam 1×/session, « ne plus demander »). - Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les agents/profils au lancement, avant la persona. UI ProjectContextPanel. Tests : backend workspace vert (0 échec) ; frontend 306/306. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
158 lines
5.7 KiB
Rust
158 lines
5.7 KiB
Rust
//! [`FsProjectStore`] — JSON file implementation of the [`ProjectStore`] port.
|
|
//!
|
|
//! Persistence layout (under the injected app-data directory, ARCHITECTURE §9.2):
|
|
//!
|
|
//! ```text
|
|
//! <app_data_dir>/
|
|
//! ├── 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<Project>,
|
|
}
|
|
|
|
/// 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<dyn FileSystem>,
|
|
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<dyn FileSystem>, app_data_dir: impl Into<String>) -> 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<Registry, StoreError> {
|
|
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<Vec<Project>, StoreError> {
|
|
Ok(self.read_registry().await?.projects)
|
|
}
|
|
|
|
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
|
|
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<Workspace, StoreError> {
|
|
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())),
|
|
}
|
|
}
|
|
}
|