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 the total number of suppressed (duplicate) warning occurrences.
122///
123/// **For use in tests only.**
124#[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
133/// Emit a "did you mean…" warning when globs matched zero files and the glob
134/// pattern appears to redundantly include the configured `--dir` path.
135///
136/// Example: if `dir` is `files/en-us` and a glob is `files/en-us/web/css/**`,
137/// the user probably meant `web/css/**` (since globs are relative to `--dir`).
138///
139/// Only warns when `matched_count` is 0, at least one glob is provided, and
140/// at least one glob starts with a path component matching the dir or its last
141/// segment.
142pub 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    // Normalise dir to forward slashes, strip leading "./" and trailing "/"
148    // so comparisons work on Windows and with `--dir ./docs/` style inputs.
149    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    // Only consider non-trivial dir values (not ".")
156    if dir_str == "." || dir_str.is_empty() {
157        return;
158    }
159
160    for glob in globs {
161        // Skip negation patterns
162        if glob.starts_with('!') {
163            continue;
164        }
165
166        // Normalise glob the same way for consistent comparison
167        let glob_norm = glob.replace('\\', "/");
168
169        // Check if the glob starts with the full dir path followed by a '/'
170        // (e.g. "files/en-us/web/css/**" when dir is "files/en-us").
171        // Require a '/' boundary to avoid matching partial prefixes like
172        // "files/en-us-old/**".
173        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        // Also check if the glob starts with the last path component of dir
185        // followed by a '/' (e.g. "en-us/web/**" when dir is "files/en-us").
186        // Again require the '/' boundary to avoid "notes" matching "notes-archive/**".
187        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
202/// Print a summary of suppressed duplicate warnings, if any.
203///
204/// Should be called just before the process exits. Prints to stderr.
205/// If no warnings were suppressed (or `init` was never called) this is a no-op.
206pub 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/// Test-level mutex to serialise tests that touch the global warn state.
218///
219/// The global `SUPPRESSED` and `QUIET` statics are shared across all tests in
220/// the same process, so parallel execution would cause interference. Any test
221/// module that calls `reset_for_test()` / `init()` / `was_emitted()` must
222/// acquire this lock first.
223#[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        // First one is printed; remaining 4 are suppressed.
258        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        // In quiet mode nothing is tracked or printed.
270        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        // "x" fires 3 times → 2 suppressed; "y" fires 2 times → 1 suppressed
291        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    // -----------------------------------------------------------------------
301    // warn_glob_dir_overlap tests
302    // -----------------------------------------------------------------------
303
304    #[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        // No warning should be tracked at all
324        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        // "notes" should NOT match "notes-archive/**"
354        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        // Should not emit any glob-overlap warning
360        assert_eq!(total_suppressed(), 0);
361    }
362
363    #[test]
364    fn glob_overlap_no_false_positive_on_partial_dir_prefix() {
365        // "files/en-us" should NOT match "files/en-us-old/**"
366        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        // Should not emit any glob-overlap warning
372        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}