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 print).
34///        Inserted as 0 on the first occurrence; incremented on each subsequent one.
35static SUPPRESSED: Mutex<Option<HashMap<String, usize>>> = Mutex::new(None);
36
37/// Initialise the warning system.
38///
39/// Must be called once, early in `main`, after CLI flags are parsed.
40/// Calling it more than once is safe but redundant.
41pub fn init(quiet: bool) {
42    QUIET.store(quiet, Ordering::Relaxed);
43    // Initialise the dedup map (replacing None with an empty map).
44    if let Ok(mut guard) = SUPPRESSED.lock()
45        && guard.is_none()
46    {
47        *guard = Some(HashMap::new());
48    }
49}
50
51/// Emit a warning message to stderr.
52///
53/// - If quiet mode is active the message is silently discarded.
54/// - If the identical message has already been printed once, it is counted
55///   as suppressed rather than re-printed.
56/// - If `init` has not been called yet the message is always printed and
57///   dedup tracking is skipped (the dedup map is not yet initialised).
58pub fn warn(msg: impl AsRef<str>) {
59    if QUIET.load(Ordering::Relaxed) {
60        return;
61    }
62
63    let msg = msg.as_ref();
64
65    // Try dedup tracking.
66    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            // Already printed once — suppress and increment counter.
71            *count += 1;
72            return;
73        }
74        // First occurrence: insert with suppression count 0, fall through to print.
75        map.insert(msg.to_owned(), 0);
76        // guard.is_none() means init() hasn't been called yet — fall through to print.
77    }
78
79    eprintln!("warning: {msg}");
80}
81
82/// Reset the warning system to its initial state.
83///
84/// **For use in tests only.**  Clears the dedup map and resets the quiet flag
85/// so that each test starts from a clean slate.  This is necessary because the
86/// static globals persist across tests within the same process.
87#[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/// Return the number of times the given message was suppressed (seen after the
96/// first print).
97///
98/// **For use in tests only.**
99#[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/// Return whether the given message was tracked at least once after `init()`.
109///
110/// Note: warnings emitted before `init()` are not tracked.
111/// **For use in tests only.**
112#[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/// Return whether any tracked warning key starts with the given prefix.
122///
123/// Useful when the exact message contains a path that's known only at runtime.
124/// **For use in tests only.**
125#[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/// Return the total number of suppressed (duplicate) warning occurrences.
135///
136/// **For use in tests only.**
137#[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
146/// Emit a "did you mean…" warning when globs matched zero files and the glob
147/// pattern appears to redundantly include the configured `--dir` path.
148///
149/// Example: if `dir` is `files/en-us` and a glob is `files/en-us/web/css/**`,
150/// the user probably meant `web/css/**` (since globs are relative to `--dir`).
151///
152/// Only warns when `matched_count` is 0, at least one glob is provided, and
153/// at least one glob starts with a path component matching the dir or its last
154/// segment.
155pub 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    // Normalise dir to forward slashes, strip leading "./" and trailing "/"
161    // so comparisons work on Windows and with `--dir ./docs/` style inputs.
162    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    // Only consider non-trivial dir values (not ".")
169    if dir_str == "." || dir_str.is_empty() {
170        return;
171    }
172
173    for glob in globs {
174        // Skip negation patterns
175        if glob.starts_with('!') {
176            continue;
177        }
178
179        // Normalise glob the same way for consistent comparison
180        let glob_norm = glob.replace('\\', "/");
181
182        // Check if the glob starts with the full dir path followed by a '/'
183        // (e.g. "files/en-us/web/css/**" when dir is "files/en-us").
184        // Require a '/' boundary to avoid matching partial prefixes like
185        // "files/en-us-old/**".
186        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        // Also check if the glob starts with the last path component of dir
198        // followed by a '/' (e.g. "en-us/web/**" when dir is "files/en-us").
199        // Again require the '/' boundary to avoid "notes" matching "notes-archive/**".
200        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
215/// Print a summary of suppressed duplicate warnings, if any.
216///
217/// Should be called just before the process exits. Prints to stderr.
218/// If no warnings were suppressed (or `init` was never called) this is a no-op.
219pub 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/// Test-level mutex to serialise tests that touch the global warn state.
231///
232/// The global `SUPPRESSED` and `QUIET` statics are shared across all tests in
233/// the same process, so parallel execution would cause interference. Any test
234/// module that calls `reset_for_test()` / `init()` / `was_emitted()` must
235/// acquire this lock first.
236#[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        // First one is printed; remaining 4 are suppressed.
271        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        // In quiet mode nothing is tracked or printed.
283        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        // "x" fires 3 times → 2 suppressed; "y" fires 2 times → 1 suppressed
304        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    // -----------------------------------------------------------------------
314    // warn_glob_dir_overlap tests
315    // -----------------------------------------------------------------------
316
317    #[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        // No warning should be tracked at all
337        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        // "notes" should NOT match "notes-archive/**"
367        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        // Should not emit any glob-overlap warning
373        assert_eq!(total_suppressed(), 0);
374    }
375
376    #[test]
377    fn glob_overlap_no_false_positive_on_partial_dir_prefix() {
378        // "files/en-us" should NOT match "files/en-us-old/**"
379        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        // Should not emit any glob-overlap warning
385        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}