1use std::collections::HashSet;
2use std::collections::hash_map::DefaultHasher;
3use std::hash::{Hash, Hasher};
4use std::path::{Path, PathBuf};
5
6use anyhow::Result;
7use serde::{Deserialize, Serialize};
8use tracing::warn;
9
10use crate::text_util::{safe_prefix, safe_slice, safe_suffix};
11
12#[must_use]
18pub fn content_hash(content: &str) -> u64 {
19 let mut hasher = DefaultHasher::new();
20 content.hash(&mut hasher);
21 hasher.finish()
22}
23
24#[must_use]
33pub fn stable_hash(content: &str) -> u64 {
34 use sha2::Digest as _;
35 let digest = sha2::Sha256::digest(content.as_bytes());
36 let mut first_eight = [0u8; 8];
37 first_eight.copy_from_slice(&digest[..8]);
38 u64::from_le_bytes(first_eight)
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct DiagnosticFingerprint {
43 pub message_hash: u64,
44 pub context_hash: u64,
45 pub anchor_hash: u64,
46}
47
48impl DiagnosticFingerprint {
49 #[must_use]
50 pub fn new(message: &str, text: &str, start_byte: usize, end_byte: usize) -> Self {
51 let mut message_hasher = DefaultHasher::new();
52 message.hash(&mut message_hasher);
53
54 let context = safe_slice(text, start_byte.saturating_sub(20), end_byte + 20);
57
58 let mut context_hasher = DefaultHasher::new();
59 context.hash(&mut context_hasher);
60
61 let mut anchor_hasher = DefaultHasher::new();
63 Self::extract_word_anchor(text, start_byte, end_byte).hash(&mut anchor_hasher);
64
65 Self {
66 message_hash: message_hasher.finish(),
67 context_hash: context_hasher.finish(),
68 anchor_hash: anchor_hasher.finish(),
69 }
70 }
71
72 fn extract_word_anchor(text: &str, start_byte: usize, end_byte: usize) -> String {
73 let before: String = safe_prefix(text, start_byte)
74 .split_whitespace()
75 .rev()
76 .take(3)
77 .collect::<Vec<_>>()
78 .into_iter()
79 .rev()
80 .collect::<Vec<_>>()
81 .join(" ");
82 let after: String = safe_suffix(text, end_byte)
83 .split_whitespace()
84 .take(3)
85 .collect::<Vec<_>>()
86 .join(" ");
87 format!("{before}|{after}")
88 }
89
90 fn combined_hash(&self) -> u64 {
91 let mut hasher = DefaultHasher::new();
92 self.message_hash.hash(&mut hasher);
93 self.context_hash.hash(&mut hasher);
94 self.anchor_hash.hash(&mut hasher);
95 hasher.finish()
96 }
97}
98
99#[derive(Serialize, Deserialize)]
100struct IgnoreStoreData {
101 fingerprints: Vec<u64>,
102}
103
104pub struct IgnoreStore {
105 ignored_fingerprints: HashSet<u64>,
106 persist_path: Option<PathBuf>,
107}
108
109impl Default for IgnoreStore {
110 fn default() -> Self {
111 Self::new()
112 }
113}
114
115impl IgnoreStore {
116 #[must_use]
117 pub fn new() -> Self {
118 Self {
119 ignored_fingerprints: HashSet::new(),
120 persist_path: None,
121 }
122 }
123
124 #[must_use]
131 pub fn fingerprint(&self) -> u64 {
132 let mut sorted: Vec<u64> = self.ignored_fingerprints.iter().copied().collect();
133 sorted.sort_unstable();
134 let joined = sorted
135 .iter()
136 .map(u64::to_string)
137 .collect::<Vec<_>>()
138 .join(",");
139 stable_hash(&joined)
140 }
141
142 pub fn load(workspace_root: &Path) -> Result<Self> {
144 let persist_path = workspace_root.join(".languagecheck").join("ignores.json");
145 let mut store = Self {
146 ignored_fingerprints: HashSet::new(),
147 persist_path: Some(persist_path.clone()),
148 };
149
150 if persist_path.exists() {
151 let data = std::fs::read_to_string(&persist_path)?;
152 let stored: IgnoreStoreData = serde_json::from_str(&data)?;
153 store.ignored_fingerprints = stored.fingerprints.into_iter().collect();
154 }
155
156 Ok(store)
157 }
158
159 pub fn ignore(&mut self, fingerprint: &DiagnosticFingerprint) {
160 self.ignored_fingerprints
161 .insert(fingerprint.combined_hash());
162 if let Err(e) = self.persist() {
163 warn!("Failed to persist ignore store: {e}");
164 }
165 }
166
167 #[must_use]
168 pub fn is_ignored(&self, fingerprint: &DiagnosticFingerprint) -> bool {
169 self.ignored_fingerprints
170 .contains(&fingerprint.combined_hash())
171 }
172
173 fn persist(&self) -> Result<()> {
174 let Some(path) = &self.persist_path else {
175 return Ok(());
176 };
177
178 if let Some(parent) = path.parent() {
179 std::fs::create_dir_all(parent)?;
180 }
181
182 let data = IgnoreStoreData {
183 fingerprints: self.ignored_fingerprints.iter().copied().collect(),
184 };
185 std::fs::write(path, serde_json::to_string_pretty(&data)?)?;
186 Ok(())
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 #[test]
195 fn fingerprint_same_input_same_hash() {
196 let fp1 = DiagnosticFingerprint::new("bad grammar", "This has bad grammar here.", 9, 12);
197 let fp2 = DiagnosticFingerprint::new("bad grammar", "This has bad grammar here.", 9, 12);
198 assert_eq!(fp1.combined_hash(), fp2.combined_hash());
199 }
200
201 #[test]
202 fn fingerprint_different_message_different_hash() {
203 let fp1 = DiagnosticFingerprint::new("bad grammar", "This has bad grammar here.", 9, 12);
204 let fp2 = DiagnosticFingerprint::new("spelling error", "This has bad grammar here.", 9, 12);
205 assert_ne!(fp1.combined_hash(), fp2.combined_hash());
206 }
207
208 #[test]
209 fn fingerprint_different_context_different_hash() {
210 let fp1 = DiagnosticFingerprint::new("error", "AAA error BBB", 4, 9);
211 let fp2 = DiagnosticFingerprint::new("error", "CCC error DDD", 4, 9);
212 assert_ne!(fp1.combined_hash(), fp2.combined_hash());
213 }
214
215 #[test]
216 fn fingerprint_word_anchor_extraction() {
217 let text = "one two three ERROR four five six";
218 let anchor = DiagnosticFingerprint::extract_word_anchor(text, 14, 19);
219 assert_eq!(anchor, "one two three|four five six");
220 }
221
222 #[test]
223 fn fingerprint_word_anchor_at_start() {
224 let text = "ERROR some words after";
225 let anchor = DiagnosticFingerprint::extract_word_anchor(text, 0, 5);
226 assert_eq!(anchor, "|some words after");
227 }
228
229 #[test]
230 fn fingerprint_word_anchor_at_end() {
231 let text = "words before ERROR";
232 let anchor = DiagnosticFingerprint::extract_word_anchor(text, 13, 18);
233 assert_eq!(anchor, "words before|");
234 }
235
236 #[test]
237 fn ignore_store_basic_operations() {
238 let mut store = IgnoreStore::new();
239 let fp = DiagnosticFingerprint::new("test msg", "some test msg context", 5, 13);
240
241 assert!(!store.is_ignored(&fp));
242 store.ignore(&fp);
243 assert!(store.is_ignored(&fp));
244 }
245
246 #[test]
247 fn ignore_store_does_not_ignore_different_fingerprint() {
248 let mut store = IgnoreStore::new();
249 let fp1 = DiagnosticFingerprint::new("msg A", "context A msg A here", 10, 15);
250 let fp2 = DiagnosticFingerprint::new("msg B", "context B msg B here", 10, 15);
251
252 store.ignore(&fp1);
253 assert!(store.is_ignored(&fp1));
254 assert!(!store.is_ignored(&fp2));
255 }
256
257 #[test]
258 fn ignore_store_persistence_roundtrip() {
259 let dir = std::env::temp_dir().join("lang_check_test_ignore_persist");
260 let _ = std::fs::remove_dir_all(&dir);
261 std::fs::create_dir_all(&dir).unwrap();
262
263 let fp = DiagnosticFingerprint::new("persist test", "the persist test text", 4, 16);
264
265 {
267 let mut store = IgnoreStore::load(&dir).unwrap();
268 store.ignore(&fp);
269 }
270
271 {
273 let store = IgnoreStore::load(&dir).unwrap();
274 assert!(store.is_ignored(&fp));
275 }
276
277 let _ = std::fs::remove_dir_all(&dir);
278 }
279
280 #[test]
281 fn fingerprint_handles_multibyte_utf8() {
282 let text = "Ärger mit Ölförderung"; let fp = DiagnosticFingerprint::new("test", text, 11, 15);
287 assert!(fp.combined_hash() != 0 || fp.combined_hash() == 0);
289 }
290
291 #[test]
292 fn ignore_store_empty_persistence() {
293 let dir = std::env::temp_dir().join("lang_check_test_ignore_empty");
294 let _ = std::fs::remove_dir_all(&dir);
295 std::fs::create_dir_all(&dir).unwrap();
296
297 let store = IgnoreStore::load(&dir).unwrap();
298 let fp = DiagnosticFingerprint::new("not ignored", "some context", 0, 5);
299 assert!(!store.is_ignored(&fp));
300
301 let _ = std::fs::remove_dir_all(&dir);
302 }
303}