Skip to main content

gradatum_vault/
lib.rs

1//! # gradatum-vault
2//!
3//! Vault domain logic : registry + lifecycle + overrides + drift + effective_note cache.
4//!
5//! Couche L2 de l'architecture Gradatum — composition au-dessus des couches L1 :
6//! - `gradatum-core` : primitives, traits, erreurs.
7//! - `gradatum-markdown` : parse + write `.md`.
8//! - `gradatum-cache` : `EffectiveNoteCache` moka.
9//! - `gradatum-index` : `SqliteIndex` impl `Index` trait.
10//! - `gradatum-storage` : `FileStorage` OpenDAL.
11//!
12//! ## Modules
13//!
14//! - [`registry`] : `Vault::create` / `Vault::open` — layout init, tenant_id, handles.
15//! - [`lifecycle`] : `write_note` — ContentHash + persist .md + upsert index.
16//! - [`overrides`] : `NoteMetadataOverride` — `Overridable` + `OverridePayload` impl.
17//! - [`drift`] : `drift_check` — scan Phase A via `gradatum-index::scan_phase_a`.
18//! - [`effective_note`] : `get_effective_note` — cache moka avec validation checksum.
19//! - [`history`] : `NoteHistoryEntry` — entrée d'historique CoW.
20//! - [`error`] : `VaultError` — erreurs typées sans `Box<dyn Error>`.
21//!
22//! ## Stabilité
23//!
24//! `0.x` — aucune garantie de stabilité API.
25//! Voir [RELEASE-POLICY.md](https://github.com/gradatum/gradatum/blob/main/RELEASE-POLICY.md).
26
27#![forbid(unsafe_code)]
28#![warn(missing_docs)]
29#![warn(rust_2018_idioms)]
30
31pub mod drift;
32pub mod effective_note;
33pub mod error;
34pub mod history;
35pub mod lifecycle;
36pub mod overrides;
37pub mod registry;
38pub mod write;
39
40pub use error::VaultError;
41pub use history::NoteHistoryEntry;
42pub use lifecycle::HISTORY_DIR_PREFIX;
43pub use overrides::NoteMetadataOverride;
44pub use registry::Vault;
45pub use write::WriteResult;
46
47// ── Registry trait (T2 P2.0c) ────────────────────────────────────────────────
48
49/// Trait d'accès registre vault — exposé à `AppState` pour découpler le serveur
50/// de l'implémentation concrète `Vault`.
51///
52/// Méthodes async via `async_trait` — compatible `Arc<dyn Registry>`.
53///
54/// ## Implémenteurs
55///
56/// - [`Vault`] : implémentation réelle depuis l'index SQLite.
57/// - `PlaceholderRegistry` (dans `gradatum-server`) : stub retournant 0/0
58///   pour les constructeurs sync avant injection du chemin vault.
59#[async_trait::async_trait]
60pub trait Registry: Send + Sync {
61    /// Nombre de tenants (vault_id distincts) dans l'index.
62    ///
63    /// Retourne 0 si le vault est vide ou pas encore initialisé.
64    async fn tenant_count(&self) -> Result<u32, gradatum_core::error::GradatumError>;
65
66    /// Nombre de loci distincts (paires vault_id + locus) dans l'index.
67    ///
68    /// Un locus est l'unité d'organisation sub-tenant.
69    /// Retourne 0 si aucune note n'est indexée.
70    async fn locus_count(&self) -> Result<u32, gradatum_core::error::GradatumError>;
71
72    /// S'assure qu'un tenant existe dans le registre.
73    ///
74    /// Idempotent — peut être appelé plusieurs fois sans effet de bord.
75    async fn ensure_tenant(
76        &self,
77        tenant_id: &str,
78    ) -> Result<(), gradatum_core::error::GradatumError>;
79
80    /// Lit une note par identifiant ULID (string) depuis le vault.
81    ///
82    /// T4 P2.0c : implémentation réelle avec cache hit/miss, checksum B22, disk read.
83    ///
84    /// ## Comportement
85    ///
86    /// - Cache hit valide → retour immédiat, compteur cache_hits incrémenté.
87    /// - Cache miss → `index.get_note` + `storage.read(.md)` + `parse` + insert cache.
88    ///
89    /// ## Erreurs
90    ///
91    /// - `GradatumError::NoteNotFound` si l'identifiant est absent de l'index.
92    /// - `GradatumError::Storage` si la lecture disque échoue.
93    async fn read_note_by_id(
94        &self,
95        note_id: &str,
96    ) -> Result<gradatum_core::note::Note, gradatum_core::error::GradatumError>;
97
98    /// F-40 — Liste les timestamps (ms Unix) des snapshots historiques d'une note.
99    ///
100    /// Retourne un `Vec<i64>` trié croissant (plus ancien en premier).
101    /// Liste vide si aucun historique n'existe ou si note inconnue.
102    async fn history_versions(
103        &self,
104        note_id: &str,
105    ) -> Result<Vec<i64>, gradatum_core::error::GradatumError>;
106
107    /// F-40 — Lit le contenu d'un snapshot historique.
108    ///
109    /// `ts_ms` est un timestamp issu de `history_versions`.
110    ///
111    /// ## Erreurs
112    ///
113    /// - `GradatumError::Storage` si le snapshot est introuvable.
114    /// - `GradatumError::Markdown` si le parsing échoue.
115    async fn history_get(
116        &self,
117        note_id: &str,
118        ts_ms: i64,
119    ) -> Result<gradatum_core::note::Note, gradatum_core::error::GradatumError>;
120
121    /// F-40 — Restaure une note depuis un snapshot historique.
122    ///
123    /// Équivalent à écrire le snapshot comme nouvelle version courante (déclenche un CoW).
124    /// L'id de la note est préservé. Retourne le hash SHA-256 hex de la version restaurée.
125    ///
126    /// ## Erreurs
127    ///
128    /// - `GradatumError::Storage` si le snapshot est introuvable.
129    /// - `GradatumError::Markdown` si le parsing du snapshot échoue.
130    async fn history_restore(
131        &self,
132        note_id: &str,
133        ts_ms: i64,
134    ) -> Result<String, gradatum_core::error::GradatumError>;
135
136    /// F-40 — Diff brut ligne-à-ligne entre deux versions.
137    ///
138    /// `a` et `b` sont des timestamps issus de `history_versions`, ou `"current"` pour
139    /// la version courante. Retourne une liste de lignes diff (préfixe `-`/`+`/` `).
140    ///
141    /// Diff implémentation : diff brut ligne-à-ligne (PAS Myers) — suffisant pour
142    /// l'usage MCP (lisibilité > compacité).
143    async fn history_diff(
144        &self,
145        note_id: &str,
146        a: &str,
147        b: &str,
148    ) -> Result<Vec<String>, gradatum_core::error::GradatumError>;
149
150    /// Met à jour le statut d'une note avec validation de la state machine.
151    ///
152    /// Seules les transitions définies dans `NoteStatus::can_transition_to` sont
153    /// autorisées. `target == current` est un no-op silencieux (idempotence).
154    /// Chaque transition réussie est tracée dans `.history/` (Copy-on-Write).
155    ///
156    /// ## Erreurs
157    ///
158    /// - `GradatumError::NoteNotFound` si la note est absente.
159    /// - `GradatumError::InvalidStatusTransition { from, to }` si la transition
160    ///   n'est pas autorisée par le graphe.
161    /// - `GradatumError::Storage` / `GradatumError::Markdown` sur erreur I/O.
162    async fn update_note_status(
163        &self,
164        note_id: &str,
165        target: gradatum_core::status::NoteStatus,
166        reason: Option<String>,
167    ) -> Result<(), gradatum_core::error::GradatumError>;
168}
169
170#[async_trait::async_trait]
171impl Registry for Vault {
172    async fn tenant_count(&self) -> Result<u32, gradatum_core::error::GradatumError> {
173        self.index.vault_id_count().await
174    }
175
176    async fn locus_count(&self) -> Result<u32, gradatum_core::error::GradatumError> {
177        self.index.locus_count().await
178    }
179
180    async fn ensure_tenant(
181        &self,
182        tenant_id: &str,
183    ) -> Result<(), gradatum_core::error::GradatumError> {
184        self.index.ensure_vault_id(tenant_id).await
185    }
186
187    async fn read_note_by_id(
188        &self,
189        note_id: &str,
190    ) -> Result<gradatum_core::note::Note, gradatum_core::error::GradatumError> {
191        use gradatum_core::error::GradatumError;
192        use ulid::Ulid;
193
194        let ulid = Ulid::from_string(note_id).map_err(|e| {
195            GradatumError::Storage(format!("read_note_by_id : ULID invalide {note_id:?} : {e}"))
196        })?;
197        let id = gradatum_core::identity::NoteId(ulid);
198
199        self.read_note(id).await.map_err(|e| match e {
200            crate::error::VaultError::Core(inner) => inner,
201            crate::error::VaultError::Storage(msg) => GradatumError::Storage(msg),
202            crate::error::VaultError::Markdown(msg) => {
203                GradatumError::Markdown(format!("read_note_by_id : {msg}"))
204            }
205            // Conflict ne peut pas survenir via read_note — variante défensive.
206            crate::error::VaultError::Conflict(hash) => GradatumError::Storage(format!(
207                "read_note_by_id : conflit inattendu hash={:?}",
208                hash
209            )),
210        })
211    }
212
213    async fn history_versions(
214        &self,
215        note_id: &str,
216    ) -> Result<Vec<i64>, gradatum_core::error::GradatumError> {
217        use gradatum_core::error::GradatumError;
218        let id = self.parse_note_id(note_id)?;
219        self.history_versions(id)
220            .await
221            .map_err(|e| GradatumError::Storage(format!("history_versions : {e}")))
222    }
223
224    async fn history_get(
225        &self,
226        note_id: &str,
227        ts_ms: i64,
228    ) -> Result<gradatum_core::note::Note, gradatum_core::error::GradatumError> {
229        use gradatum_core::error::GradatumError;
230        let id = self.parse_note_id(note_id)?;
231        self.history_get(id, ts_ms)
232            .await
233            .map_err(|e| GradatumError::Storage(format!("history_get : {e}")))
234    }
235
236    async fn history_restore(
237        &self,
238        note_id: &str,
239        ts_ms: i64,
240    ) -> Result<String, gradatum_core::error::GradatumError> {
241        use gradatum_core::error::GradatumError;
242        let id = self.parse_note_id(note_id)?;
243
244        // Lire le snapshot puis l'écrire comme nouvelle version (déclenche un CoW).
245        let snapshot = self
246            .history_get(id, ts_ms)
247            .await
248            .map_err(|e| GradatumError::Storage(format!("history_restore get snapshot: {e}")))?;
249
250        let written = self
251            .write_note_with_id(snapshot.frontmatter, snapshot.body.markdown, id)
252            .await
253            .map_err(|e| GradatumError::Storage(format!("history_restore write: {e}")))?;
254
255        // Retourner le hash hex de la version restaurée.
256        Ok(written.content_hash.hex())
257    }
258
259    async fn history_diff(
260        &self,
261        note_id: &str,
262        a: &str,
263        b: &str,
264    ) -> Result<Vec<String>, gradatum_core::error::GradatumError> {
265        let id = self.parse_note_id(note_id)?;
266
267        // Résoudre les deux versions : timestamp ou "current".
268        let body_a = self.resolve_history_body(id, a).await?;
269        let body_b = self.resolve_history_body(id, b).await?;
270
271        // Diff brut ligne-à-ligne (PAS Myers — suffisant pour usage MCP).
272        let lines_a: Vec<&str> = body_a.lines().collect();
273        let lines_b: Vec<&str> = body_b.lines().collect();
274        let diff = diff_lines_brut(&lines_a, &lines_b);
275        Ok(diff)
276    }
277
278    async fn update_note_status(
279        &self,
280        note_id: &str,
281        target: gradatum_core::status::NoteStatus,
282        reason: Option<String>,
283    ) -> Result<(), gradatum_core::error::GradatumError> {
284        use gradatum_core::error::GradatumError;
285
286        let id = self.parse_note_id(note_id)?;
287
288        self.update_status(id, target, reason)
289            .await
290            .map_err(|e| match e {
291                crate::error::VaultError::Core(inner) => inner,
292                crate::error::VaultError::Storage(msg) => GradatumError::Storage(msg),
293                crate::error::VaultError::Markdown(msg) => {
294                    GradatumError::Markdown(format!("update_note_status : {msg}"))
295                }
296                // Conflict ne peut pas survenir via update_status — variante défensive.
297                crate::error::VaultError::Conflict(hash) => GradatumError::Storage(format!(
298                    "update_note_status : conflit inattendu hash={:?}",
299                    hash
300                )),
301            })
302    }
303}
304
305impl Vault {
306    /// Helper interne : parse un ULID string en NoteId.
307    fn parse_note_id(
308        &self,
309        note_id: &str,
310    ) -> Result<gradatum_core::identity::NoteId, gradatum_core::error::GradatumError> {
311        use gradatum_core::error::GradatumError;
312        use ulid::Ulid;
313        let ulid = Ulid::from_string(note_id)
314            .map_err(|e| GradatumError::Storage(format!("ULID invalide {note_id:?} : {e}")))?;
315        Ok(gradatum_core::identity::NoteId(ulid))
316    }
317
318    /// Helper interne : résout un sélecteur de version ("current" ou timestamp ms) en body String.
319    async fn resolve_history_body(
320        &self,
321        id: gradatum_core::identity::NoteId,
322        version_selector: &str,
323    ) -> Result<String, gradatum_core::error::GradatumError> {
324        use gradatum_core::error::GradatumError;
325        if version_selector == "current" {
326            let note = self.read_note(id).await.map_err(|e| {
327                GradatumError::Storage(format!("resolve_history_body current: {e}"))
328            })?;
329            Ok(note.body.markdown)
330        } else {
331            let ts_ms = version_selector.parse::<i64>().map_err(|_| {
332                GradatumError::Storage(format!(
333                    "sélecteur de version invalide : attendu 'current' ou timestamp ms, reçu {:?}",
334                    version_selector
335                ))
336            })?;
337            let snapshot = self.history_get(id, ts_ms).await.map_err(|e| {
338                GradatumError::Storage(format!("resolve_history_body snapshot: {e}"))
339            })?;
340            Ok(snapshot.body.markdown)
341        }
342    }
343}
344
345/// Diff brut ligne-à-ligne entre deux corps de notes.
346///
347/// Algorithme : LCS simplifié — ligne présente dans A mais absente dans B = `-`,
348/// ligne présente dans B mais absente dans A = `+`, ligne commune = ` `.
349///
350/// Note : ce n'est pas un diff Myers (pas d'alignement optimal des blocs).
351/// Suffisant pour usage MCP (inspection humaine des changements).
352fn diff_lines_brut(lines_a: &[&str], lines_b: &[&str]) -> Vec<String> {
353    // Diff naïf : compare position par position, signale les divergences.
354    // Pour les notes de vault (généralement < 200 lignes), O(n) est acceptable.
355    let max_len = lines_a.len().max(lines_b.len());
356    let mut result = Vec::with_capacity(max_len * 2);
357
358    let mut i = 0;
359    let mut j = 0;
360
361    while i < lines_a.len() || j < lines_b.len() {
362        match (lines_a.get(i), lines_b.get(j)) {
363            (Some(la), Some(lb)) => {
364                if la == lb {
365                    result.push(format!(" {}", la));
366                    i += 1;
367                    j += 1;
368                } else {
369                    result.push(format!("-{}", la));
370                    result.push(format!("+{}", lb));
371                    i += 1;
372                    j += 1;
373                }
374            }
375            (Some(la), None) => {
376                result.push(format!("-{}", la));
377                i += 1;
378            }
379            (None, Some(lb)) => {
380                result.push(format!("+{}", lb));
381                j += 1;
382            }
383            (None, None) => break,
384        }
385    }
386
387    result
388}
389
390/// Crate version (from `workspace.package.version`).
391pub const VERSION: &str = env!("CARGO_PKG_VERSION");
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn version_is_set() {
399        assert!(!VERSION.is_empty());
400    }
401}