Skip to main content

zccache/depgraph/
watcher_support.rs

1//! Watcher integration support for the dependency graph.
2//!
3//! Provides:
4//! - `WatchSet` — tracks which directories should be watched and which
5//!   filenames within them are relevant.
6//! - Shadow detection — identifies when a newly created file in a
7//!   higher-priority include directory would shadow an existing resolved
8//!   include.
9//! - Unresolved include resolution — identifies when a newly created file
10//!   matches a previously unresolved `#include`.
11
12use std::collections::{HashMap, HashSet};
13use std::path::Path;
14
15use super::context::ContextKey;
16use super::graph::DepGraph;
17use crate::core::NormalizedPath;
18
19/// Set of directories that should be watched, with tracked filenames per directory.
20///
21/// The watcher layer uses this to decide which directories to register with
22/// the OS file-watcher (non-recursive) and which events to filter for.
23#[derive(Debug, Clone, Default)]
24pub struct WatchSet {
25    /// Maps directory path → set of tracked file names within it.
26    dirs: HashMap<NormalizedPath, HashSet<String>>,
27}
28
29fn normalize_watch_filename(name: &std::ffi::OsStr) -> String {
30    #[cfg(windows)]
31    {
32        name.to_string_lossy().to_ascii_lowercase()
33    }
34
35    #[cfg(not(windows))]
36    {
37        name.to_string_lossy().into_owned()
38    }
39}
40
41impl WatchSet {
42    /// Create an empty watch set.
43    #[must_use]
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// Build a watch set from a list of absolute file paths.
49    /// Each file's parent directory is added, with the filename tracked.
50    #[must_use]
51    pub fn from_paths(paths: impl IntoIterator<Item = impl AsRef<Path>>) -> Self {
52        let mut dirs: HashMap<NormalizedPath, HashSet<String>> = HashMap::new();
53        for path in paths {
54            let path = path.as_ref();
55            if let (Some(parent), Some(name)) = (path.parent(), path.file_name()) {
56                dirs.entry(parent.to_path_buf().into())
57                    .or_default()
58                    .insert(normalize_watch_filename(name));
59            }
60        }
61        Self { dirs }
62    }
63
64    /// Add a directory to watch (even if it has no tracked files yet).
65    /// Used for include search directories where new files might appear.
66    pub fn add_dir(&mut self, dir: NormalizedPath) {
67        self.dirs.entry(dir).or_default();
68    }
69
70    /// Add a specific file path to the watch set.
71    pub fn add_path(&mut self, path: &Path) {
72        if let (Some(parent), Some(name)) = (path.parent(), path.file_name()) {
73            self.dirs
74                .entry(parent.to_path_buf().into())
75                .or_default()
76                .insert(normalize_watch_filename(name));
77        }
78    }
79
80    /// Get all directories that need to be watched.
81    pub fn dirs(&self) -> impl Iterator<Item = &NormalizedPath> {
82        self.dirs.keys()
83    }
84
85    /// Check if a path is in the tracked file set.
86    #[must_use]
87    pub fn is_tracked(&self, path: &Path) -> bool {
88        if let (Some(parent), Some(name)) = (path.parent(), path.file_name()) {
89            let parent = NormalizedPath::new(parent);
90            self.dirs
91                .get(&parent)
92                .is_some_and(|names| names.contains(&normalize_watch_filename(name)))
93        } else {
94            false
95        }
96    }
97
98    /// Check if a directory is in the watch set.
99    #[must_use]
100    pub fn is_watched(&self, dir: &Path) -> bool {
101        self.dirs.contains_key(&NormalizedPath::new(dir))
102    }
103
104    /// Number of watched directories.
105    #[must_use]
106    pub fn dir_count(&self) -> usize {
107        self.dirs.len()
108    }
109
110    /// Total number of tracked files across all directories.
111    #[must_use]
112    pub fn file_count(&self) -> usize {
113        self.dirs.values().map(HashSet::len).sum()
114    }
115
116    /// Directories in `self` that are not in `previous`.
117    /// These are newly added directories that need watch registration.
118    #[must_use]
119    pub fn new_dirs_vs(&self, previous: &WatchSet) -> Vec<NormalizedPath> {
120        self.dirs
121            .keys()
122            .filter(|d| !previous.dirs.contains_key(*d))
123            .cloned()
124            .collect()
125    }
126
127    /// Directories in `previous` that are not in `self`.
128    /// These are removed directories whose watches can be dropped.
129    #[must_use]
130    pub fn removed_dirs_vs(&self, previous: &WatchSet) -> Vec<NormalizedPath> {
131        previous
132            .dirs
133            .keys()
134            .filter(|d| !self.dirs.contains_key(*d))
135            .cloned()
136            .collect()
137    }
138}
139
140/// Check if `dir_a` appears before `dir_b` in the given search path order.
141///
142/// Returns `true` if `dir_a` has higher priority (appears earlier) than `dir_b`.
143/// Returns `false` if either directory is not in the search paths or they are equal.
144fn is_higher_priority(
145    dir_a: &Path,
146    dir_b: &Path,
147    search: &super::search_paths::IncludeSearchPaths,
148) -> bool {
149    let all_dirs: Vec<&Path> = search.all_search_dirs().collect();
150
151    let pos_a = all_dirs.iter().position(|d| *d == dir_a);
152    let pos_b = all_dirs.iter().position(|d| *d == dir_b);
153
154    match (pos_a, pos_b) {
155        (Some(a), Some(b)) => a < b,
156        _ => false,
157    }
158}
159
160impl DepGraph {
161    /// Compute the set of directories that should be watched.
162    ///
163    /// Includes:
164    /// - Parent directories of all resolved include paths (to detect modifications)
165    /// - Parent directories of source files (to detect source changes)
166    /// - All include search directories from all contexts (to detect new files)
167    #[must_use]
168    pub fn watch_set(&self) -> WatchSet {
169        let mut ws = WatchSet::new();
170
171        for entry in self.contexts_iter() {
172            let ctx_entry = entry.value();
173
174            // Source file parent dir.
175            ws.add_path(&ctx_entry.context.source_file);
176
177            // All resolved include parent dirs.
178            for inc in &ctx_entry.resolved_includes {
179                ws.add_path(inc);
180            }
181
182            // All include search dirs (for new-file detection).
183            for dir in ctx_entry.context.include_search.all_search_dirs() {
184                ws.add_dir(dir.into());
185            }
186        }
187
188        ws
189    }
190
191    /// Check if a newly created file shadows any existing resolved include
192    /// in any context. Returns context keys that should be marked stale.
193    ///
194    /// A shadow occurs when `new_file` has the same filename as an existing
195    /// resolved include, and `new_file`'s directory appears earlier (higher
196    /// priority) in that context's include search path.
197    #[must_use]
198    pub fn check_shadow(&self, new_file: &Path) -> Vec<ContextKey> {
199        let new_name = match new_file.file_name() {
200            Some(n) => n.to_string_lossy().into_owned(),
201            None => return Vec::new(),
202        };
203        let new_dir = match new_file.parent() {
204            Some(d) => d,
205            None => return Vec::new(),
206        };
207
208        let mut affected = Vec::new();
209
210        for entry in self.contexts_iter() {
211            let ctx_entry = entry.value();
212            let search = &ctx_entry.context.include_search;
213
214            for resolved_path in &ctx_entry.resolved_includes {
215                let resolved_name = match resolved_path.file_name() {
216                    Some(n) => n.to_string_lossy(),
217                    None => continue,
218                };
219
220                if *resolved_name != new_name {
221                    continue;
222                }
223
224                let resolved_dir = match resolved_path.parent() {
225                    Some(d) => d,
226                    None => continue,
227                };
228
229                // Same directory — not a shadow, just a replacement (handled
230                // by the watcher's Modified event).
231                if resolved_dir == new_dir {
232                    continue;
233                }
234
235                if is_higher_priority(new_dir, resolved_dir, search) {
236                    affected.push(*entry.key());
237                    break; // Context already affected, move to next.
238                }
239            }
240        }
241
242        affected
243    }
244
245    /// Check if a newly created file resolves any previously unresolved
246    /// `#include` in any context. Returns affected context keys.
247    #[must_use]
248    pub fn check_new_resolve(&self, new_file: &Path) -> Vec<ContextKey> {
249        let new_name = match new_file.file_name() {
250            Some(n) => n.to_string_lossy().into_owned(),
251            None => return Vec::new(),
252        };
253
254        let mut affected = Vec::new();
255
256        for entry in self.contexts_iter() {
257            let ctx_entry = entry.value();
258
259            for unresolved in &ctx_entry.unresolved_includes {
260                // Unresolved includes may be bare names ("foo.h") or paths
261                // ("path/to/foo.h"). Compare against the filename.
262                let unresolved_name = Path::new(unresolved)
263                    .file_name()
264                    .map(|n| n.to_string_lossy().into_owned())
265                    .unwrap_or_default();
266
267                if unresolved_name == new_name {
268                    affected.push(*entry.key());
269                    break;
270                }
271            }
272        }
273
274        affected
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::core::NormalizedPath;
282
283    use super::super::context::CompileContext;
284    use super::super::scanner::ScanResult;
285    use super::super::search_paths::IncludeSearchPaths;
286    use crate::hash::ContentHash;
287
288    fn dummy_hash(path: &Path) -> Option<ContentHash> {
289        Some(crate::hash::hash_bytes(path.to_string_lossy().as_bytes()))
290    }
291
292    fn make_ctx_with_search(source: &str, search: IncludeSearchPaths) -> CompileContext {
293        CompileContext {
294            source_file: NormalizedPath::from(source),
295            include_search: search,
296            defines: Vec::new(),
297            flags: Vec::new(),
298            force_includes: Vec::new(),
299            unknown_flags: Vec::new(),
300        }
301    }
302
303    // --- WatchSet tests ---
304
305    #[test]
306    fn watch_set_from_paths_groups_by_dir() {
307        let ws = WatchSet::from_paths([
308            NormalizedPath::from("/inc/a.h"),
309            NormalizedPath::from("/inc/b.h"),
310            NormalizedPath::from("/src/main.c"),
311        ]);
312        assert_eq!(ws.dir_count(), 2);
313        assert_eq!(ws.file_count(), 3);
314        assert!(ws.is_watched(Path::new("/inc")));
315        assert!(ws.is_watched(Path::new("/src")));
316    }
317
318    #[test]
319    fn watch_set_deduplication() {
320        let ws = WatchSet::from_paths([
321            NormalizedPath::from("/inc/a.h"),
322            NormalizedPath::from("/inc/a.h"), // duplicate
323        ]);
324        assert_eq!(ws.dir_count(), 1);
325        assert_eq!(ws.file_count(), 1);
326    }
327
328    #[test]
329    fn watch_set_is_tracked() {
330        let ws = WatchSet::from_paths([NormalizedPath::from("/inc/a.h")]);
331        assert!(ws.is_tracked(Path::new("/inc/a.h")));
332        assert!(!ws.is_tracked(Path::new("/inc/b.h")));
333        assert!(!ws.is_tracked(Path::new("/other/a.h")));
334    }
335
336    #[cfg(windows)]
337    #[test]
338    fn watch_set_is_tracked_ignores_filename_case_on_windows() {
339        let ws = WatchSet::from_paths([NormalizedPath::from(r"C:\inc\Config.h")]);
340        assert!(ws.is_tracked(Path::new(r"C:\inc\config.h")));
341        assert!(ws.is_tracked(Path::new(r"C:\inc\CONFIG.H")));
342    }
343
344    #[test]
345    fn watch_set_add_dir_empty() {
346        let mut ws = WatchSet::new();
347        ws.add_dir(NormalizedPath::from("/usr/include"));
348        assert!(ws.is_watched(Path::new("/usr/include")));
349        assert_eq!(ws.file_count(), 0);
350        assert_eq!(ws.dir_count(), 1);
351    }
352
353    #[test]
354    fn watch_set_add_path() {
355        let mut ws = WatchSet::new();
356        ws.add_path(Path::new("/inc/foo.h"));
357        assert!(ws.is_tracked(Path::new("/inc/foo.h")));
358        assert!(ws.is_watched(Path::new("/inc")));
359    }
360
361    #[test]
362    fn watch_set_new_dirs_vs() {
363        let old = WatchSet::from_paths([NormalizedPath::from("/inc/a.h")]);
364        let new = WatchSet::from_paths([
365            NormalizedPath::from("/inc/a.h"),
366            NormalizedPath::from("/new/b.h"),
367        ]);
368        let added = new.new_dirs_vs(&old);
369        assert_eq!(added, vec![NormalizedPath::from("/new")]);
370    }
371
372    #[test]
373    fn watch_set_removed_dirs_vs() {
374        let old = WatchSet::from_paths([
375            NormalizedPath::from("/inc/a.h"),
376            NormalizedPath::from("/old/b.h"),
377        ]);
378        let new = WatchSet::from_paths([NormalizedPath::from("/inc/a.h")]);
379        let removed = new.removed_dirs_vs(&old);
380        assert_eq!(removed, vec![NormalizedPath::from("/old")]);
381    }
382
383    // --- DepGraph::watch_set() tests ---
384
385    #[test]
386    fn watch_set_includes_source_and_headers() {
387        let graph = DepGraph::new();
388        let ctx = make_ctx_with_search("/src/main.c", IncludeSearchPaths::default());
389        let key = graph.register(ctx);
390
391        let scan = ScanResult {
392            resolved: vec![
393                NormalizedPath::from("/inc/a.h"),
394                NormalizedPath::from("/inc/b.h"),
395            ],
396            unresolved: Vec::new(),
397            has_computed: false,
398        };
399        graph.update(&key, scan, dummy_hash);
400
401        let ws = graph.watch_set();
402        assert!(ws.is_tracked(Path::new("/src/main.c")));
403        assert!(ws.is_tracked(Path::new("/inc/a.h")));
404        assert!(ws.is_tracked(Path::new("/inc/b.h")));
405    }
406
407    #[test]
408    fn watch_set_includes_search_dirs() {
409        let graph = DepGraph::new();
410        let search = IncludeSearchPaths {
411            user: vec![NormalizedPath::from("/project/include")],
412            system: vec![NormalizedPath::from("/usr/include")],
413            ..Default::default()
414        };
415        let ctx = make_ctx_with_search("/src/main.c", search);
416        graph.register(ctx);
417
418        let ws = graph.watch_set();
419        // Search dirs are watched even if no files resolve there yet.
420        assert!(ws.is_watched(Path::new("/project/include")));
421        assert!(ws.is_watched(Path::new("/usr/include")));
422    }
423
424    #[test]
425    fn watch_set_dedupes_across_contexts() {
426        let graph = DepGraph::new();
427        let search = IncludeSearchPaths {
428            user: vec![NormalizedPath::from("/inc")],
429            ..Default::default()
430        };
431
432        let ctx1 = make_ctx_with_search("/src/a.c", search.clone());
433        let key1 = graph.register(ctx1);
434        let ctx2 = make_ctx_with_search("/src/b.c", search);
435        let key2 = graph.register(ctx2);
436
437        let scan = ScanResult {
438            resolved: vec![NormalizedPath::from("/inc/common.h")],
439            unresolved: Vec::new(),
440            has_computed: false,
441        };
442        graph.update(&key1, scan.clone(), dummy_hash);
443        graph.update(&key2, scan, dummy_hash);
444
445        let ws = graph.watch_set();
446        // /inc should appear once, not twice.
447        let inc_count = ws
448            .dirs()
449            .filter(|d| d.as_path() == Path::new("/inc"))
450            .count();
451        assert_eq!(inc_count, 1);
452    }
453
454    // --- Shadow detection tests ---
455
456    #[test]
457    fn check_shadow_detects_higher_priority() {
458        let graph = DepGraph::new();
459        let search = IncludeSearchPaths {
460            user: vec![NormalizedPath::from("/high"), NormalizedPath::from("/low")],
461            ..Default::default()
462        };
463        let ctx = make_ctx_with_search("/src/main.c", search);
464        let key = graph.register(ctx);
465
466        // foo.h currently resolves from /low.
467        let scan = ScanResult {
468            resolved: vec![NormalizedPath::from("/low/foo.h")],
469            unresolved: Vec::new(),
470            has_computed: false,
471        };
472        graph.update(&key, scan, dummy_hash);
473
474        // New foo.h appears in /high (higher priority).
475        let affected = graph.check_shadow(Path::new("/high/foo.h"));
476        assert_eq!(affected, vec![key]);
477    }
478
479    #[test]
480    fn check_shadow_no_false_positive_lower_priority() {
481        let graph = DepGraph::new();
482        let search = IncludeSearchPaths {
483            user: vec![NormalizedPath::from("/high"), NormalizedPath::from("/low")],
484            ..Default::default()
485        };
486        let ctx = make_ctx_with_search("/src/main.c", search);
487        let key = graph.register(ctx);
488
489        // foo.h already resolves from /high.
490        let scan = ScanResult {
491            resolved: vec![NormalizedPath::from("/high/foo.h")],
492            unresolved: Vec::new(),
493            has_computed: false,
494        };
495        graph.update(&key, scan, dummy_hash);
496
497        // New foo.h appears in /low (lower priority) — NOT a shadow.
498        let affected = graph.check_shadow(Path::new("/low/foo.h"));
499        assert!(affected.is_empty());
500    }
501
502    #[test]
503    fn check_shadow_different_filename_no_match() {
504        let graph = DepGraph::new();
505        let search = IncludeSearchPaths {
506            user: vec![NormalizedPath::from("/high"), NormalizedPath::from("/low")],
507            ..Default::default()
508        };
509        let ctx = make_ctx_with_search("/src/main.c", search);
510        let key = graph.register(ctx);
511
512        let scan = ScanResult {
513            resolved: vec![NormalizedPath::from("/low/foo.h")],
514            unresolved: Vec::new(),
515            has_computed: false,
516        };
517        graph.update(&key, scan, dummy_hash);
518
519        // bar.h in /high — different name, not a shadow.
520        let affected = graph.check_shadow(Path::new("/high/bar.h"));
521        assert!(affected.is_empty());
522    }
523
524    #[test]
525    fn check_shadow_same_dir_not_shadow() {
526        let graph = DepGraph::new();
527        let search = IncludeSearchPaths {
528            user: vec![NormalizedPath::from("/inc")],
529            ..Default::default()
530        };
531        let ctx = make_ctx_with_search("/src/main.c", search);
532        let key = graph.register(ctx);
533
534        let scan = ScanResult {
535            resolved: vec![NormalizedPath::from("/inc/foo.h")],
536            unresolved: Vec::new(),
537            has_computed: false,
538        };
539        graph.update(&key, scan, dummy_hash);
540
541        // Same dir — this is a modify/replace, not a shadow.
542        let affected = graph.check_shadow(Path::new("/inc/foo.h"));
543        assert!(affected.is_empty());
544    }
545
546    #[test]
547    fn check_shadow_iquote_over_user() {
548        let graph = DepGraph::new();
549        let search = IncludeSearchPaths {
550            iquote: vec![NormalizedPath::from("/iquote")],
551            user: vec![NormalizedPath::from("/user")],
552            ..Default::default()
553        };
554        let ctx = make_ctx_with_search("/src/main.c", search);
555        let key = graph.register(ctx);
556
557        // foo.h resolves from -I dir.
558        let scan = ScanResult {
559            resolved: vec![NormalizedPath::from("/user/foo.h")],
560            unresolved: Vec::new(),
561            has_computed: false,
562        };
563        graph.update(&key, scan, dummy_hash);
564
565        // New foo.h in -iquote dir (higher priority).
566        let affected = graph.check_shadow(Path::new("/iquote/foo.h"));
567        assert_eq!(affected, vec![key]);
568    }
569
570    #[test]
571    fn check_shadow_cold_context_not_affected() {
572        let graph = DepGraph::new();
573        let search = IncludeSearchPaths {
574            user: vec![NormalizedPath::from("/high"), NormalizedPath::from("/low")],
575            ..Default::default()
576        };
577        let ctx = make_ctx_with_search("/src/main.c", search);
578        graph.register(ctx);
579
580        // Cold context has no resolved includes — nothing to shadow.
581        let affected = graph.check_shadow(Path::new("/high/foo.h"));
582        assert!(affected.is_empty());
583    }
584
585    // --- New resolve detection tests ---
586
587    #[test]
588    fn check_new_resolve_matches_unresolved() {
589        let graph = DepGraph::new();
590        let ctx = make_ctx_with_search("/src/main.c", IncludeSearchPaths::default());
591        let key = graph.register(ctx);
592
593        let scan = ScanResult {
594            resolved: Vec::new(),
595            unresolved: vec!["missing.h".to_string()],
596            has_computed: false,
597        };
598        graph.update(&key, scan, dummy_hash);
599
600        let affected = graph.check_new_resolve(Path::new("/inc/missing.h"));
601        assert_eq!(affected, vec![key]);
602    }
603
604    #[test]
605    fn check_new_resolve_no_match() {
606        let graph = DepGraph::new();
607        let ctx = make_ctx_with_search("/src/main.c", IncludeSearchPaths::default());
608        let key = graph.register(ctx);
609
610        let scan = ScanResult {
611            resolved: Vec::new(),
612            unresolved: vec!["missing.h".to_string()],
613            has_computed: false,
614        };
615        graph.update(&key, scan, dummy_hash);
616
617        let affected = graph.check_new_resolve(Path::new("/inc/other.h"));
618        assert!(affected.is_empty());
619    }
620
621    #[test]
622    fn check_new_resolve_path_include() {
623        let graph = DepGraph::new();
624        let ctx = make_ctx_with_search("/src/main.c", IncludeSearchPaths::default());
625        let key = graph.register(ctx);
626
627        // Unresolved include with a path component.
628        let scan = ScanResult {
629            resolved: Vec::new(),
630            unresolved: vec!["sub/missing.h".to_string()],
631            has_computed: false,
632        };
633        graph.update(&key, scan, dummy_hash);
634
635        // New file with matching filename.
636        let affected = graph.check_new_resolve(Path::new("/inc/sub/missing.h"));
637        assert_eq!(affected, vec![key]);
638    }
639
640    // --- mark_stale tests ---
641
642    #[test]
643    fn mark_stale_changes_state() {
644        let graph = DepGraph::new();
645        let ctx = make_ctx_with_search("/src/main.c", IncludeSearchPaths::default());
646        let key = graph.register(ctx);
647
648        let scan = ScanResult {
649            resolved: Vec::new(),
650            unresolved: Vec::new(),
651            has_computed: false,
652        };
653        graph.update(&key, scan, dummy_hash);
654        assert_eq!(
655            graph.get_state(&key),
656            Some(super::super::graph::ContextState::Warm)
657        );
658
659        assert!(graph.mark_stale(&key));
660        assert_eq!(
661            graph.get_state(&key),
662            Some(super::super::graph::ContextState::Stale)
663        );
664    }
665
666    #[test]
667    fn mark_stale_nonexistent_returns_false() {
668        let graph = DepGraph::new();
669        let ctx = make_ctx_with_search("/src/main.c", IncludeSearchPaths::default());
670        let key = ctx.context_key();
671        assert!(!graph.mark_stale(&key));
672    }
673
674    // --- is_higher_priority tests ---
675
676    #[test]
677    fn priority_iquote_before_user() {
678        let search = IncludeSearchPaths {
679            iquote: vec![NormalizedPath::from("/q")],
680            user: vec![NormalizedPath::from("/u")],
681            ..Default::default()
682        };
683        assert!(is_higher_priority(
684            Path::new("/q"),
685            Path::new("/u"),
686            &search
687        ));
688        assert!(!is_higher_priority(
689            Path::new("/u"),
690            Path::new("/q"),
691            &search
692        ));
693    }
694
695    #[test]
696    fn priority_user_before_system() {
697        let search = IncludeSearchPaths {
698            user: vec![NormalizedPath::from("/u")],
699            system: vec![NormalizedPath::from("/s")],
700            ..Default::default()
701        };
702        assert!(is_higher_priority(
703            Path::new("/u"),
704            Path::new("/s"),
705            &search
706        ));
707    }
708
709    #[test]
710    fn priority_unknown_dir_returns_false() {
711        let search = IncludeSearchPaths {
712            user: vec![NormalizedPath::from("/u")],
713            ..Default::default()
714        };
715        assert!(!is_higher_priority(
716            Path::new("/unknown"),
717            Path::new("/u"),
718            &search
719        ));
720    }
721
722    #[test]
723    fn priority_same_dir_returns_false() {
724        let search = IncludeSearchPaths {
725            user: vec![NormalizedPath::from("/u")],
726            ..Default::default()
727        };
728        assert!(!is_higher_priority(
729            Path::new("/u"),
730            Path::new("/u"),
731            &search
732        ));
733    }
734}