Skip to main content

hyalo_cli/
warn.rs

1//! Lightweight warning system for hyalo CLI.
2//!
3//! Provides:
4//! - Quiet mode: suppress all warnings when `-q` / `--quiet` is set.
5//! - Dedup tracking: identical warning messages are counted but only
6//!   printed once; `flush_summary()` reports how many were suppressed.
7//!
8//! # Initialisation
9//!
10//! Call `init(quiet)` as early as possible after CLI flags are parsed.
11//! Until `init` is called the system defaults to non-quiet mode, so any
12//! warnings emitted before initialisation (e.g. from config loading) are
13//! still printed.
14//!
15//! # Usage
16//!
17//! ```ignore
18//! warn::init(cli.quiet);
19//! // ...
20//! warn::warn("skipping foo.md: invalid frontmatter");
21//! // ...
22//! warn::flush_summary();
23//! ```
24
25use std::collections::HashMap;
26use std::sync::Mutex;
27use std::sync::atomic::{AtomicBool, Ordering};
28
29static QUIET: AtomicBool = AtomicBool::new(false);
30
31/// Per-message suppression counters.
32/// Key: warning message string.
33/// Value: number of times the message was suppressed (i.e. seen after the first).
34static SUPPRESSED: Mutex<Option<HashMap<String, usize>>> = Mutex::new(None);
35
36/// Initialise the warning system.
37///
38/// Must be called once, early in `main`, after CLI flags are parsed.
39/// Calling it more than once is safe but redundant.
40pub fn init(quiet: bool) {
41    QUIET.store(quiet, Ordering::Relaxed);
42    // Initialise the dedup map (replacing None with an empty map).
43    if let Ok(mut guard) = SUPPRESSED.lock()
44        && guard.is_none()
45    {
46        *guard = Some(HashMap::new());
47    }
48}
49
50/// Emit a warning message to stderr.
51///
52/// - If quiet mode is active the message is silently discarded.
53/// - If the identical message has already been printed once, it is counted
54///   as suppressed rather than re-printed.
55/// - If `init` has not been called yet the message is always printed and
56///   dedup tracking is skipped (the dedup map is not yet initialised).
57pub fn warn(msg: impl AsRef<str>) {
58    if QUIET.load(Ordering::Relaxed) {
59        return;
60    }
61
62    let msg = msg.as_ref();
63
64    // Try dedup tracking.
65    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            // Already printed once — suppress and increment counter.
70            *count += 1;
71            return;
72        }
73        // First occurrence: insert and fall through to print.
74        map.insert(msg.to_owned(), 1);
75        // guard.is_none() means init() hasn't been called yet — fall through to print.
76    }
77
78    eprintln!("warning: {msg}");
79}
80
81/// Reset the warning system to its initial state.
82///
83/// **For use in tests only.**  Clears the dedup map and resets the quiet flag
84/// so that each test starts from a clean slate.  This is necessary because the
85/// static globals persist across tests within the same process.
86#[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/// Return the number of times the given message was suppressed (seen after the
95/// first print).
96///
97/// **For use in tests only.**
98#[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/// Return whether the given message was tracked at least once after `init()`.
108///
109/// Note: warnings emitted before `init()` are not tracked.
110/// **For use in tests only.**
111#[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/// Return the total number of suppressed (duplicate) warning occurrences.
121///
122/// **For use in tests only.**
123#[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
138/// Emit a "did you mean…" warning when globs matched zero files and the glob
139/// pattern appears to redundantly include the configured `--dir` path.
140///
141/// Example: if `dir` is `files/en-us` and a glob is `files/en-us/web/css/**`,
142/// the user probably meant `web/css/**` (since globs are relative to `--dir`).
143///
144/// Only warns when `matched_count` is 0, at least one glob is provided, and
145/// at least one glob starts with a path component matching the dir or its last
146/// segment.
147pub 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    // Normalise dir to forward slashes, strip leading "./" and trailing "/"
153    // so comparisons work on Windows and with `--dir ./docs/` style inputs.
154    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    // Only consider non-trivial dir values (not ".")
161    if dir_str == "." || dir_str.is_empty() {
162        return;
163    }
164
165    for glob in globs {
166        // Skip negation patterns
167        if glob.starts_with('!') {
168            continue;
169        }
170
171        // Normalise glob the same way for consistent comparison
172        let glob_norm = glob.replace('\\', "/");
173
174        // Check if the glob starts with the full dir path followed by a '/'
175        // (e.g. "files/en-us/web/css/**" when dir is "files/en-us").
176        // Require a '/' boundary to avoid matching partial prefixes like
177        // "files/en-us-old/**".
178        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        // Also check if the glob starts with the last path component of dir
190        // followed by a '/' (e.g. "en-us/web/**" when dir is "files/en-us").
191        // Again require the '/' boundary to avoid "notes" matching "notes-archive/**".
192        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
207/// Print a summary of suppressed duplicate warnings, if any.
208///
209/// Should be called just before the process exits. Prints to stderr.
210/// If no warnings were suppressed (or `init` was never called) this is a no-op.
211pub 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                // Each entry counts: first print is not suppressed, so suppress count
216                // is (total_seen - 1). We stored total_seen in the map entry.
217                .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/// Test-level mutex to serialise tests that touch the global warn state.
229///
230/// The global `SUPPRESSED` and `QUIET` statics are shared across all tests in
231/// the same process, so parallel execution would cause interference. Any test
232/// module that calls `reset_for_test()` / `init()` / `was_emitted()` must
233/// acquire this lock first.
234#[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        // First one is printed; remaining 4 are suppressed.
269        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        // In quiet mode nothing is tracked or printed.
281        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        // "x" fires 3 times → 2 suppressed; "y" fires 2 times → 1 suppressed
302        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    // -----------------------------------------------------------------------
312    // warn_glob_dir_overlap tests
313    // -----------------------------------------------------------------------
314
315    #[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        // No warning should be tracked at all
335        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        // "notes" should NOT match "notes-archive/**"
365        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        // Should not emit any glob-overlap warning
371        assert_eq!(total_suppressed(), 0);
372    }
373
374    #[test]
375    fn glob_overlap_no_false_positive_on_partial_dir_prefix() {
376        // "files/en-us" should NOT match "files/en-us-old/**"
377        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        // Should not emit any glob-overlap warning
383        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}