//! [`FsHandoffStore`] — l'adapter `tokio::fs` du port [`HandoffStore`] //! (cadrage « persistance conversationnelle », lot P3). //! //! Le **point de reprise** d'une conversation (ARCHITECTURE §19.2/§19.3) est un //! [`Handoff`] : un résumé cumulatif Markdown borné par un curseur [`TurnId`]. On en //! garde **un seul** par conversation (le dernier), dans un fichier lisible à l'œil : //! //! ```text //! /.ideai/conversations/ //! └── / //! ├── log.jsonl # le log append-only (lot P2) //! └── handoff.md # le dernier point de reprise (ce module) //! ``` //! //! ## Format `handoff.md` //! //! Un **front-matter** YAML délimité par `---`, suivi du corps `summary_md` tel quel : //! //! ```text //! --- //! upTo: 00000000-0000-0000-0000-00000000002a //! objective: livrer le lot P3 //! --- //! # Résumé //! …le summary_md, octet pour octet… //! ``` //! //! Le front-matter porte le curseur `upTo` (toujours) et `objective` (seulement s'il //! est `Some`). Le corps après le second `---\n` est le `summary_md` **exact** : le //! round-trip (`save` puis `load`) redonne le même [`Handoff`]. `objective` étant //! sérialisé sur une seule ligne, un objectif est interdit de retour-chariot ici ; en //! pratique c'est une phrase courte (un titre de but), jamais du multi-ligne. //! //! ## Robustesse //! //! - **Écriture atomique** : on écrit dans `handoff.md.tmp` puis on `rename` — jamais //! de fichier à moitié écrit visible. //! - **Fichier absent** ⇒ `Ok(None)` (jamais une erreur) : une conversation sans //! reprise est l'état normal au premier tour. //! - **Fichier présent mais illisible** (front-matter absent/incomplet, `upTo` //! manquant ou non-UUID) ⇒ [`StoreError::Serialization`]. Contrairement au log //! JSONL (où une ligne corrompue est sautée), un handoff corrompu est une vraie //! erreur : il n'y a qu'un enregistrement, on ne peut pas « sauter ». use std::path::PathBuf; use async_trait::async_trait; use domain::conversation::ConversationId; use domain::conversation_log::{Handoff, HandoffStore, TurnId}; use domain::ports::StoreError; use domain::project::ProjectPath; use super::{CONVERSATIONS_DIR, IDEAI_DIR}; /// Nom du fichier de handoff, par conversation. const HANDOFF_FILE: &str = "handoff.md"; /// Nom du fichier temporaire d'écriture atomique (renommé sur `handoff.md`). const HANDOFF_TMP_FILE: &str = "handoff.md.tmp"; /// Délimiteur de front-matter (en tête et fin de l'entête). const FRONT_MATTER_FENCE: &str = "---"; /// Adapter `tokio::fs` du store de handoff, un `handoff.md` par conversation. /// /// Même convention de construction que [`super::FsConversationLog`] : le **project /// root** est fourni au constructeur, la base `/.ideai/conversations` en dérive, /// et chaque conversation a son sous-dossier `/`. pub struct FsHandoffStore { /// Racine `/.ideai/conversations`. base: PathBuf, } impl FsHandoffStore { /// Construit l'adapter à partir du **project root**. /// /// La base `/.ideai/conversations` en est dérivée ; le dossier de /// conversation est créé paresseusement au premier `save`. #[must_use] pub fn new(root: &ProjectPath) -> Self { let base = PathBuf::from(root.as_str()) .join(IDEAI_DIR) .join(CONVERSATIONS_DIR); Self { base } } /// `/` — le dossier d'une conversation. fn conversation_dir(&self, conversation: ConversationId) -> PathBuf { self.base.join(conversation.to_string()) } /// `//handoff.md` — le fichier de handoff d'une conversation. fn handoff_path(&self, conversation: ConversationId) -> PathBuf { self.conversation_dir(conversation).join(HANDOFF_FILE) } /// `//handoff.md.tmp` — le fichier temporaire d'écriture. fn handoff_tmp_path(&self, conversation: ConversationId) -> PathBuf { self.conversation_dir(conversation).join(HANDOFF_TMP_FILE) } } /// Sérialise un [`Handoff`] au format `handoff.md` (front-matter + corps exact). fn serialize(handoff: &Handoff) -> String { let mut out = String::new(); out.push_str(FRONT_MATTER_FENCE); out.push('\n'); out.push_str(&format!("upTo: {}\n", handoff.up_to)); if let Some(objective) = &handoff.objective { out.push_str(&format!("objective: {objective}\n")); } out.push_str(FRONT_MATTER_FENCE); out.push('\n'); // Corps : le summary_md tel quel, octet pour octet. out.push_str(&handoff.summary_md); out } /// Parse le contenu d'un `handoff.md` en [`Handoff`]. /// /// Format attendu : `---\n*\n---\n`. Le corps après le second /// `---\n` est rendu **exact**. Toute déviation (front-matter absent/incomplet, `upTo` /// manquant ou non-UUID) ⇒ `Err(StoreError::Serialization)`. fn deserialize(content: &str) -> Result { let bad = |msg: &str| StoreError::Serialization(format!("handoff.md: {msg}")); // Entête : `---\n` exactement en tête. let after_open = content .strip_prefix(FRONT_MATTER_FENCE) .and_then(|rest| rest.strip_prefix('\n')) .ok_or_else(|| bad("front-matter ouvrant `---` absent"))?; // Fin de l'entête : la première ligne `---\n` (ou `---` en toute fin). // On sépare les lignes du front-matter du corps. let mut up_to: Option = None; let mut objective: Option = None; // Trouver le `---` de fermeture, ligne par ligne. let mut rest = after_open; loop { // Découpe la prochaine ligne (jusqu'au `\n` inclus si présent). let (line, tail) = match rest.find('\n') { Some(idx) => (&rest[..idx], &rest[idx + 1..]), None => (rest, ""), }; if line == FRONT_MATTER_FENCE { // Fermeture trouvée : `tail` est le corps exact. let up_to = up_to.ok_or_else(|| bad("clé `upTo` absente du front-matter"))?; return Ok(Handoff { summary_md: tail.to_string(), up_to, objective, }); } // Une ligne de clé: valeur dans le front-matter. let (key, value) = line .split_once(':') .ok_or_else(|| bad("ligne de front-matter sans `:`"))?; let value = value.trim(); match key.trim() { "upTo" => { let uuid = uuid::Uuid::parse_str(value) .map_err(|_| bad("`upTo` n'est pas un UUID valide"))?; up_to = Some(TurnId::from_uuid(uuid)); } "objective" => objective = Some(value.to_string()), // Clé inconnue : tolérée (extensible), ignorée. _ => {} } if tail.is_empty() { // Plus de lignes et toujours pas de `---` fermant. return Err(bad("front-matter fermant `---` absent")); } rest = tail; } } #[async_trait] impl HandoffStore for FsHandoffStore { async fn load(&self, conversation: ConversationId) -> Result, StoreError> { let content = match tokio::fs::read_to_string(self.handoff_path(conversation)).await { Ok(content) => content, // Absent ⇒ pas de reprise (jamais une erreur). Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(e) => return Err(StoreError::Io(e.to_string())), }; deserialize(&content).map(Some) } async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError> { let body = serialize(&handoff); let dir = self.conversation_dir(conversation); tokio::fs::create_dir_all(&dir) .await .map_err(|e| StoreError::Io(e.to_string()))?; // Écriture atomique : écrire le tmp puis `rename` sur la cible. Un lecteur ne // voit jamais de fichier à moitié écrit (le rename est atomique sur le FS). let tmp = self.handoff_tmp_path(conversation); tokio::fs::write(&tmp, body.as_bytes()) .await .map_err(|e| StoreError::Io(e.to_string()))?; tokio::fs::rename(&tmp, self.handoff_path(conversation)) .await .map_err(|e| StoreError::Io(e.to_string()))?; Ok(()) } }