Skip to main content

pitchfork_cli/
watch_files.rs

1use crate::Result;
2use crate::pitchfork_toml::WatchMode;
3use globset::{GlobBuilder, GlobMatcher};
4use itertools::Itertools;
5use miette::IntoDiagnostic;
6use notify::event::ModifyKind;
7use notify::{Config, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode};
8use notify_debouncer_full::{DebounceEventResult, Debouncer, NoCache, new_debouncer_opt};
9use std::collections::HashMap;
10use std::path::{Component, Path, PathBuf};
11use std::time::Duration;
12
13/// A debounced batch of file changes.
14#[derive(Debug)]
15pub struct WatchEvents {
16    /// Every path that was created, modified, or removed.
17    pub paths: Vec<PathBuf>,
18    /// Paths that were created or moved in; their contents produce no events
19    /// of their own.
20    pub created: Vec<PathBuf>,
21}
22
23pub struct WatchFiles {
24    pub rx: tokio::sync::mpsc::Receiver<WatchEvents>,
25    backend: WatchFilesBackend,
26}
27
28// `NoCache` rather than `FileIdMap`: only changed paths are needed, not rename
29// tracking, and `FileIdMap` re-walks every recursive root on each rescan. On
30// Linux that walk opens every directory, and the resulting inotify `IN_OPEN`
31// events can overflow the event queue, which triggers another rescan and spins
32// the watcher thread indefinitely on large trees.
33enum WatchFilesBackend {
34    Native(Debouncer<RecommendedWatcher, NoCache>),
35    Poll(Debouncer<PollWatcher, NoCache>),
36}
37
38impl WatchFiles {
39    pub fn new(duration: Duration, mode: WatchMode, poll_interval: Duration) -> Result<Self> {
40        let h = tokio::runtime::Handle::current();
41        let (tx, rx) = tokio::sync::mpsc::channel(256);
42        let make_callback = |tx: tokio::sync::mpsc::Sender<WatchEvents>,
43                             h: tokio::runtime::Handle| {
44            move |res: DebounceEventResult| {
45                let Ok(ev) = res else { return };
46                let mut paths = vec![];
47                let mut created = vec![];
48                for e in ev.iter().filter(|e| {
49                    matches!(
50                        e.kind,
51                        EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
52                    )
53                }) {
54                    paths.extend(e.paths.iter().cloned());
55                    if matches!(
56                        e.kind,
57                        EventKind::Create(_) | EventKind::Modify(ModifyKind::Name(_))
58                    ) {
59                        created.extend(e.paths.iter().cloned());
60                    }
61                }
62                if paths.is_empty() {
63                    return;
64                }
65                let events = WatchEvents {
66                    paths: paths.into_iter().unique().collect(),
67                    created: created.into_iter().unique().collect(),
68                };
69                let tx = tx.clone();
70                h.spawn(async move {
71                    // Ignore send errors - receiver may be dropped during shutdown
72                    let _ = tx.send(events).await;
73                });
74            }
75        };
76
77        let backend = match mode {
78            WatchMode::Native => WatchFilesBackend::Native(
79                new_debouncer_opt(
80                    duration,
81                    None,
82                    make_callback(tx.clone(), h.clone()),
83                    NoCache::new(),
84                    Config::default(),
85                )
86                .into_diagnostic()?,
87            ),
88            WatchMode::Poll => WatchFilesBackend::Poll(
89                new_debouncer_opt(
90                    duration,
91                    None,
92                    make_callback(tx.clone(), h.clone()),
93                    NoCache::new(),
94                    Config::default().with_poll_interval(poll_interval),
95                )
96                .into_diagnostic()?,
97            ),
98            WatchMode::Auto => {
99                return Err(miette::miette!(
100                    "WatchMode::Auto must not be passed directly to WatchFiles::new; \
101                     the caller must resolve auto to native or poll"
102                ));
103            }
104        };
105
106        Ok(Self { backend, rx })
107    }
108
109    pub fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> Result<()> {
110        match &mut self.backend {
111            WatchFilesBackend::Native(debouncer) => {
112                debouncer.watch(path, recursive_mode).into_diagnostic()
113            }
114            WatchFilesBackend::Poll(debouncer) => {
115                debouncer.watch(path, recursive_mode).into_diagnostic()
116            }
117        }
118    }
119
120    pub fn unwatch(&mut self, path: &Path) -> Result<()> {
121        match &mut self.backend {
122            WatchFilesBackend::Native(debouncer) => debouncer.unwatch(path).into_diagnostic(),
123            WatchFilesBackend::Poll(debouncer) => debouncer.unwatch(path).into_diagnostic(),
124        }
125    }
126}
127
128/// List the entries a watch on `dir` covers: its direct entries, or with
129/// `Recursive` every entry below it. Symlinked directories are not followed.
130pub fn watched_entries(dir: &Path, mode: RecursiveMode) -> Vec<PathBuf> {
131    let mut paths = vec![];
132    collect_entries(dir, mode, &mut paths);
133    paths
134}
135
136fn collect_entries(dir: &Path, mode: RecursiveMode, paths: &mut Vec<PathBuf>) {
137    let Ok(entries) = std::fs::read_dir(dir) else {
138        return;
139    };
140    for entry in entries.flatten() {
141        let path = entry.path();
142        if mode == RecursiveMode::Recursive && entry.file_type().is_ok_and(|t| t.is_dir()) {
143            collect_entries(&path, mode, paths);
144        }
145        paths.push(path);
146    }
147}
148
149/// Normalize a path by attempting to canonicalize it. If that fails, it attempts
150/// to resolve it as an absolute path. This helps ensure that different relative
151/// paths to the same directory are deduplicated.
152///
153/// On Windows, `std::fs::canonicalize()` returns paths with the `\\?\` (verbatim)
154/// prefix. The `notify` crate's PollWatcher may not correctly report changes for
155/// verbatim-prefixed paths, and the changed paths it reports would carry the
156/// prefix, causing mismatches with non-canonicalized glob patterns. We strip
157/// the prefix after canonicalization to keep paths consistent across the watcher
158/// and the pattern matcher.
159fn normalize_watch_path(path: &Path) -> PathBuf {
160    match path.canonicalize() {
161        Ok(p) => {
162            #[cfg(windows)]
163            {
164                strip_verbatim_prefix(&p)
165            }
166            #[cfg(not(windows))]
167            {
168                p
169            }
170        }
171        Err(_) => {
172            if path.is_absolute() {
173                path.to_path_buf()
174            } else {
175                crate::env::CWD.join(path)
176            }
177        }
178    }
179}
180
181/// Strip the `\\?\` verbatim prefix from a Windows path.
182/// `\\?\C:\dir` → `C:\dir`, `\\?\UNC\server\share` → `\\server\share`
183#[cfg(windows)]
184fn strip_verbatim_prefix(path: &Path) -> PathBuf {
185    let s = path.to_string_lossy();
186    if let Some(rest) = s.strip_prefix(r"\\?\") {
187        if let Some(unc) = rest.strip_prefix(r"UNC\") {
188            PathBuf::from(format!(r"\\{}", unc))
189        } else {
190            PathBuf::from(rest)
191        }
192    } else {
193        path.to_path_buf()
194    }
195}
196
197/// Expand watch patterns to the directories that must be watched to see every
198/// change they can match, and how each directory must be watched.
199/// Patterns are resolved relative to base_dir.
200///
201/// Recursive watches are only used where a `**` component requires one, since
202/// they register a watch on every directory below the root. Otherwise each
203/// directory level the pattern spans is watched non-recursively, which also
204/// notices newly created directories that match; the supervisor re-expands the
205/// patterns after every batch of events so new directories get watched.
206pub fn expand_watch_patterns(
207    patterns: &[String],
208    base_dir: &Path,
209) -> HashMap<PathBuf, RecursiveMode> {
210    let mut targets = HashMap::new();
211    for pattern in patterns {
212        for alt in relative_alternatives(pattern) {
213            for (dir, mode) in watch_targets_for_pattern(&alt, base_dir) {
214                insert_watch_target(&mut targets, normalize_watch_path(&dir), mode);
215            }
216        }
217    }
218    targets
219}
220
221/// Brace alternatives of `pattern`, kept relative to the base directory when
222/// `pattern` is: `{a,}/**/*.rs` must not become `/**/*.rs` and watch `/`.
223/// Patterns that start at a root (including Windows' drive-relative `\src`)
224/// are left as they are.
225fn relative_alternatives(pattern: &str) -> Vec<String> {
226    let path = Path::new(pattern);
227    if path.is_absolute() || path.has_root() {
228        return expand_braces(pattern);
229    }
230    expand_braces(pattern)
231        .into_iter()
232        .map(|alt| alt.trim_start_matches(['/', '\\']).to_string())
233        // e.g. a drive-prefixed alternative on Windows
234        .filter(|alt| !Path::new(alt).is_absolute())
235        .collect()
236}
237
238/// Expand `{a,b}` alternatives into separate patterns, so each is watched
239/// narrowly even when an alternative contains `/` (`{src/api,lib}/*.rs`).
240/// Patterns with more alternatives than is reasonable are returned as is.
241fn expand_braces(pattern: &str) -> Vec<String> {
242    const MAX_ALTERNATIVES: usize = 64;
243    let mut expanded = vec![];
244    let mut pending = vec![pattern.to_string()];
245    while let Some(p) = pending.pop() {
246        match first_brace_group(&p) {
247            Some((start, end, alternatives)) => {
248                for alt in alternatives {
249                    pending.push(format!("{}{alt}{}", &p[..start], &p[end + 1..]));
250                }
251            }
252            None => expanded.push(p),
253        }
254        if expanded.len() + pending.len() > MAX_ALTERNATIVES {
255            return vec![pattern.to_string()];
256        }
257    }
258    expanded
259}
260
261/// Find the first top-level `{...}` group, returning the byte offsets of its
262/// braces and its comma-separated alternatives.
263fn first_brace_group(pattern: &str) -> Option<(usize, usize, Vec<&str>)> {
264    let bytes = pattern.as_bytes();
265    let (mut start, mut alt_start, mut depth) = (0, 0, 0);
266    let mut in_class = false;
267    let mut alternatives = vec![];
268    let mut i = 0;
269    while i < bytes.len() {
270        match bytes[i] {
271            // globset treats `\` as an escape except on Windows
272            b'\\' if cfg!(not(windows)) => i += 1,
273            b']' if in_class => in_class = false,
274            _ if in_class => {}
275            b'[' => in_class = true,
276            b'{' => {
277                if depth == 0 {
278                    start = i;
279                    alt_start = i + 1;
280                }
281                depth += 1;
282            }
283            b',' if depth == 1 => {
284                alternatives.push(&pattern[alt_start..i]);
285                alt_start = i + 1;
286            }
287            b'}' if depth > 0 => {
288                depth -= 1;
289                if depth == 0 {
290                    alternatives.push(&pattern[alt_start..i]);
291                    return Some((start, i, alternatives));
292                }
293            }
294            _ => {}
295        }
296        i += 1;
297    }
298    None
299}
300
301/// Add a watch target, upgrading an existing entry to recursive if needed.
302pub fn insert_watch_target(
303    targets: &mut HashMap<PathBuf, RecursiveMode>,
304    dir: PathBuf,
305    mode: RecursiveMode,
306) {
307    let entry = targets.entry(dir).or_insert(mode);
308    if mode == RecursiveMode::Recursive {
309        *entry = RecursiveMode::Recursive;
310    }
311}
312
313fn watch_targets_for_pattern(pattern: &str, base_dir: &Path) -> Vec<(PathBuf, RecursiveMode)> {
314    // Strip leading "./" from patterns to handle relative path prefixes
315    let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
316    let full_path = base_dir.join(pattern);
317    let mut components = full_path.components().collect_vec();
318    let Some(file_part) = components.pop() else {
319        return vec![];
320    };
321
322    // Directories before the first glob component are fixed.
323    let mut dir_parts = components.into_iter().peekable();
324    let mut literal_dir = PathBuf::new();
325    while let Some(part) = dir_parts.next_if(|c| !is_glob_component(c)) {
326        literal_dir.push(part);
327    }
328    if !literal_dir.is_dir() {
329        // Watch the nearest existing ancestor so creating the missing
330        // directory wakes the watcher, which then re-expands the pattern.
331        return literal_dir
332            .ancestors()
333            .find(|p| p.is_dir())
334            .map(|p| vec![(p.to_path_buf(), RecursiveMode::NonRecursive)])
335            .unwrap_or_default();
336    }
337
338    let mut targets = vec![];
339    let mut current = vec![literal_dir];
340    for part in dir_parts {
341        let part = part.as_os_str().to_string_lossy();
342        // `**` is only recursive as a whole component; elsewhere it acts as `*`
343        if part == "**" {
344            targets.extend(current.into_iter().map(|d| (d, RecursiveMode::Recursive)));
345            return targets;
346        }
347        let Some(matcher) = component_matcher(&part) else {
348            let full_pattern = normalize_path_for_glob(&full_path.to_string_lossy());
349            match GlobBuilder::new(&full_pattern).build() {
350                // A brace or class containing `/` spans components and cannot
351                // be expanded level by level, so watch all it could match.
352                Ok(_) => targets.extend(current.into_iter().map(|d| (d, RecursiveMode::Recursive))),
353                // An invalid pattern never matches, so it needs no watch.
354                Err(e) => log::warn!("Invalid glob pattern '{pattern}': {e}"),
355            }
356            return targets;
357        };
358        // Watch this level too, so a newly created matching directory is seen.
359        targets.extend(
360            current
361                .iter()
362                .map(|d| (d.clone(), RecursiveMode::NonRecursive)),
363        );
364        current = current
365            .iter()
366            .flat_map(|d| matching_subdirs(d, &matcher))
367            .collect();
368    }
369
370    let mode = if file_part.as_os_str() == "**" {
371        RecursiveMode::Recursive
372    } else {
373        RecursiveMode::NonRecursive
374    };
375    targets.extend(current.into_iter().map(|d| (d, mode)));
376    targets
377}
378
379fn is_glob_component(component: &Component) -> bool {
380    component
381        .as_os_str()
382        .to_string_lossy()
383        .contains(['*', '?', '[', '{'])
384}
385
386/// Build a matcher for a single path component, with the same glob semantics
387/// as `path_matches_patterns`.
388fn component_matcher(component: &str) -> Option<GlobMatcher> {
389    GlobBuilder::new(component)
390        .case_insensitive(cfg!(target_os = "windows"))
391        .literal_separator(true)
392        .build()
393        .ok()
394        .map(|glob| glob.compile_matcher())
395}
396
397fn matching_subdirs(dir: &Path, matcher: &GlobMatcher) -> Vec<PathBuf> {
398    let Ok(entries) = std::fs::read_dir(dir) else {
399        return vec![];
400    };
401    entries
402        .flatten()
403        .filter(|e| matcher.is_match(e.file_name()))
404        .map(|e| e.path())
405        .filter(|p| p.is_dir())
406        .collect()
407}
408
409/// Normalize a path string to use forward slashes for glob pattern matching.
410/// This ensures consistent behavior across Windows and Unix platforms.
411///
412/// On Windows, `std::fs::canonicalize()` returns paths with the `\\?\` prefix
413/// (verbatim path). If we don't strip it, canonicalized watcher paths won't
414/// match non-canonicalized glob patterns built from `env::CWD`, causing all
415/// file-change matching to silently fail on Windows.
416///
417/// Verbatim UNC paths (`\\?\UNC\server\share`) are converted to the regular
418/// UNC form (`//server/share`) so they match glob patterns consistently.
419fn normalize_path_for_glob(path: &str) -> String {
420    if let Some(rest) = path.strip_prefix(r"\\?\UNC\") {
421        format!("//{}", rest.replace('\\', "/"))
422    } else {
423        path.strip_prefix(r"\\?\")
424            .unwrap_or(path)
425            .replace('\\', "/")
426    }
427}
428
429/// Check if a changed path matches any of the watch patterns.
430/// Uses globset which properly supports ** for recursive directory matching.
431pub fn path_matches_patterns(changed_path: &Path, patterns: &[String], base_dir: &Path) -> bool {
432    // Normalize the changed path to use forward slashes for consistent matching
433    let changed_path_str = normalize_path_for_glob(&changed_path.to_string_lossy());
434
435    // Match the same brace alternatives that are watched
436    for pattern in patterns.iter().flat_map(|p| relative_alternatives(p)) {
437        // Strip leading "./" from patterns to handle relative path prefixes
438        let normalized_pattern = pattern.strip_prefix("./").unwrap_or(&pattern);
439
440        // Build the full pattern and normalize to use forward slashes
441        let full_pattern = if Path::new(normalized_pattern).is_absolute() {
442            normalize_path_for_glob(normalized_pattern)
443        } else {
444            normalize_path_for_glob(&base_dir.join(normalized_pattern).to_string_lossy())
445        };
446
447        // Use globset which properly supports ** for recursive matching
448        let glob = globset::GlobBuilder::new(&full_pattern)
449            .case_insensitive(cfg!(target_os = "windows"))
450            .literal_separator(true) // * doesn't match /, use ** for recursive
451            .build();
452
453        if let Ok(glob) = glob {
454            let matcher = glob.compile_matcher();
455            if matcher.is_match(&changed_path_str) {
456                return true;
457            }
458        }
459    }
460    false
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use std::fs;
467    use tempfile::TempDir;
468
469    #[test]
470    fn test_normalize_watch_path_existing_directory() {
471        let temp_dir = TempDir::new().unwrap();
472        let dir_path = temp_dir.path().join("test_dir");
473        fs::create_dir(&dir_path).unwrap();
474
475        // Canonicalize should work for existing directories
476        let normalized = normalize_watch_path(&dir_path);
477        assert!(normalized.is_absolute());
478        assert!(normalized.exists());
479    }
480
481    #[test]
482    fn test_normalize_watch_path_nonexistent_path() {
483        // Use a platform-appropriate absolute path that doesn't exist.
484        // On Windows, "/nonexistent/..." is not absolute (no drive letter),
485        // so normalize_watch_path would prepend CWD instead of returning as-is.
486        #[cfg(unix)]
487        let path = PathBuf::from("/nonexistent/path/to/dir");
488        #[cfg(windows)]
489        let path = PathBuf::from(r"C:\nonexistent\path\to\dir");
490
491        // Should return the original path when canonicalization fails
492        let normalized = normalize_watch_path(&path);
493        assert_eq!(normalized, path);
494    }
495
496    #[test]
497    fn test_normalize_watch_path_deduplication() {
498        let temp_dir = TempDir::new().unwrap();
499        let dir_path = temp_dir.path().join("test_dir");
500        fs::create_dir(&dir_path).unwrap();
501
502        // Create a subdirectory to test path traversal
503        let subdir = dir_path.join("subdir");
504        fs::create_dir(&subdir).unwrap();
505
506        // Create two different relative paths pointing to the same directory
507        // One is direct, the other uses parent/child traversal
508        let path1 = subdir.clone();
509        let path2 = subdir.join("..").join("subdir");
510
511        let normalized1 = normalize_watch_path(&path1);
512        let normalized2 = normalize_watch_path(&path2);
513
514        // Both should canonicalize to the same path
515        assert_eq!(normalized1, normalized2);
516    }
517
518    fn canon(path: &Path) -> PathBuf {
519        normalize_watch_path(path)
520    }
521
522    fn expand(patterns: &[&str], base_dir: &Path) -> HashMap<PathBuf, RecursiveMode> {
523        let patterns = patterns.iter().map(|p| p.to_string()).collect_vec();
524        expand_watch_patterns(&patterns, base_dir)
525    }
526
527    #[test]
528    fn test_expand_watch_patterns_specific_file() {
529        let temp_dir = TempDir::new().unwrap();
530        let base_dir = temp_dir.path();
531        fs::write(base_dir.join("package.json"), "{}").unwrap();
532        // A large sibling tree must not make the watch recursive
533        fs::create_dir_all(base_dir.join("node_modules/a/b")).unwrap();
534
535        let dirs = expand(&["package.json"], base_dir);
536
537        assert_eq!(
538            dirs,
539            HashMap::from([(canon(base_dir), RecursiveMode::NonRecursive)])
540        );
541    }
542
543    #[test]
544    fn test_expand_watch_patterns_recursive_glob() {
545        let temp_dir = TempDir::new().unwrap();
546        let base_dir = temp_dir.path();
547        let subdir = base_dir.join("src");
548        fs::create_dir_all(subdir.join("nested")).unwrap();
549        fs::write(subdir.join("file1.rs"), "").unwrap();
550        fs::write(subdir.join("nested/file2.rs"), "").unwrap();
551
552        let dirs = expand(&["src/**/*.rs"], base_dir);
553
554        assert_eq!(
555            dirs,
556            HashMap::from([(canon(&subdir), RecursiveMode::Recursive)])
557        );
558    }
559
560    #[test]
561    fn test_expand_watch_patterns_single_level_glob() {
562        let temp_dir = TempDir::new().unwrap();
563        let base_dir = temp_dir.path();
564        fs::create_dir_all(base_dir.join("config/nested")).unwrap();
565
566        let dirs = expand(&["config/*.toml"], base_dir);
567
568        assert_eq!(
569            dirs,
570            HashMap::from([(canon(&base_dir.join("config")), RecursiveMode::NonRecursive)])
571        );
572    }
573
574    #[test]
575    fn test_expand_watch_patterns_glob_directory_component() {
576        let temp_dir = TempDir::new().unwrap();
577        let base_dir = temp_dir.path();
578        fs::create_dir_all(base_dir.join("crates/a/deep")).unwrap();
579        fs::create_dir_all(base_dir.join("crates/b")).unwrap();
580        fs::write(base_dir.join("crates/file.toml"), "").unwrap();
581
582        let dirs = expand(&["crates/*/Cargo.toml"], base_dir);
583
584        // The glob level is watched to notice new crates, plus each match
585        assert_eq!(
586            dirs,
587            HashMap::from([
588                (canon(&base_dir.join("crates")), RecursiveMode::NonRecursive),
589                (
590                    canon(&base_dir.join("crates/a")),
591                    RecursiveMode::NonRecursive
592                ),
593                (
594                    canon(&base_dir.join("crates/b")),
595                    RecursiveMode::NonRecursive
596                ),
597            ])
598        );
599    }
600
601    #[test]
602    fn test_expand_watch_patterns_embedded_double_star() {
603        let temp_dir = TempDir::new().unwrap();
604        let base_dir = temp_dir.path();
605        fs::create_dir_all(base_dir.join("src/a")).unwrap();
606        fs::create_dir_all(base_dir.join("src/b")).unwrap();
607
608        // `**` inside a component matches like `*`, within one level
609        let dirs = expand(&["src/foo**bar.rs", "sr**/x.rs"], base_dir);
610
611        assert_eq!(
612            dirs,
613            HashMap::from([
614                (canon(base_dir), RecursiveMode::NonRecursive),
615                (canon(&base_dir.join("src")), RecursiveMode::NonRecursive),
616            ])
617        );
618    }
619
620    #[test]
621    fn test_expand_watch_patterns_trailing_double_star() {
622        let temp_dir = TempDir::new().unwrap();
623        let base_dir = temp_dir.path();
624        fs::create_dir(base_dir.join("src")).unwrap();
625
626        let dirs = expand(&["src/**"], base_dir);
627
628        assert_eq!(
629            dirs,
630            HashMap::from([(canon(&base_dir.join("src")), RecursiveMode::Recursive)])
631        );
632    }
633
634    #[test]
635    fn test_expand_watch_patterns_alternatives_with_separator() {
636        let temp_dir = TempDir::new().unwrap();
637        let base_dir = temp_dir.path();
638        fs::create_dir_all(base_dir.join("src/api")).unwrap();
639        fs::create_dir(base_dir.join("lib")).unwrap();
640
641        // Each alternative is watched on its own, even across components
642        let dirs = expand(&["{src/api,lib}/*.rs"], base_dir);
643        assert_eq!(
644            dirs,
645            HashMap::from([
646                (
647                    canon(&base_dir.join("src/api")),
648                    RecursiveMode::NonRecursive
649                ),
650                (canon(&base_dir.join("lib")), RecursiveMode::NonRecursive),
651            ])
652        );
653        assert!(path_matches_patterns(
654            &base_dir.join("src/api/main.rs"),
655            &["{src/api,lib}/*.rs".to_string()],
656            base_dir
657        ));
658
659        // A class spanning components can't be expanded level by level, so
660        // everything below it is watched
661        let dirs = expand(&["[a/b]/*.rs"], base_dir);
662        assert_eq!(
663            dirs,
664            HashMap::from([(canon(base_dir), RecursiveMode::Recursive)])
665        );
666
667        // An invalid pattern never matches, so it is not watched
668        assert!(expand(&["[z-a]/*.rs"], base_dir).is_empty());
669    }
670
671    #[test]
672    fn test_expand_braces() {
673        let mut expanded = expand_braces("{src/{a,b},lib}/*.{rs,toml}");
674        expanded.sort();
675        assert_eq!(
676            expanded,
677            [
678                "lib/*.rs",
679                "lib/*.toml",
680                "src/a/*.rs",
681                "src/a/*.toml",
682                "src/b/*.rs",
683                "src/b/*.toml",
684            ]
685        );
686
687        // Braces inside a class or escaped are literal
688        assert_eq!(expand_braces("[{]x}/*.rs"), ["[{]x}/*.rs"]);
689        #[cfg(unix)]
690        assert_eq!(expand_braces(r"\{a,b}.rs"), [r"\{a,b}.rs"]);
691
692        // Too many alternatives are left for the recursive fallback
693        let many = "{a,b,c,d,e}/{a,b,c,d,e}/{a,b,c}/*.rs";
694        assert_eq!(expand_braces(many), [many]);
695    }
696
697    #[test]
698    fn test_expand_watch_patterns_alternatives_stay_relative() {
699        let temp_dir = TempDir::new().unwrap();
700        let base_dir = temp_dir.path();
701        fs::create_dir_all(base_dir.join("src/x")).unwrap();
702
703        // An empty or `/`-leading alternative must not escape to `/`
704        let dirs = expand(&["{src,}/**/*.rs", "{/src/x,lib}/*.rs"], base_dir);
705
706        assert_eq!(
707            dirs,
708            HashMap::from([
709                (canon(base_dir), RecursiveMode::Recursive),
710                (canon(&base_dir.join("src")), RecursiveMode::Recursive),
711                (canon(&base_dir.join("src/x")), RecursiveMode::NonRecursive),
712            ])
713        );
714    }
715
716    #[test]
717    fn test_relative_alternatives() {
718        assert_eq!(relative_alternatives("{src,}/*.rs"), ["*.rs", "src/*.rs"]);
719        // Patterns starting at a root keep it
720        assert_eq!(relative_alternatives("/{a,b}.rs"), ["/b.rs", "/a.rs"]);
721        #[cfg(windows)]
722        assert_eq!(relative_alternatives(r"\src\main.rs"), [r"\src\main.rs"]);
723    }
724
725    #[test]
726    fn test_path_matches_patterns_alternatives_stay_relative() {
727        let temp_dir = TempDir::new().unwrap();
728        let base_dir = temp_dir.path();
729        let patterns = ["{/src/x,lib}/*.rs".to_string()];
730
731        // The watched `src/x` and `lib` are the ones matched
732        assert!(path_matches_patterns(
733            &base_dir.join("src/x/main.rs"),
734            &patterns,
735            base_dir
736        ));
737        assert!(path_matches_patterns(
738            &base_dir.join("lib/main.rs"),
739            &patterns,
740            base_dir
741        ));
742        assert!(!path_matches_patterns(
743            &base_dir.join("src/main.rs"),
744            &patterns,
745            base_dir
746        ));
747    }
748
749    #[test]
750    fn test_expand_watch_patterns_recursive_wins() {
751        let temp_dir = TempDir::new().unwrap();
752        let base_dir = temp_dir.path();
753        fs::create_dir(base_dir.join("src")).unwrap();
754
755        let dirs = expand(&["src/main.rs", "src/**/*.rs"], base_dir);
756
757        assert_eq!(
758            dirs,
759            HashMap::from([(canon(&base_dir.join("src")), RecursiveMode::Recursive)])
760        );
761    }
762
763    #[test]
764    fn test_expand_watch_patterns_nonexistent_file() {
765        let temp_dir = TempDir::new().unwrap();
766        let base_dir = temp_dir.path();
767
768        // Pattern for a file that doesn't exist yet
769        let dirs = expand(&["config.toml"], base_dir);
770
771        assert_eq!(
772            dirs,
773            HashMap::from([(canon(base_dir), RecursiveMode::NonRecursive)])
774        );
775    }
776
777    #[test]
778    fn test_expand_watch_patterns_nonexistent_directory() {
779        let temp_dir = TempDir::new().unwrap();
780        let base_dir = temp_dir.path();
781        fs::create_dir(base_dir.join("config")).unwrap();
782
783        // The nearest existing ancestor is watched until the directory appears
784        let dirs = expand(&["config/app/*.toml", "lib/**/*.ts"], base_dir);
785
786        assert_eq!(
787            dirs,
788            HashMap::from([
789                (canon(base_dir), RecursiveMode::NonRecursive),
790                (canon(&base_dir.join("config")), RecursiveMode::NonRecursive),
791            ])
792        );
793    }
794
795    #[test]
796    fn test_watched_entries() {
797        let temp_dir = TempDir::new().unwrap();
798        let dir = temp_dir.path().join("new");
799        fs::create_dir_all(dir.join("nested")).unwrap();
800        fs::write(dir.join("a.toml"), "").unwrap();
801        fs::write(dir.join("nested/b.toml"), "").unwrap();
802
803        let mut paths = watched_entries(&dir, RecursiveMode::NonRecursive);
804        paths.sort();
805        assert_eq!(paths, vec![dir.join("a.toml"), dir.join("nested")]);
806
807        let mut paths = watched_entries(&dir, RecursiveMode::Recursive);
808        paths.sort();
809        assert_eq!(
810            paths,
811            vec![
812                dir.join("a.toml"),
813                dir.join("nested"),
814                dir.join("nested/b.toml"),
815            ]
816        );
817    }
818
819    #[test]
820    fn test_path_matches_patterns_simple() {
821        let temp_dir = TempDir::new().unwrap();
822        let base_dir = temp_dir.path();
823
824        // Create test files
825        let test_txt = base_dir.join("test.txt");
826        let test_rs = base_dir.join("test.rs");
827        fs::write(&test_txt, "").unwrap();
828        fs::write(&test_rs, "").unwrap();
829
830        // Simple pattern match
831        assert!(path_matches_patterns(
832            &test_txt,
833            &["*.txt".to_string()],
834            base_dir
835        ));
836
837        // Non-matching pattern
838        assert!(!path_matches_patterns(
839            &test_rs,
840            &["*.txt".to_string()],
841            base_dir
842        ));
843    }
844
845    #[test]
846    fn test_path_matches_patterns_recursive_glob() {
847        let temp_dir = TempDir::new().unwrap();
848        let base_dir = temp_dir.path();
849        let src_dir = base_dir.join("src");
850        let deep_dir = src_dir.join("deep");
851        fs::create_dir_all(&deep_dir).unwrap();
852
853        // Create test files
854        let deep_file = deep_dir.join("file.rs");
855        let src_file = src_dir.join("file.rs");
856        fs::write(&deep_file, "").unwrap();
857        fs::write(&src_file, "").unwrap();
858
859        // ** pattern should match any depth
860        assert!(path_matches_patterns(
861            &deep_file,
862            &["src/**/*.rs".to_string()],
863            base_dir
864        ));
865
866        // Should also match top-level
867        assert!(path_matches_patterns(
868            &src_file,
869            &["src/**/*.rs".to_string()],
870            base_dir
871        ));
872    }
873
874    #[test]
875    fn test_path_matches_patterns_multiple_patterns() {
876        let temp_dir = TempDir::new().unwrap();
877        let base_dir = temp_dir.path();
878
879        // Create test files
880        let cargo_toml = base_dir.join("Cargo.toml");
881        let main_rs = base_dir.join("main.rs");
882        let readme_md = base_dir.join("README.md");
883        fs::write(&cargo_toml, "").unwrap();
884        fs::write(&main_rs, "").unwrap();
885        fs::write(&readme_md, "").unwrap();
886
887        // Multiple patterns - should match if any pattern matches
888        let patterns = vec!["*.rs".to_string(), "*.toml".to_string()];
889        assert!(path_matches_patterns(&cargo_toml, &patterns, base_dir));
890        assert!(path_matches_patterns(&main_rs, &patterns, base_dir));
891        assert!(!path_matches_patterns(&readme_md, &patterns, base_dir));
892    }
893
894    #[test]
895    fn test_path_matches_patterns_relative_prefix() {
896        let temp_dir = TempDir::new().unwrap();
897        let base_dir = temp_dir.path();
898
899        // Create a test file
900        let test_file = base_dir.join("config.json");
901        fs::write(&test_file, "{}").unwrap();
902
903        // Pattern with "./" prefix should match the file
904        assert!(path_matches_patterns(
905            &test_file,
906            &["./config.json".to_string()],
907            base_dir
908        ));
909
910        // Same pattern without prefix should also match
911        assert!(path_matches_patterns(
912            &test_file,
913            &["config.json".to_string()],
914            base_dir
915        ));
916    }
917
918    #[test]
919    fn test_expand_watch_patterns_relative_prefix() {
920        let temp_dir = TempDir::new().unwrap();
921        let base_dir = temp_dir.path();
922
923        // Create a test file
924        let test_file = base_dir.join("config.json");
925        fs::write(&test_file, "{}").unwrap();
926
927        // Pattern with "./" prefix should expand correctly
928        let dirs = expand(&["./config.json"], base_dir);
929
930        assert_eq!(
931            dirs,
932            HashMap::from([(canon(base_dir), RecursiveMode::NonRecursive)])
933        );
934    }
935}