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};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct DiagnosticFingerprint {
11 pub message_hash: u64,
12 pub context_hash: u64,
13 pub anchor_hash: u64,
14}
15
16impl DiagnosticFingerprint {
17 #[must_use]
18 pub fn new(message: &str, text: &str, start_byte: usize, end_byte: usize) -> Self {
19 let mut message_hasher = DefaultHasher::new();
20 message.hash(&mut message_hasher);
21
22 let start = text.floor_char_boundary(start_byte.saturating_sub(20));
24 let end = text.ceil_char_boundary((end_byte + 20).min(text.len()));
25 let context = &text[start..end];
26
27 let mut context_hasher = DefaultHasher::new();
28 context.hash(&mut context_hasher);
29
30 let mut anchor_hasher = DefaultHasher::new();
32 Self::extract_word_anchor(text, start_byte, end_byte).hash(&mut anchor_hasher);
33
34 Self {
35 message_hash: message_hasher.finish(),
36 context_hash: context_hasher.finish(),
37 anchor_hash: anchor_hasher.finish(),
38 }
39 }
40
41 fn extract_word_anchor(text: &str, start_byte: usize, end_byte: usize) -> String {
42 let sb = text.floor_char_boundary(start_byte.min(text.len()));
43 let before: String = text[..sb]
44 .split_whitespace()
45 .rev()
46 .take(3)
47 .collect::<Vec<_>>()
48 .into_iter()
49 .rev()
50 .collect::<Vec<_>>()
51 .join(" ");
52 let eb = text.ceil_char_boundary(end_byte.min(text.len()));
53 let after: String = text[eb..]
54 .split_whitespace()
55 .take(3)
56 .collect::<Vec<_>>()
57 .join(" ");
58 format!("{before}|{after}")
59 }
60
61 fn combined_hash(&self) -> u64 {
62 let mut hasher = DefaultHasher::new();
63 self.message_hash.hash(&mut hasher);
64 self.context_hash.hash(&mut hasher);
65 self.anchor_hash.hash(&mut hasher);
66 hasher.finish()
67 }
68}
69
70#[derive(Serialize, Deserialize)]
71struct IgnoreStoreData {
72 fingerprints: Vec<u64>,
73}
74
75pub struct IgnoreStore {
76 ignored_fingerprints: HashSet<u64>,
77 persist_path: Option<PathBuf>,
78}
79
80impl Default for IgnoreStore {
81 fn default() -> Self {
82 Self::new()
83 }
84}
85
86impl IgnoreStore {
87 #[must_use]
88 pub fn new() -> Self {
89 Self {
90 ignored_fingerprints: HashSet::new(),
91 persist_path: None,
92 }
93 }
94
95 pub fn load(workspace_root: &Path) -> Result<Self> {
97 let persist_path = workspace_root.join(".languagecheck").join("ignores.json");
98 let mut store = Self {
99 ignored_fingerprints: HashSet::new(),
100 persist_path: Some(persist_path.clone()),
101 };
102
103 if persist_path.exists() {
104 let data = std::fs::read_to_string(&persist_path)?;
105 let stored: IgnoreStoreData = serde_json::from_str(&data)?;
106 store.ignored_fingerprints = stored.fingerprints.into_iter().collect();
107 }
108
109 Ok(store)
110 }
111
112 pub fn ignore(&mut self, fingerprint: &DiagnosticFingerprint) {
113 self.ignored_fingerprints
114 .insert(fingerprint.combined_hash());
115 if let Err(e) = self.persist() {
116 eprintln!("Warning: failed to persist ignore store: {e}");
117 }
118 }
119
120 #[must_use]
121 pub fn is_ignored(&self, fingerprint: &DiagnosticFingerprint) -> bool {
122 self.ignored_fingerprints
123 .contains(&fingerprint.combined_hash())
124 }
125
126 fn persist(&self) -> Result<()> {
127 let Some(path) = &self.persist_path else {
128 return Ok(());
129 };
130
131 if let Some(parent) = path.parent() {
132 std::fs::create_dir_all(parent)?;
133 }
134
135 let data = IgnoreStoreData {
136 fingerprints: self.ignored_fingerprints.iter().copied().collect(),
137 };
138 std::fs::write(path, serde_json::to_string_pretty(&data)?)?;
139 Ok(())
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn fingerprint_same_input_same_hash() {
149 let fp1 = DiagnosticFingerprint::new("bad grammar", "This has bad grammar here.", 9, 12);
150 let fp2 = DiagnosticFingerprint::new("bad grammar", "This has bad grammar here.", 9, 12);
151 assert_eq!(fp1.combined_hash(), fp2.combined_hash());
152 }
153
154 #[test]
155 fn fingerprint_different_message_different_hash() {
156 let fp1 = DiagnosticFingerprint::new("bad grammar", "This has bad grammar here.", 9, 12);
157 let fp2 = DiagnosticFingerprint::new("spelling error", "This has bad grammar here.", 9, 12);
158 assert_ne!(fp1.combined_hash(), fp2.combined_hash());
159 }
160
161 #[test]
162 fn fingerprint_different_context_different_hash() {
163 let fp1 = DiagnosticFingerprint::new("error", "AAA error BBB", 4, 9);
164 let fp2 = DiagnosticFingerprint::new("error", "CCC error DDD", 4, 9);
165 assert_ne!(fp1.combined_hash(), fp2.combined_hash());
166 }
167
168 #[test]
169 fn fingerprint_word_anchor_extraction() {
170 let text = "one two three ERROR four five six";
171 let anchor = DiagnosticFingerprint::extract_word_anchor(text, 14, 19);
172 assert_eq!(anchor, "one two three|four five six");
173 }
174
175 #[test]
176 fn fingerprint_word_anchor_at_start() {
177 let text = "ERROR some words after";
178 let anchor = DiagnosticFingerprint::extract_word_anchor(text, 0, 5);
179 assert_eq!(anchor, "|some words after");
180 }
181
182 #[test]
183 fn fingerprint_word_anchor_at_end() {
184 let text = "words before ERROR";
185 let anchor = DiagnosticFingerprint::extract_word_anchor(text, 13, 18);
186 assert_eq!(anchor, "words before|");
187 }
188
189 #[test]
190 fn ignore_store_basic_operations() {
191 let mut store = IgnoreStore::new();
192 let fp = DiagnosticFingerprint::new("test msg", "some test msg context", 5, 13);
193
194 assert!(!store.is_ignored(&fp));
195 store.ignore(&fp);
196 assert!(store.is_ignored(&fp));
197 }
198
199 #[test]
200 fn ignore_store_does_not_ignore_different_fingerprint() {
201 let mut store = IgnoreStore::new();
202 let fp1 = DiagnosticFingerprint::new("msg A", "context A msg A here", 10, 15);
203 let fp2 = DiagnosticFingerprint::new("msg B", "context B msg B here", 10, 15);
204
205 store.ignore(&fp1);
206 assert!(store.is_ignored(&fp1));
207 assert!(!store.is_ignored(&fp2));
208 }
209
210 #[test]
211 fn ignore_store_persistence_roundtrip() {
212 let dir = std::env::temp_dir().join("lang_check_test_ignore_persist");
213 let _ = std::fs::remove_dir_all(&dir);
214 std::fs::create_dir_all(&dir).unwrap();
215
216 let fp = DiagnosticFingerprint::new("persist test", "the persist test text", 4, 16);
217
218 {
220 let mut store = IgnoreStore::load(&dir).unwrap();
221 store.ignore(&fp);
222 }
223
224 {
226 let store = IgnoreStore::load(&dir).unwrap();
227 assert!(store.is_ignored(&fp));
228 }
229
230 let _ = std::fs::remove_dir_all(&dir);
231 }
232
233 #[test]
234 fn fingerprint_handles_multibyte_utf8() {
235 let text = "Ärger mit Ölförderung"; let fp = DiagnosticFingerprint::new("test", text, 11, 15);
240 assert!(fp.combined_hash() != 0 || fp.combined_hash() == 0);
242 }
243
244 #[test]
245 fn ignore_store_empty_persistence() {
246 let dir = std::env::temp_dir().join("lang_check_test_ignore_empty");
247 let _ = std::fs::remove_dir_all(&dir);
248 std::fs::create_dir_all(&dir).unwrap();
249
250 let store = IgnoreStore::load(&dir).unwrap();
251 let fp = DiagnosticFingerprint::new("not ignored", "some context", 0, 5);
252 assert!(!store.is_ignored(&fp));
253
254 let _ = std::fs::remove_dir_all(&dir);
255 }
256}