Skip to main content

oximemo_core/
hash.rs

1//! Content hashing with deterministic normalization (§5.3).
2//!
3//! The hash covers a memo's full *meaningful state* — body, tags, favorite flag,
4//! and color — so the sync diff (§9.2) detects metadata-only edits (tag/favorite/
5//! color changes), not just body edits. The digest must be stable regardless
6//! of how a memo was written — vim's atomic-rename, a shell redirect, or
7//! oximemo's own writer must all produce the same digest for identical state.
8//! Normalization therefore precedes hashing.
9
10use blake3::Hasher;
11use unicode_normalization::UnicodeNormalization;
12
13use crate::memo::MemoHash;
14
15/// Normalize note body bytes before hashing.
16///
17/// Rules (§5.3):
18/// 1. newlines normalized to `\n`
19/// 2. trailing whitespace stripped from each line
20/// 3. text Unicode-NFC normalized
21/// 4. exactly one trailing newline
22///
23/// Input is treated as UTF-8; replacement chars are used for invalid bytes so
24/// hashing never fails on a partially-written file.
25pub fn normalize(input: &[u8]) -> String {
26    let text = String::from_utf8_lossy(input);
27
28    // 1. normalize newlines
29    let text = text.replace("\r\n", "\n").replace('\r', "\n");
30
31    // 2. + 3. strip trailing whitespace per line, then NFC
32    let mut out = String::with_capacity(text.len());
33    for line in text.split('\n') {
34        let trimmed = line.trim_end();
35        let nfc: String = trimmed.nfc().collect();
36        out.push_str(&nfc);
37        out.push('\n');
38    }
39
40    // 4. collapse multiple trailing newlines to exactly one
41    while out.ends_with("\n\n") {
42        out.pop();
43    }
44    if !out.ends_with('\n') {
45        out.push('\n');
46    }
47    // empty file → single newline
48    if out == "\n" && text.is_empty() {
49        // keep as-is: empty body normalizes to one newline
50    }
51    out
52}
53
54/// Hash normalized content, returning a prefixed `b3:` digest.
55pub fn hash_content(input: &[u8]) -> MemoHash {
56    let normalized = normalize(input);
57    let mut hasher = Hasher::new();
58    hasher.update(normalized.as_bytes());
59    MemoHash::new(hasher.finalize().to_hex().to_string())
60}
61
62/// Hash an already-normalized string. Used internally when the body has been
63/// produced by our own writer and is known-normal.
64pub fn hash_normalized(normalized: &str) -> MemoHash {
65    let mut hasher = Hasher::new();
66    hasher.update(normalized.as_bytes());
67    MemoHash::new(hasher.finalize().to_hex().to_string())
68}
69
70/// Hash a memo's full meaningful state: body + tags + favorite + category (§5.3).
71///
72/// Deliberately excluded from the input:
73/// - `hash` (avoids a self-referential cycle),
74/// - `id` / `created_at` (immutable after creation),
75/// - `updated_at` (it is the sync *cursor*, not content),
76/// - `deleted_at` (tombstones travel via the manifest's `deleted` flag).
77///
78/// Because tags, favorite, and category are part of the digest, editing any of them
79/// changes the hash and is correctly surfaced by the sync diff — closing the
80/// gap where a metadata-only edit would otherwise look "unchanged".
81pub fn hash_memo(body: &[u8], favorite: bool, category: &str) -> MemoHash {
82    let normalized_body = normalize(body);
83    let mut hasher = Hasher::new();
84    hasher.update(normalized_body.as_bytes());
85    hasher.update(b"\x1f"); // unit separator between fields
86    hasher.update(if favorite { b"1" } else { b"0" });
87    hasher.update(b"\x1f");
88    hasher.update(category.as_bytes());
89    MemoHash::new(hasher.finalize().to_hex().to_string())
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn crlf_and_lf_hash_equal() {
98        let a = hash_content(b"hello\nworld");
99        let b = hash_content(b"hello\r\nworld");
100        assert_eq!(a, b);
101    }
102
103    #[test]
104    fn trailing_whitespace_ignored() {
105        let a = hash_content(b"line one   \nline two");
106        let b = hash_content(b"line one\nline two");
107        assert_eq!(a, b);
108    }
109
110    #[test]
111    fn trailing_newline_normalized() {
112        let a = hash_content(b"text\n\n\n");
113        let b = hash_content(b"text");
114        assert_eq!(a, b);
115    }
116
117    #[test]
118    fn nfc_normalization() {
119        // U+0041 U+0301 (A + combining acute) vs U+00C1 (precomposed Á)
120        let a = hash_content("A\u{301}".as_bytes());
121        let b = hash_content("\u{C1}".as_bytes());
122        assert_eq!(a, b);
123    }
124
125    #[test]
126    fn hash_is_prefixed() {
127        let h = hash_content(b"x");
128        assert!(h.as_str().starts_with("b3:"));
129    }
130
131    #[test]
132    fn metadata_only_edit_changes_hash() {
133        // Favorite / color still change the hash (§9.2). Tags are derived from the
134        // body now, so a tag change IS a body change — covered below.
135        let base = hash_memo(b"body", false, "");
136        let favorite = hash_memo(b"body", true, "");
137        let colored = hash_memo(b"body", false, "todo");
138        assert_ne!(base, favorite);
139        assert_ne!(base, colored);
140    }
141    #[test]
142    fn tag_in_body_changes_hash() {
143        // Adding `#x` to the body changes the digest (tags live in the body).
144        let a = hash_memo(b"note", false, "");
145        let b = hash_memo(b"note #x", false, "");
146        assert_ne!(a, b);
147    }
148
149    #[test]
150    fn identical_state_hashes_equal() {
151        let a = hash_memo(b"body", true, "todo");
152        let b = hash_memo(b"body", true, "todo");
153        assert_eq!(a, b);
154    }
155}