gradatum-core 0.4.3

Shared primitives: errors, IDs, types
Documentation
//! F-40 — Hash d'historique pour le Copy-on-Write.
//!
//! `sha256_for_history` produit un hash stable sur le contenu *sémantique* d'une note :
//! il exclut les champs qui évoluent lors de traitements autonomes (curator, jobs) pour
//! éviter de créer des versions parasites dans `.history/` à chaque passage de job.
//!
//! ## Champs exclus (`HISTORY_EXCLUDED_FIELDS`)
//!
//! Deux catégories :
//! 1. **Clés de `frontmatter.extra` (ExtraFields)** : `processed`, `covered-by`,
//!    `derived-into`, `rank` — posées par les jobs curator/warden sans modifier le
//!    contenu sémantique de la note.
//! 2. **Champ struct `updated`** (`Option<DateTime<Utc>>`) — mis à jour à chaque
//!    `write_note_inner`, même pour un write à contenu identique.
//!
//! ## Algorithme
//!
//! ```text
//! filtered_fm = frontmatter.clone() { extra sans les clés exclues, updated = None }
//! canon       = JCS(filtered_fm)          // serde_jcs — même canonicaliseur que ContentHash
//! hash        = SHA-256(canon ++ b"\n---\n" ++ body_utf8)
//! ```
//!
//! Utilise `serde_jcs` pour la cohérence avec [`crate::identity::ContentHash::compute`].
//!
//! ## Non-monotone
//!
//! Ce hash ne remplace PAS `NoteVersion` (compteur monotone non câblé).
//! Le CoW se déclenche sur la *différence* de hash entre `existing` et `new`,
//! pas sur un compteur monotone.

use sha2::{Digest, Sha256};

use crate::frontmatter::Frontmatter;
use crate::note::Note;

/// Clés `extra` et champ struct exclus du hash d'historique.
///
/// Ces champs sont mis à jour par les jobs de traitement (curator, warden) sans
/// modifier le contenu sémantique de la note. Les inclure déclencherait des
/// versions parasites dans `.history/` à chaque passage de job.
///
/// - `processed`, `covered-by`, `derived-into`, `rank` : clés de `ExtraFields`.
/// - `updated` : champ struct `Frontmatter::updated` (`Option<DateTime<Utc>>`).
pub const HISTORY_EXCLUDED_FIELDS: &[&str] =
    &["processed", "covered-by", "derived-into", "updated", "rank"];

/// Retourne une copie de `frontmatter` filtrée pour le hash d'historique.
///
/// Deux transformations :
/// 1. Retire les clés `extra` présentes dans `HISTORY_EXCLUDED_FIELDS`.
/// 2. Met `updated = None` (champ struct, pas dans `extra`).
///
/// La liste `HISTORY_EXCLUDED_FIELDS` contient `"updated"` pour documenter
/// l'intention, mais c'est la mise à `None` explicite ici qui s'en charge.
fn frontmatter_for_history(fm: &Frontmatter) -> Frontmatter {
    let mut filtered = fm.clone();

    // Effacer le timestamp de modification — il évolue à chaque write même à contenu identique.
    filtered.updated = None;

    // Retirer les clés extra exclues (opérations de traitement, pas de contenu sémantique).
    if let Some(extra_map) = filtered.extra.0.as_mut() {
        for key in HISTORY_EXCLUDED_FIELDS {
            // "updated" est un champ struct, pas une clé extra — inutile de le chercher ici.
            if *key != "updated" {
                extra_map.remove(*key);
            }
        }
        // Nettoyer la Box si la map est désormais vide (optimisation mémoire, cohérence avec ExtraFields::is_empty).
        if extra_map.is_empty() {
            filtered.extra.0 = None;
        }
    }

    filtered
}

/// Calcule le hash SHA-256 du contenu sémantique d'une note (excluant les champs de traitement).
///
/// ## Algorithme
///
/// ```text
/// filtered_fm = frontmatter_for_history(&note.frontmatter)
/// canon       = JCS(filtered_fm)                           // serde_jcs RFC 8785
/// hash        = SHA-256(canon_bytes ++ b"\n---\n" ++ body_utf8)
/// ```
///
/// Utilise le même séparateur et le même canonicaliseur JCS que
/// [`crate::identity::ContentHash::compute`] pour garantir la cohérence des outils
/// d'analyse croisant les deux types de hash.
///
/// ## Panics
///
/// Ne panique jamais en production. `serde_jcs::to_string` ne peut échouer que si
/// `Frontmatter` contient un flottant non-sérialisable (`f32::NAN`). `Frontmatter`
/// ne contient aucun `f32` → le `expect` ici est justifié par le même invariant que
/// `ContentHash::compute`.
pub fn sha256_for_history(note: &Note) -> [u8; 32] {
    let filtered = frontmatter_for_history(&note.frontmatter);

    // JCS RFC 8785 : clés ordonnées + floats IEEE 754 canoniques.
    // Même canonicaliseur que ContentHash → reproductible cross-outil.
    let canon = serde_jcs::to_string(&filtered).expect(
        "Frontmatter filtré est toujours sérialisable en JCS (pas de f32::NAN ni de types non-JSON)",
    );

    let mut h = Sha256::new();
    h.update(canon.as_bytes());
    h.update(b"\n---\n");
    h.update(note.body.markdown.as_bytes());

    h.finalize().into()
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::frontmatter::ExtraFields;
    use crate::identity::{ContentHash, NoteId, NoteVersion};
    use crate::note::{Note, NoteBody};
    use crate::scope::VaultId;
    use crate::section::Section;
    use crate::status::NoteStatus;
    use chrono::Utc;

    /// Construit une Note minimale avec le `body` fourni.
    fn note_for_test(body: &str) -> Note {
        let fm = Frontmatter {
            schema_version: 1,
            vault_id: VaultId::new("main"),
            locus: None,
            section: Section::Decisions,
            status: NoteStatus::Draft,
            status_reason: None,
            status_changed: None,
            tags: Default::default(),
            author: None,
            created: Utc::now(),
            updated: None,
            extra: ExtraFields::empty(),
            provenance: None,
            forgotten: None,
            forgotten_at: None,
            forgotten_by: None,
        };
        let body_obj = NoteBody {
            markdown: body.to_string(),
        };
        let hash = ContentHash::compute(&fm, body);
        Note {
            id: NoteId::new(),
            frontmatter: fm,
            body: body_obj,
            version: NoteVersion::initial(),
            content_hash: hash,
            integrity_signature: None,
        }
    }

    /// Ajouter une clé extra exclue (`processed`) ne doit PAS changer le hash d'historique.
    #[test]
    fn history_hash_ignores_excluded_extra_key_processed() {
        let note_a = note_for_test("corps de la note");
        let mut note_b = note_a.clone();
        note_b
            .frontmatter
            .extra
            .insert("processed".to_string(), toml::Value::Boolean(true));

        assert_eq!(
            sha256_for_history(&note_a),
            sha256_for_history(&note_b),
            "L'ajout de la clé extra 'processed' ne doit pas changer le hash d'historique"
        );
    }

    /// Ajouter `covered-by` (clé extra exclue) ne doit pas changer le hash.
    #[test]
    fn history_hash_ignores_excluded_extra_key_covered_by() {
        let note_a = note_for_test("corps");
        let mut note_b = note_a.clone();
        note_b.frontmatter.extra.insert(
            "covered-by".to_string(),
            toml::Value::String("01ABCDEF".to_string()),
        );

        assert_eq!(
            sha256_for_history(&note_a),
            sha256_for_history(&note_b),
            "L'ajout de 'covered-by' ne doit pas changer le hash d'historique"
        );
    }

    /// Ajouter `rank` (clé extra exclue) ne doit pas changer le hash.
    #[test]
    fn history_hash_ignores_excluded_extra_key_rank() {
        let note_a = note_for_test("corps");
        let mut note_b = note_a.clone();
        note_b
            .frontmatter
            .extra
            .insert("rank".to_string(), toml::Value::Float(0.9));

        assert_eq!(
            sha256_for_history(&note_a),
            sha256_for_history(&note_b),
            "L'ajout de 'rank' ne doit pas changer le hash d'historique"
        );
    }

    /// Le champ `updated` (champ struct) ne doit pas influer sur le hash d'historique.
    #[test]
    fn history_hash_ignores_updated_field() {
        let mut note_a = note_for_test("corps");
        note_a.frontmatter.updated = None;
        let mut note_b = note_a.clone();
        note_b.frontmatter.updated = Some(Utc::now());

        assert_eq!(
            sha256_for_history(&note_a),
            sha256_for_history(&note_b),
            "Le champ 'updated' ne doit pas influer sur le hash d'historique"
        );
    }

    /// Modifier le body DOIT changer le hash d'historique.
    #[test]
    fn history_hash_changes_on_body_modification() {
        let note_a = note_for_test("corps original");
        let note_b = note_for_test("corps MODIFIÉ");

        assert_ne!(
            sha256_for_history(&note_a),
            sha256_for_history(&note_b),
            "Un changement de body doit changer le hash d'historique"
        );
    }

    /// Modifier le titre (section ici — le titre vit dans ContentHash, pas dans Frontmatter titre)
    /// → tester via section : changer la section doit changer le hash.
    #[test]
    fn history_hash_changes_on_section_change() {
        let note_a = note_for_test("corps");
        let mut note_b = note_a.clone();
        note_b.frontmatter.section = Section::Reasoning;

        assert_ne!(
            sha256_for_history(&note_a),
            sha256_for_history(&note_b),
            "Un changement de section doit changer le hash d'historique"
        );
    }

    /// Une clé extra non exclue doit bien changer le hash.
    #[test]
    fn history_hash_changes_on_non_excluded_extra_key() {
        let note_a = note_for_test("corps");
        let mut note_b = note_a.clone();
        note_b.frontmatter.extra.insert(
            "custom-field".to_string(),
            toml::Value::String("valeur".to_string()),
        );

        assert_ne!(
            sha256_for_history(&note_a),
            sha256_for_history(&note_b),
            "Une clé extra non exclue doit changer le hash d'historique"
        );
    }

    /// Stabilité : appels successifs sur la même note donnent le même hash.
    #[test]
    fn history_hash_is_deterministic() {
        let note = note_for_test("contenu stable");
        let h1 = sha256_for_history(&note);
        let h2 = sha256_for_history(&note);
        assert_eq!(h1, h2, "sha256_for_history doit être déterministe");
    }
}