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);
35
36pub fn init(quiet: bool) {
41 QUIET.store(quiet, Ordering::Relaxed);
42 if let Ok(mut guard) = SUPPRESSED.lock()
44 && guard.is_none()
45 {
46 *guard = Some(HashMap::new());
47 }
48}
49
50pub fn warn(msg: impl AsRef<str>) {
58 if QUIET.load(Ordering::Relaxed) {
59 return;
60 }
61
62 let msg = msg.as_ref();
63
64 if let Ok(mut guard) = SUPPRESSED.lock()
66 && let Some(ref mut map) = *guard
67 {
68 if let Some(count) = map.get_mut(msg) {
69 *count += 1;
71 return;
72 }
73 map.insert(msg.to_owned(), 1);
75 }
77
78 eprintln!("warning: {msg}");
79}
80
81#[cfg(test)]
87pub fn reset_for_test() {
88 QUIET.store(false, Ordering::Relaxed);
89 if let Ok(mut guard) = SUPPRESSED.lock() {
90 *guard = None;
91 }
92}
93
94#[cfg(test)]
99pub fn suppressed_count_for(msg: &str) -> usize {
100 SUPPRESSED
101 .lock()
102 .ok()
103 .and_then(|g| g.as_ref().and_then(|m| m.get(msg).copied()))
104 .map_or(0, |seen| seen.saturating_sub(1))
105}
106
107#[cfg(test)]
112pub fn was_emitted(msg: &str) -> bool {
113 SUPPRESSED
114 .lock()
115 .ok()
116 .and_then(|g| g.as_ref().map(|m| m.contains_key(msg)))
117 .unwrap_or(false)
118}
119
120#[cfg(test)]
124pub fn total_suppressed() -> usize {
125 SUPPRESSED
126 .lock()
127 .ok()
128 .and_then(|g| {
129 g.as_ref().map(|m| {
130 m.values()
131 .map(|&seen| seen.saturating_sub(1))
132 .sum::<usize>()
133 })
134 })
135 .unwrap_or(0)
136}
137
138pub fn warn_glob_dir_overlap(dir: &std::path::Path, globs: &[String], matched_count: usize) {
148 if matched_count > 0 || globs.is_empty() {
149 return;
150 }
151
152 let dir_str = dir.to_string_lossy().replace('\\', "/");
155 let dir_str = dir_str
156 .strip_prefix("./")
157 .unwrap_or(&dir_str)
158 .trim_end_matches('/');
159
160 if dir_str == "." || dir_str.is_empty() {
162 return;
163 }
164
165 for glob in globs {
166 if glob.starts_with('!') {
168 continue;
169 }
170
171 let glob_norm = glob.replace('\\', "/");
173
174 let full_prefix = format!("{dir_str}/");
179 if let Some(rest) = glob_norm.strip_prefix(full_prefix.as_str())
180 && !rest.is_empty()
181 {
182 warn(format!(
183 "glob '{glob}' matched 0 files. Globs are relative to --dir '{dir_str}'. \
184 Did you mean '{rest}'?"
185 ));
186 return;
187 }
188
189 if let Some(last_component) = dir.file_name().and_then(|n| n.to_str()) {
193 let component_prefix = format!("{last_component}/");
194 if let Some(rest) = glob_norm.strip_prefix(component_prefix.as_str())
195 && !rest.is_empty()
196 {
197 warn(format!(
198 "glob '{glob}' matched 0 files. Globs are relative to --dir '{dir_str}'. \
199 Did you mean '{rest}'?"
200 ));
201 return;
202 }
203 }
204 }
205}
206
207pub fn flush_summary() {
212 let total_suppressed: usize = match SUPPRESSED.lock() {
213 Ok(guard) => guard.as_ref().map_or(0, |map| {
214 map.values()
215 .map(|&seen| seen.saturating_sub(1))
218 .sum()
219 }),
220 Err(_) => return,
221 };
222
223 if !QUIET.load(Ordering::Relaxed) && total_suppressed > 0 {
224 eprintln!("warning: {total_suppressed} additional identical warning(s) suppressed");
225 }
226}
227
228#[cfg(test)]
235pub(crate) static WARN_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn dedup_first_occurrence_not_suppressed() {
243 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
244 reset_for_test();
245 init(false);
246 warn("msg-dedup-first");
247 assert_eq!(suppressed_count_for("msg-dedup-first"), 0);
248 }
249
250 #[test]
251 fn dedup_second_occurrence_counted() {
252 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
253 reset_for_test();
254 init(false);
255 warn("msg-dedup-second");
256 warn("msg-dedup-second");
257 assert_eq!(suppressed_count_for("msg-dedup-second"), 1);
258 }
259
260 #[test]
261 fn dedup_many_occurrences_all_counted() {
262 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
263 reset_for_test();
264 init(false);
265 for _ in 0..5 {
266 warn("msg-dedup-many");
267 }
268 assert_eq!(suppressed_count_for("msg-dedup-many"), 4);
270 assert_eq!(total_suppressed(), 4);
271 }
272
273 #[test]
274 fn quiet_mode_suppresses_all() {
275 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
276 reset_for_test();
277 init(true);
278 warn("msg-quiet-a");
279 warn("msg-quiet-a");
280 assert_eq!(suppressed_count_for("msg-quiet-a"), 0);
282 }
283
284 #[test]
285 fn different_messages_not_deduped() {
286 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
287 reset_for_test();
288 init(false);
289 warn("msg-diff-a");
290 warn("msg-diff-b");
291 assert_eq!(suppressed_count_for("msg-diff-a"), 0);
292 assert_eq!(suppressed_count_for("msg-diff-b"), 0);
293 assert_eq!(total_suppressed(), 0);
294 }
295
296 #[test]
297 fn total_suppressed_across_multiple_messages() {
298 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
299 reset_for_test();
300 init(false);
301 for _ in 0..3 {
303 warn("msg-total-x");
304 }
305 for _ in 0..2 {
306 warn("msg-total-y");
307 }
308 assert_eq!(total_suppressed(), 3);
309 }
310
311 #[test]
316 fn glob_overlap_no_warning_when_results_found() {
317 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
318 reset_for_test();
319 init(false);
320 let dir = std::path::Path::new("files/en-us");
321 warn_glob_dir_overlap(dir, &["files/en-us/web/**".to_owned()], 5);
322 assert!(!was_emitted(
323 "glob 'files/en-us/web/**' matched 0 files. Globs are relative to --dir 'files/en-us'. Did you mean 'web/**'?"
324 ));
325 }
326
327 #[test]
328 fn glob_overlap_no_warning_when_dir_is_dot() {
329 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
330 reset_for_test();
331 init(false);
332 let dir = std::path::Path::new(".");
333 warn_glob_dir_overlap(dir, &["web/**".to_owned()], 0);
334 assert_eq!(total_suppressed(), 0);
336 }
337
338 #[test]
339 fn glob_overlap_warns_on_full_dir_prefix() {
340 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
341 reset_for_test();
342 init(false);
343 let dir = std::path::Path::new("files/en-us");
344 warn_glob_dir_overlap(dir, &["files/en-us/web/css/**".to_owned()], 0);
345 assert!(was_emitted(
346 "glob 'files/en-us/web/css/**' matched 0 files. Globs are relative to --dir 'files/en-us'. Did you mean 'web/css/**'?"
347 ));
348 }
349
350 #[test]
351 fn glob_overlap_warns_on_last_component() {
352 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
353 reset_for_test();
354 init(false);
355 let dir = std::path::Path::new("files/en-us");
356 warn_glob_dir_overlap(dir, &["en-us/web/**".to_owned()], 0);
357 assert!(was_emitted(
358 "glob 'en-us/web/**' matched 0 files. Globs are relative to --dir 'files/en-us'. Did you mean 'web/**'?"
359 ));
360 }
361
362 #[test]
363 fn glob_overlap_no_false_positive_on_partial_prefix() {
364 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
366 reset_for_test();
367 init(false);
368 let dir = std::path::Path::new("vault/notes");
369 warn_glob_dir_overlap(dir, &["notes-archive/**".to_owned()], 0);
370 assert_eq!(total_suppressed(), 0);
372 }
373
374 #[test]
375 fn glob_overlap_no_false_positive_on_partial_dir_prefix() {
376 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
378 reset_for_test();
379 init(false);
380 let dir = std::path::Path::new("files/en-us");
381 warn_glob_dir_overlap(dir, &["files/en-us-old/**".to_owned()], 0);
382 assert_eq!(total_suppressed(), 0);
384 }
385
386 #[test]
387 fn glob_overlap_skips_negation_patterns() {
388 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
389 reset_for_test();
390 init(false);
391 let dir = std::path::Path::new("docs");
392 warn_glob_dir_overlap(dir, &["!docs/drafts/**".to_owned()], 0);
393 assert_eq!(total_suppressed(), 0);
394 }
395
396 #[test]
397 fn glob_overlap_no_warning_when_globs_empty() {
398 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
399 reset_for_test();
400 init(false);
401 let dir = std::path::Path::new("docs");
402 warn_glob_dir_overlap(dir, &[], 0);
403 assert_eq!(total_suppressed(), 0);
404 }
405}