gradatum-vault 0.4.3

Multi-vault registry + lifecycle (create/list/swap/delete) + forward-compat
Documentation
//! # gradatum-vault
//!
//! Vault domain logic : registry + lifecycle + overrides + drift + effective_note cache.
//!
//! Couche L2 de l'architecture Gradatum — composition au-dessus des couches L1 :
//! - `gradatum-core` : primitives, traits, erreurs.
//! - `gradatum-markdown` : parse + write `.md`.
//! - `gradatum-cache` : `EffectiveNoteCache` moka.
//! - `gradatum-index` : `SqliteIndex` impl `Index` trait.
//! - `gradatum-storage` : `FileStorage` OpenDAL.
//!
//! ## Modules
//!
//! - [`registry`] : `Vault::create` / `Vault::open` — layout init, tenant_id, handles.
//! - [`lifecycle`] : `write_note` — ContentHash + persist .md + upsert index.
//! - [`overrides`] : `NoteMetadataOverride` — `Overridable` + `OverridePayload` impl.
//! - [`drift`] : `drift_check` — scan Phase A via `gradatum-index::scan_phase_a`.
//! - [`effective_note`] : `get_effective_note` — cache moka avec validation checksum.
//! - [`history`] : `NoteHistoryEntry` — entrée d'historique CoW.
//! - [`error`] : `VaultError` — erreurs typées sans `Box<dyn Error>`.
//!
//! ## Stabilité
//!
//! `0.x` — aucune garantie de stabilité API.
//! Voir [RELEASE-POLICY.md](https://github.com/gradatum/gradatum/blob/main/RELEASE-POLICY.md).

#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![warn(rust_2018_idioms)]

pub mod drift;
pub mod effective_note;
pub mod error;
pub mod history;
pub mod lifecycle;
pub mod overrides;
pub mod registry;
pub mod write;

pub use error::VaultError;
pub use history::NoteHistoryEntry;
pub use lifecycle::HISTORY_DIR_PREFIX;
pub use overrides::NoteMetadataOverride;
pub use registry::Vault;
pub use write::WriteResult;

// ── Registry trait (T2 P2.0c) ────────────────────────────────────────────────

/// Trait d'accès registre vault — exposé à `AppState` pour découpler le serveur
/// de l'implémentation concrète `Vault`.
///
/// Méthodes async via `async_trait` — compatible `Arc<dyn Registry>`.
///
/// ## Implémenteurs
///
/// - [`Vault`] : implémentation réelle depuis l'index SQLite.
/// - `PlaceholderRegistry` (dans `gradatum-server`) : stub retournant 0/0
///   pour les constructeurs sync avant injection du chemin vault.
#[async_trait::async_trait]
pub trait Registry: Send + Sync {
    /// Nombre de tenants (vault_id distincts) dans l'index.
    ///
    /// Retourne 0 si le vault est vide ou pas encore initialisé.
    async fn tenant_count(&self) -> Result<u32, gradatum_core::error::GradatumError>;

    /// Nombre de loci distincts (paires vault_id + locus) dans l'index.
    ///
    /// Un locus est l'unité d'organisation sub-tenant.
    /// Retourne 0 si aucune note n'est indexée.
    async fn locus_count(&self) -> Result<u32, gradatum_core::error::GradatumError>;

    /// S'assure qu'un tenant existe dans le registre.
    ///
    /// Idempotent — peut être appelé plusieurs fois sans effet de bord.
    async fn ensure_tenant(
        &self,
        tenant_id: &str,
    ) -> Result<(), gradatum_core::error::GradatumError>;

    /// Lit une note par identifiant ULID (string) depuis le vault.
    ///
    /// T4 P2.0c : implémentation réelle avec cache hit/miss, checksum B22, disk read.
    ///
    /// ## Comportement
    ///
    /// - Cache hit valide → retour immédiat, compteur cache_hits incrémenté.
    /// - Cache miss → `index.get_note` + `storage.read(.md)` + `parse` + insert cache.
    ///
    /// ## Erreurs
    ///
    /// - `GradatumError::NoteNotFound` si l'identifiant est absent de l'index.
    /// - `GradatumError::Storage` si la lecture disque échoue.
    async fn read_note_by_id(
        &self,
        note_id: &str,
    ) -> Result<gradatum_core::note::Note, gradatum_core::error::GradatumError>;

    /// F-40 — Liste les timestamps (ms Unix) des snapshots historiques d'une note.
    ///
    /// Retourne un `Vec<i64>` trié croissant (plus ancien en premier).
    /// Liste vide si aucun historique n'existe ou si note inconnue.
    async fn history_versions(
        &self,
        note_id: &str,
    ) -> Result<Vec<i64>, gradatum_core::error::GradatumError>;

    /// F-40 — Lit le contenu d'un snapshot historique.
    ///
    /// `ts_ms` est un timestamp issu de `history_versions`.
    ///
    /// ## Erreurs
    ///
    /// - `GradatumError::Storage` si le snapshot est introuvable.
    /// - `GradatumError::Markdown` si le parsing échoue.
    async fn history_get(
        &self,
        note_id: &str,
        ts_ms: i64,
    ) -> Result<gradatum_core::note::Note, gradatum_core::error::GradatumError>;

    /// F-40 — Restaure une note depuis un snapshot historique.
    ///
    /// Équivalent à écrire le snapshot comme nouvelle version courante (déclenche un CoW).
    /// L'id de la note est préservé. Retourne le hash SHA-256 hex de la version restaurée.
    ///
    /// ## Erreurs
    ///
    /// - `GradatumError::Storage` si le snapshot est introuvable.
    /// - `GradatumError::Markdown` si le parsing du snapshot échoue.
    async fn history_restore(
        &self,
        note_id: &str,
        ts_ms: i64,
    ) -> Result<String, gradatum_core::error::GradatumError>;

    /// F-40 — Diff brut ligne-à-ligne entre deux versions.
    ///
    /// `a` et `b` sont des timestamps issus de `history_versions`, ou `"current"` pour
    /// la version courante. Retourne une liste de lignes diff (préfixe `-`/`+`/` `).
    ///
    /// Diff implémentation : diff brut ligne-à-ligne (PAS Myers) — suffisant pour
    /// l'usage MCP (lisibilité > compacité).
    async fn history_diff(
        &self,
        note_id: &str,
        a: &str,
        b: &str,
    ) -> Result<Vec<String>, gradatum_core::error::GradatumError>;

    /// Met à jour le statut d'une note avec validation de la state machine.
    ///
    /// Seules les transitions définies dans `NoteStatus::can_transition_to` sont
    /// autorisées. `target == current` est un no-op silencieux (idempotence).
    /// Chaque transition réussie est tracée dans `.history/` (Copy-on-Write).
    ///
    /// ## Erreurs
    ///
    /// - `GradatumError::NoteNotFound` si la note est absente.
    /// - `GradatumError::InvalidStatusTransition { from, to }` si la transition
    ///   n'est pas autorisée par le graphe.
    /// - `GradatumError::Storage` / `GradatumError::Markdown` sur erreur I/O.
    async fn update_note_status(
        &self,
        note_id: &str,
        target: gradatum_core::status::NoteStatus,
        reason: Option<String>,
    ) -> Result<(), gradatum_core::error::GradatumError>;
}

#[async_trait::async_trait]
impl Registry for Vault {
    async fn tenant_count(&self) -> Result<u32, gradatum_core::error::GradatumError> {
        self.index.vault_id_count().await
    }

    async fn locus_count(&self) -> Result<u32, gradatum_core::error::GradatumError> {
        self.index.locus_count().await
    }

    async fn ensure_tenant(
        &self,
        tenant_id: &str,
    ) -> Result<(), gradatum_core::error::GradatumError> {
        self.index.ensure_vault_id(tenant_id).await
    }

    async fn read_note_by_id(
        &self,
        note_id: &str,
    ) -> Result<gradatum_core::note::Note, gradatum_core::error::GradatumError> {
        use gradatum_core::error::GradatumError;
        use ulid::Ulid;

        let ulid = Ulid::from_string(note_id).map_err(|e| {
            GradatumError::Storage(format!("read_note_by_id : ULID invalide {note_id:?} : {e}"))
        })?;
        let id = gradatum_core::identity::NoteId(ulid);

        self.read_note(id).await.map_err(|e| match e {
            crate::error::VaultError::Core(inner) => inner,
            crate::error::VaultError::Storage(msg) => GradatumError::Storage(msg),
            crate::error::VaultError::Markdown(msg) => {
                GradatumError::Markdown(format!("read_note_by_id : {msg}"))
            }
            // Conflict ne peut pas survenir via read_note — variante défensive.
            crate::error::VaultError::Conflict(hash) => GradatumError::Storage(format!(
                "read_note_by_id : conflit inattendu hash={:?}",
                hash
            )),
        })
    }

    async fn history_versions(
        &self,
        note_id: &str,
    ) -> Result<Vec<i64>, gradatum_core::error::GradatumError> {
        use gradatum_core::error::GradatumError;
        let id = self.parse_note_id(note_id)?;
        self.history_versions(id)
            .await
            .map_err(|e| GradatumError::Storage(format!("history_versions : {e}")))
    }

    async fn history_get(
        &self,
        note_id: &str,
        ts_ms: i64,
    ) -> Result<gradatum_core::note::Note, gradatum_core::error::GradatumError> {
        use gradatum_core::error::GradatumError;
        let id = self.parse_note_id(note_id)?;
        self.history_get(id, ts_ms)
            .await
            .map_err(|e| GradatumError::Storage(format!("history_get : {e}")))
    }

    async fn history_restore(
        &self,
        note_id: &str,
        ts_ms: i64,
    ) -> Result<String, gradatum_core::error::GradatumError> {
        use gradatum_core::error::GradatumError;
        let id = self.parse_note_id(note_id)?;

        // Lire le snapshot puis l'écrire comme nouvelle version (déclenche un CoW).
        let snapshot = self
            .history_get(id, ts_ms)
            .await
            .map_err(|e| GradatumError::Storage(format!("history_restore get snapshot: {e}")))?;

        let written = self
            .write_note_with_id(snapshot.frontmatter, snapshot.body.markdown, id)
            .await
            .map_err(|e| GradatumError::Storage(format!("history_restore write: {e}")))?;

        // Retourner le hash hex de la version restaurée.
        Ok(written.content_hash.hex())
    }

    async fn history_diff(
        &self,
        note_id: &str,
        a: &str,
        b: &str,
    ) -> Result<Vec<String>, gradatum_core::error::GradatumError> {
        let id = self.parse_note_id(note_id)?;

        // Résoudre les deux versions : timestamp ou "current".
        let body_a = self.resolve_history_body(id, a).await?;
        let body_b = self.resolve_history_body(id, b).await?;

        // Diff brut ligne-à-ligne (PAS Myers — suffisant pour usage MCP).
        let lines_a: Vec<&str> = body_a.lines().collect();
        let lines_b: Vec<&str> = body_b.lines().collect();
        let diff = diff_lines_brut(&lines_a, &lines_b);
        Ok(diff)
    }

    async fn update_note_status(
        &self,
        note_id: &str,
        target: gradatum_core::status::NoteStatus,
        reason: Option<String>,
    ) -> Result<(), gradatum_core::error::GradatumError> {
        use gradatum_core::error::GradatumError;

        let id = self.parse_note_id(note_id)?;

        self.update_status(id, target, reason)
            .await
            .map_err(|e| match e {
                crate::error::VaultError::Core(inner) => inner,
                crate::error::VaultError::Storage(msg) => GradatumError::Storage(msg),
                crate::error::VaultError::Markdown(msg) => {
                    GradatumError::Markdown(format!("update_note_status : {msg}"))
                }
                // Conflict ne peut pas survenir via update_status — variante défensive.
                crate::error::VaultError::Conflict(hash) => GradatumError::Storage(format!(
                    "update_note_status : conflit inattendu hash={:?}",
                    hash
                )),
            })
    }
}

impl Vault {
    /// Helper interne : parse un ULID string en NoteId.
    fn parse_note_id(
        &self,
        note_id: &str,
    ) -> Result<gradatum_core::identity::NoteId, gradatum_core::error::GradatumError> {
        use gradatum_core::error::GradatumError;
        use ulid::Ulid;
        let ulid = Ulid::from_string(note_id)
            .map_err(|e| GradatumError::Storage(format!("ULID invalide {note_id:?} : {e}")))?;
        Ok(gradatum_core::identity::NoteId(ulid))
    }

    /// Helper interne : résout un sélecteur de version ("current" ou timestamp ms) en body String.
    async fn resolve_history_body(
        &self,
        id: gradatum_core::identity::NoteId,
        version_selector: &str,
    ) -> Result<String, gradatum_core::error::GradatumError> {
        use gradatum_core::error::GradatumError;
        if version_selector == "current" {
            let note = self.read_note(id).await.map_err(|e| {
                GradatumError::Storage(format!("resolve_history_body current: {e}"))
            })?;
            Ok(note.body.markdown)
        } else {
            let ts_ms = version_selector.parse::<i64>().map_err(|_| {
                GradatumError::Storage(format!(
                    "sélecteur de version invalide : attendu 'current' ou timestamp ms, reçu {:?}",
                    version_selector
                ))
            })?;
            let snapshot = self.history_get(id, ts_ms).await.map_err(|e| {
                GradatumError::Storage(format!("resolve_history_body snapshot: {e}"))
            })?;
            Ok(snapshot.body.markdown)
        }
    }
}

/// Diff brut ligne-à-ligne entre deux corps de notes.
///
/// Algorithme : LCS simplifié — ligne présente dans A mais absente dans B = `-`,
/// ligne présente dans B mais absente dans A = `+`, ligne commune = ` `.
///
/// Note : ce n'est pas un diff Myers (pas d'alignement optimal des blocs).
/// Suffisant pour usage MCP (inspection humaine des changements).
fn diff_lines_brut(lines_a: &[&str], lines_b: &[&str]) -> Vec<String> {
    // Diff naïf : compare position par position, signale les divergences.
    // Pour les notes de vault (généralement < 200 lignes), O(n) est acceptable.
    let max_len = lines_a.len().max(lines_b.len());
    let mut result = Vec::with_capacity(max_len * 2);

    let mut i = 0;
    let mut j = 0;

    while i < lines_a.len() || j < lines_b.len() {
        match (lines_a.get(i), lines_b.get(j)) {
            (Some(la), Some(lb)) => {
                if la == lb {
                    result.push(format!(" {}", la));
                    i += 1;
                    j += 1;
                } else {
                    result.push(format!("-{}", la));
                    result.push(format!("+{}", lb));
                    i += 1;
                    j += 1;
                }
            }
            (Some(la), None) => {
                result.push(format!("-{}", la));
                i += 1;
            }
            (None, Some(lb)) => {
                result.push(format!("+{}", lb));
                j += 1;
            }
            (None, None) => break,
        }
    }

    result
}

/// Crate version (from `workspace.package.version`).
pub const VERSION: &str = env!("CARGO_PKG_VERSION");

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn version_is_set() {
        assert!(!VERSION.is_empty());
    }
}