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)]
125pub fn total_suppressed() -> usize {
126 SUPPRESSED
127 .lock()
128 .ok()
129 .and_then(|g| g.as_ref().map(|m| m.values().sum::<usize>()))
130 .unwrap_or(0)
131}
132
133pub fn warn_glob_dir_overlap(dir: &std::path::Path, globs: &[String], matched_count: usize) {
143 if matched_count > 0 || globs.is_empty() {
144 return;
145 }
146
147 let dir_str = dir.to_string_lossy().replace('\\', "/");
150 let dir_str = dir_str
151 .strip_prefix("./")
152 .unwrap_or(&dir_str)
153 .trim_end_matches('/');
154
155 if dir_str == "." || dir_str.is_empty() {
157 return;
158 }
159
160 for glob in globs {
161 if glob.starts_with('!') {
163 continue;
164 }
165
166 let glob_norm = glob.replace('\\', "/");
168
169 let full_prefix = format!("{dir_str}/");
174 if let Some(rest) = glob_norm.strip_prefix(full_prefix.as_str())
175 && !rest.is_empty()
176 {
177 warn(format!(
178 "glob '{glob}' matched 0 files. Globs are relative to --dir '{dir_str}'. \
179 Did you mean '{rest}'?"
180 ));
181 return;
182 }
183
184 if let Some(last_component) = dir.file_name().and_then(|n| n.to_str()) {
188 let component_prefix = format!("{last_component}/");
189 if let Some(rest) = glob_norm.strip_prefix(component_prefix.as_str())
190 && !rest.is_empty()
191 {
192 warn(format!(
193 "glob '{glob}' matched 0 files. Globs are relative to --dir '{dir_str}'. \
194 Did you mean '{rest}'?"
195 ));
196 return;
197 }
198 }
199 }
200}
201
202pub fn flush_summary() {
207 let total_suppressed: usize = match SUPPRESSED.lock() {
208 Ok(guard) => guard.as_ref().map_or(0, |map| map.values().sum()),
209 Err(_) => return,
210 };
211
212 if !QUIET.load(Ordering::Relaxed) && total_suppressed > 0 {
213 eprintln!("warning: {total_suppressed} additional identical warning(s) suppressed");
214 }
215}
216
217#[cfg(test)]
224pub(crate) static WARN_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn dedup_first_occurrence_not_suppressed() {
232 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
233 reset_for_test();
234 init(false);
235 warn("msg-dedup-first");
236 assert_eq!(suppressed_count_for("msg-dedup-first"), 0);
237 }
238
239 #[test]
240 fn dedup_second_occurrence_counted() {
241 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
242 reset_for_test();
243 init(false);
244 warn("msg-dedup-second");
245 warn("msg-dedup-second");
246 assert_eq!(suppressed_count_for("msg-dedup-second"), 1);
247 }
248
249 #[test]
250 fn dedup_many_occurrences_all_counted() {
251 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
252 reset_for_test();
253 init(false);
254 for _ in 0..5 {
255 warn("msg-dedup-many");
256 }
257 assert_eq!(suppressed_count_for("msg-dedup-many"), 4);
259 assert_eq!(total_suppressed(), 4);
260 }
261
262 #[test]
263 fn quiet_mode_suppresses_all() {
264 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
265 reset_for_test();
266 init(true);
267 warn("msg-quiet-a");
268 warn("msg-quiet-a");
269 assert_eq!(suppressed_count_for("msg-quiet-a"), 0);
271 }
272
273 #[test]
274 fn different_messages_not_deduped() {
275 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
276 reset_for_test();
277 init(false);
278 warn("msg-diff-a");
279 warn("msg-diff-b");
280 assert_eq!(suppressed_count_for("msg-diff-a"), 0);
281 assert_eq!(suppressed_count_for("msg-diff-b"), 0);
282 assert_eq!(total_suppressed(), 0);
283 }
284
285 #[test]
286 fn total_suppressed_across_multiple_messages() {
287 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
288 reset_for_test();
289 init(false);
290 for _ in 0..3 {
292 warn("msg-total-x");
293 }
294 for _ in 0..2 {
295 warn("msg-total-y");
296 }
297 assert_eq!(total_suppressed(), 3);
298 }
299
300 #[test]
305 fn glob_overlap_no_warning_when_results_found() {
306 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
307 reset_for_test();
308 init(false);
309 let dir = std::path::Path::new("files/en-us");
310 warn_glob_dir_overlap(dir, &["files/en-us/web/**".to_owned()], 5);
311 assert!(!was_emitted(
312 "glob 'files/en-us/web/**' matched 0 files. Globs are relative to --dir 'files/en-us'. Did you mean 'web/**'?"
313 ));
314 }
315
316 #[test]
317 fn glob_overlap_no_warning_when_dir_is_dot() {
318 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
319 reset_for_test();
320 init(false);
321 let dir = std::path::Path::new(".");
322 warn_glob_dir_overlap(dir, &["web/**".to_owned()], 0);
323 assert_eq!(total_suppressed(), 0);
325 }
326
327 #[test]
328 fn glob_overlap_warns_on_full_dir_prefix() {
329 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
330 reset_for_test();
331 init(false);
332 let dir = std::path::Path::new("files/en-us");
333 warn_glob_dir_overlap(dir, &["files/en-us/web/css/**".to_owned()], 0);
334 assert!(was_emitted(
335 "glob 'files/en-us/web/css/**' matched 0 files. Globs are relative to --dir 'files/en-us'. Did you mean 'web/css/**'?"
336 ));
337 }
338
339 #[test]
340 fn glob_overlap_warns_on_last_component() {
341 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
342 reset_for_test();
343 init(false);
344 let dir = std::path::Path::new("files/en-us");
345 warn_glob_dir_overlap(dir, &["en-us/web/**".to_owned()], 0);
346 assert!(was_emitted(
347 "glob 'en-us/web/**' matched 0 files. Globs are relative to --dir 'files/en-us'. Did you mean 'web/**'?"
348 ));
349 }
350
351 #[test]
352 fn glob_overlap_no_false_positive_on_partial_prefix() {
353 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
355 reset_for_test();
356 init(false);
357 let dir = std::path::Path::new("vault/notes");
358 warn_glob_dir_overlap(dir, &["notes-archive/**".to_owned()], 0);
359 assert_eq!(total_suppressed(), 0);
361 }
362
363 #[test]
364 fn glob_overlap_no_false_positive_on_partial_dir_prefix() {
365 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
367 reset_for_test();
368 init(false);
369 let dir = std::path::Path::new("files/en-us");
370 warn_glob_dir_overlap(dir, &["files/en-us-old/**".to_owned()], 0);
371 assert_eq!(total_suppressed(), 0);
373 }
374
375 #[test]
376 fn glob_overlap_skips_negation_patterns() {
377 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
378 reset_for_test();
379 init(false);
380 let dir = std::path::Path::new("docs");
381 warn_glob_dir_overlap(dir, &["!docs/drafts/**".to_owned()], 0);
382 assert_eq!(total_suppressed(), 0);
383 }
384
385 #[test]
386 fn glob_overlap_no_warning_when_globs_empty() {
387 let _guard = super::WARN_TEST_LOCK.lock().unwrap();
388 reset_for_test();
389 init(false);
390 let dir = std::path::Path::new("docs");
391 warn_glob_dir_overlap(dir, &[], 0);
392 assert_eq!(total_suppressed(), 0);
393 }
394}