1use std::collections::HashMap;
26use std::sync::Mutex;
27use std::sync::atomic::{AtomicBool, Ordering};
28
29static QUIET: AtomicBool = AtomicBool::new(false);
30
31static SUPPRESSED: Mutex<Option<HashMap<String, usize>>> = Mutex::new(None);
36
37pub fn init(quiet: bool) {
42 QUIET.store(quiet, Ordering::Relaxed);
43 if let Ok(mut guard) = SUPPRESSED.lock()
45 && guard.is_none()
46 {
47 *guard = Some(HashMap::new());
48 }
49}
50
51pub fn warn(msg: impl AsRef<str>) {
59 if QUIET.load(Ordering::Relaxed) {
60 return;
61 }
62
63 let msg = msg.as_ref();
64
65 if let Ok(mut guard) = SUPPRESSED.lock()
67 && let Some(ref mut map) = *guard
68 {
69 if let Some(count) = map.get_mut(msg) {
70 *count += 1;
72 return;
73 }
74 map.insert(msg.to_owned(), 0);
76 }
78
79 eprintln!("warning: {msg}");
80}
81
82#[cfg(test)]
88pub fn reset_for_test() {
89 QUIET.store(false, Ordering::Relaxed);
90 if let Ok(mut guard) = SUPPRESSED.lock() {
91 *guard = None;
92 }
93}
94
95#[cfg(test)]
100pub fn suppressed_count_for(msg: &str) -> usize {
101 SUPPRESSED
102 .lock()
103 .ok()
104 .and_then(|g| g.as_ref().and_then(|m| m.get(msg).copied()))
105 .unwrap_or(0)
106}
107
108#[cfg(test)]
113pub fn was_emitted(msg: &str) -> bool {
114 SUPPRESSED
115 .lock()
116 .ok()
117 .and_then(|g| g.as_ref().map(|m| m.contains_key(msg)))
118 .unwrap_or(false)
119}
120
121#[cfg(test)]
126pub fn any_tracked_starts_with(prefix: &str) -> bool {
127 SUPPRESSED
128 .lock()
129 .ok()
130 .and_then(|g| g.as_ref().map(|m| m.keys().any(|k| k.starts_with(prefix))))
131 .unwrap_or(false)
132}
133
134#[cfg(test)]
138pub fn total_suppressed() -> usize {
139 SUPPRESSED
140 .lock()
141 .ok()
142 .and_then(|g| g.as_ref().map(|m| m.values().sum::<usize>()))
143 .unwrap_or(0)
144}
145
146pub fn warn_glob_dir_overlap(dir: &std::path::Path, globs: &[String], matched_count: usize) {
156 if matched_count > 0 || globs.is_empty() {
157 return;
158 }
159
160 let dir_str = dir.to_string_lossy().replace('\\', "/");
163 let dir_str = dir_str
164 .strip_prefix("./")
165 .unwrap_or(&dir_str)
166 .trim_end_matches('/');
167
168 if dir_str == "." || dir_str.is_empty() {
170 return;
171 }
172
173 for glob in globs {
174 if glob.starts_with('!') {
176 continue;
177 }
178
179 let glob_norm = glob.replace('\\', "/");
181
182 let full_prefix = format!("{dir_str}/");
187 if let Some(rest) = glob_norm.strip_prefix(full_prefix.as_str())
188 && !rest.is_empty()
189 {
190 warn(format!(
191 "glob '{glob}' matched 0 files. Globs are relative to --dir '{dir_str}'. \
192 Did you mean '{rest}'?"
193 ));
194 return;
195 }
196
197 if let Some(last_component) = dir.file_name().and_then(|n| n.to_str()) {
201 let component_prefix = format!("{last_component}/");
202 if let Some(rest) = glob_norm.strip_prefix(component_prefix.as_str())
203 && !rest.is_empty()
204 {
205 warn(format!(
206 "glob '{glob}' matched 0 files. Globs are relative to --dir '{dir_str}'. \
207 Did you mean '{rest}'?"
208 ));
209 return;
210 }
211 }
212 }
213}
214
215pub fn flush_summary() {
220 let total_suppressed: usize = match SUPPRESSED.lock() {
221 Ok(guard) => guard.as_ref().map_or(0, |map| map.values().sum()),
222 Err(_) => return,
223 };
224
225 if !QUIET.load(Ordering::Relaxed) && total_suppressed > 0 {
226 eprintln!("warning: {total_suppressed} additional identical warning(s) suppressed");
227 }
228}
229
230#[cfg(test)]
237pub(crate) static WARN_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 #[test]
244 fn dedup_first_occurrence_not_suppressed() {
245 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
246 reset_for_test();
247 init(false);
248 warn("msg-dedup-first");
249 assert_eq!(suppressed_count_for("msg-dedup-first"), 0);
250 }
251
252 #[test]
253 fn dedup_second_occurrence_counted() {
254 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
255 reset_for_test();
256 init(false);
257 warn("msg-dedup-second");
258 warn("msg-dedup-second");
259 assert_eq!(suppressed_count_for("msg-dedup-second"), 1);
260 }
261
262 #[test]
263 fn dedup_many_occurrences_all_counted() {
264 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
265 reset_for_test();
266 init(false);
267 for _ in 0..5 {
268 warn("msg-dedup-many");
269 }
270 assert_eq!(suppressed_count_for("msg-dedup-many"), 4);
272 assert_eq!(total_suppressed(), 4);
273 }
274
275 #[test]
276 fn quiet_mode_suppresses_all() {
277 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
278 reset_for_test();
279 init(true);
280 warn("msg-quiet-a");
281 warn("msg-quiet-a");
282 assert_eq!(suppressed_count_for("msg-quiet-a"), 0);
284 }
285
286 #[test]
287 fn different_messages_not_deduped() {
288 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
289 reset_for_test();
290 init(false);
291 warn("msg-diff-a");
292 warn("msg-diff-b");
293 assert_eq!(suppressed_count_for("msg-diff-a"), 0);
294 assert_eq!(suppressed_count_for("msg-diff-b"), 0);
295 assert_eq!(total_suppressed(), 0);
296 }
297
298 #[test]
299 fn total_suppressed_across_multiple_messages() {
300 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
301 reset_for_test();
302 init(false);
303 for _ in 0..3 {
305 warn("msg-total-x");
306 }
307 for _ in 0..2 {
308 warn("msg-total-y");
309 }
310 assert_eq!(total_suppressed(), 3);
311 }
312
313 #[test]
318 fn glob_overlap_no_warning_when_results_found() {
319 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
320 reset_for_test();
321 init(false);
322 let dir = std::path::Path::new("files/en-us");
323 warn_glob_dir_overlap(dir, &["files/en-us/web/**".to_owned()], 5);
324 assert!(!was_emitted(
325 "glob 'files/en-us/web/**' matched 0 files. Globs are relative to --dir 'files/en-us'. Did you mean 'web/**'?"
326 ));
327 }
328
329 #[test]
330 fn glob_overlap_no_warning_when_dir_is_dot() {
331 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
332 reset_for_test();
333 init(false);
334 let dir = std::path::Path::new(".");
335 warn_glob_dir_overlap(dir, &["web/**".to_owned()], 0);
336 assert_eq!(total_suppressed(), 0);
338 }
339
340 #[test]
341 fn glob_overlap_warns_on_full_dir_prefix() {
342 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
343 reset_for_test();
344 init(false);
345 let dir = std::path::Path::new("files/en-us");
346 warn_glob_dir_overlap(dir, &["files/en-us/web/css/**".to_owned()], 0);
347 assert!(was_emitted(
348 "glob 'files/en-us/web/css/**' matched 0 files. Globs are relative to --dir 'files/en-us'. Did you mean 'web/css/**'?"
349 ));
350 }
351
352 #[test]
353 fn glob_overlap_warns_on_last_component() {
354 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
355 reset_for_test();
356 init(false);
357 let dir = std::path::Path::new("files/en-us");
358 warn_glob_dir_overlap(dir, &["en-us/web/**".to_owned()], 0);
359 assert!(was_emitted(
360 "glob 'en-us/web/**' matched 0 files. Globs are relative to --dir 'files/en-us'. Did you mean 'web/**'?"
361 ));
362 }
363
364 #[test]
365 fn glob_overlap_no_false_positive_on_partial_prefix() {
366 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
368 reset_for_test();
369 init(false);
370 let dir = std::path::Path::new("vault/notes");
371 warn_glob_dir_overlap(dir, &["notes-archive/**".to_owned()], 0);
372 assert_eq!(total_suppressed(), 0);
374 }
375
376 #[test]
377 fn glob_overlap_no_false_positive_on_partial_dir_prefix() {
378 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
380 reset_for_test();
381 init(false);
382 let dir = std::path::Path::new("files/en-us");
383 warn_glob_dir_overlap(dir, &["files/en-us-old/**".to_owned()], 0);
384 assert_eq!(total_suppressed(), 0);
386 }
387
388 #[test]
389 fn glob_overlap_skips_negation_patterns() {
390 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
391 reset_for_test();
392 init(false);
393 let dir = std::path::Path::new("docs");
394 warn_glob_dir_overlap(dir, &["!docs/drafts/**".to_owned()], 0);
395 assert_eq!(total_suppressed(), 0);
396 }
397
398 #[test]
399 fn glob_overlap_no_warning_when_globs_empty() {
400 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
401 reset_for_test();
402 init(false);
403 let dir = std::path::Path::new("docs");
404 warn_glob_dir_overlap(dir, &[], 0);
405 assert_eq!(total_suppressed(), 0);
406 }
407}