1use blake3::Hasher;
11use unicode_normalization::UnicodeNormalization;
12
13use crate::memo::MemoHash;
14
15pub fn normalize(input: &[u8]) -> String {
26 let text = String::from_utf8_lossy(input);
27
28 let text = text.replace("\r\n", "\n").replace('\r', "\n");
30
31 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 while out.ends_with("\n\n") {
42 out.pop();
43 }
44 if !out.ends_with('\n') {
45 out.push('\n');
46 }
47 if out == "\n" && text.is_empty() {
49 }
51 out
52}
53
54pub 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
62pub 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
70pub 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"); 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 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 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 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}