//! [`FsLiveStateStore`] — l'adapter `tokio::fs` du port [`LiveStateStore`] //! (programme live-state, lot LS2). //! //! Le **live-state** est la projection volatile « qui fait quoi en ce moment » : //! une entrée [`LiveEntry`](domain::live_state::LiveEntry) par agent, sémantique //! *keyed last-writer-wins* (jamais un journal). On en garde **un seul** fichier //! par projet, à la racine du `.ideai/` : //! //! ```text //! /.ideai/live-state.json //! ``` //! //! Le format est le JSON camelCase de [`LiveState`](domain::live_state::LiveState) //! (dérivé serde du domaine), lisible à l'œil. //! //! ## Robustesse //! //! - **Écriture atomique** : on écrit dans `live-state.json.tmp` puis on `rename` //! — un lecteur ne voit jamais de fichier à moitié écrit (le `rename` est //! atomique sur le FS). Même pattern que [`FsHandoffStore`](crate::FsHandoffStore). //! - **Fichier absent** ⇒ [`LiveState`] vide (jamais une erreur) : c'est l'état //! normal d'un projet qui n'a encore rien publié. //! - **Fichier présent mais illisible** (JSON corrompu/partiel) ⇒ //! [`StoreError::Serialization`]. Choix **aligné** sur les autres stores FS //! (`FsHandoffStore`, l'`index.json` de `FsSkillStore`) : on ne panique jamais, //! mais on ne masque pas non plus une corruption — l'écriture atomique rend ce //! cas très improbable, et le fichier étant runtime/non-versionné, le supprimer //! suffit à repartir d'un état vide. //! //! Le project root est fourni au constructeur (le port [`LiveStateStore`] n'a pas //! de paramètre `root` par appel, contrairement à `SkillStore`/`MemoryStore`) ; //! une instance sert donc **un** projet. use std::path::PathBuf; use async_trait::async_trait; use domain::live_state::{LiveEntry, LiveState}; use domain::ports::{LiveStateStore, StoreError}; use domain::project::ProjectPath; /// Le dossier `.ideai/` dans une racine de projet. const IDEAI_DIR: &str = ".ideai"; /// Nom du fichier de live-state, par projet. const LIVE_STATE_FILE: &str = "live-state.json"; /// Nom du fichier temporaire d'écriture atomique (renommé sur `live-state.json`). const LIVE_STATE_TMP_FILE: &str = "live-state.json.tmp"; /// Adapter `tokio::fs` du store de live-state, un `live-state.json` par projet. /// /// Même convention de construction que [`FsHandoffStore`](crate::FsHandoffStore) : /// le **project root** est fourni au constructeur, la base `/.ideai` en /// dérive, et le fichier est créé paresseusement à la première écriture. pub struct FsLiveStateStore { /// Racine `/.ideai`. dir: PathBuf, } impl FsLiveStateStore { /// Construit l'adapter à partir du **project root**. #[must_use] pub fn new(root: &ProjectPath) -> Self { let dir = PathBuf::from(root.as_str()).join(IDEAI_DIR); Self { dir } } /// `/.ideai/live-state.json` — le fichier cible. fn path(&self) -> PathBuf { self.dir.join(LIVE_STATE_FILE) } /// `/.ideai/live-state.json.tmp` — le tmp d'écriture atomique. fn tmp_path(&self) -> PathBuf { self.dir.join(LIVE_STATE_TMP_FILE) } /// Lit l'état courant : fichier absent ⇒ état vide ; JSON corrompu ⇒ erreur. async fn read_state(&self) -> Result { match tokio::fs::read(self.path()).await { Ok(bytes) => { serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string())) } // Absent ⇒ état vide (jamais une erreur). Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(LiveState::default()), Err(e) => Err(StoreError::Io(e.to_string())), } } /// Réécrit l'état **atomiquement** : tmp puis `rename` sur la cible. async fn write_state(&self, state: &LiveState) -> Result<(), StoreError> { tokio::fs::create_dir_all(&self.dir) .await .map_err(|e| StoreError::Io(e.to_string()))?; let bytes = serde_json::to_vec_pretty(state) .map_err(|e| StoreError::Serialization(e.to_string()))?; let tmp = self.tmp_path(); tokio::fs::write(&tmp, &bytes) .await .map_err(|e| StoreError::Io(e.to_string()))?; tokio::fs::rename(&tmp, self.path()) .await .map_err(|e| StoreError::Io(e.to_string()))?; Ok(()) } } #[async_trait] impl LiveStateStore for FsLiveStateStore { async fn load(&self) -> Result { self.read_state().await } async fn upsert(&self, entry: LiveEntry) -> Result<(), StoreError> { let mut state = self.read_state().await?; state.upsert(entry); self.write_state(&state).await } async fn prune(&self, now_ms: u64, ttl_ms: u64, max_n: usize) -> Result<(), StoreError> { let mut state = self.read_state().await?; state.prune(now_ms, ttl_ms, max_n); self.write_state(&state).await } }