use sha2::{Digest, Sha256};
use crate::frontmatter::Frontmatter;
use crate::note::Note;
pub const HISTORY_EXCLUDED_FIELDS: &[&str] =
&["processed", "covered-by", "derived-into", "updated", "rank"];
fn frontmatter_for_history(fm: &Frontmatter) -> Frontmatter {
let mut filtered = fm.clone();
filtered.updated = None;
if let Some(extra_map) = filtered.extra.0.as_mut() {
for key in HISTORY_EXCLUDED_FIELDS {
if *key != "updated" {
extra_map.remove(*key);
}
}
if extra_map.is_empty() {
filtered.extra.0 = None;
}
}
filtered
}
pub fn sha256_for_history(note: &Note) -> [u8; 32] {
let filtered = frontmatter_for_history(¬e.frontmatter);
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()
}
#[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;
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,
}
}
#[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(¬e_a),
sha256_for_history(¬e_b),
"L'ajout de la clé extra 'processed' ne doit pas changer le hash d'historique"
);
}
#[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(¬e_a),
sha256_for_history(¬e_b),
"L'ajout de 'covered-by' ne doit pas changer le hash d'historique"
);
}
#[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(¬e_a),
sha256_for_history(¬e_b),
"L'ajout de 'rank' ne doit pas changer 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(¬e_a),
sha256_for_history(¬e_b),
"Le champ 'updated' ne doit pas influer sur 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(¬e_a),
sha256_for_history(¬e_b),
"Un changement de body doit changer le hash d'historique"
);
}
#[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(¬e_a),
sha256_for_history(¬e_b),
"Un changement de section doit changer le hash d'historique"
);
}
#[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(¬e_a),
sha256_for_history(¬e_b),
"Une clé extra non exclue doit changer le hash d'historique"
);
}
#[test]
fn history_hash_is_deterministic() {
let note = note_for_test("contenu stable");
let h1 = sha256_for_history(¬e);
let h2 = sha256_for_history(¬e);
assert_eq!(h1, h2, "sha256_for_history doit être déterministe");
}
}