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    // base_dir is a path, not a pattern, so only the pattern's own components
317    // can be globs: a project in `app[1]` must not be read as `app1`. A rooted
318    // pattern is joined so Windows' drive-relative `\src` takes base_dir's drive.
319    let rooted = base_dir.join(pattern);
320    let (mut literal_dir, mut components) = if Path::new(pattern).has_root() {
321        (PathBuf::new(), rooted.components().collect_vec())
322    } else {
323        (
324            base_dir.to_path_buf(),
325            Path::new(pattern).components().collect_vec(),
326        )
327    };
328    let Some(file_part) = components.pop() else {
329        return vec![];
330    };
331
332    // Directories before the first glob component are fixed.
333    let mut dir_parts = components.into_iter().peekable();
334    while let Some(part) = dir_parts.next_if(|c| !is_glob_component(c)) {
335        literal_dir.push(part);
336    }
337    if !literal_dir.is_dir() {
338        // Watch the nearest existing ancestor so creating the missing
339        // directory wakes the watcher, which then re-expands the pattern.
340        return literal_dir
341            .ancestors()
342            .find(|p| p.is_dir())
343            .map(|p| vec![(p.to_path_buf(), RecursiveMode::NonRecursive)])
344            .unwrap_or_default();
345    }
346
347    let mut targets = vec![];
348    let mut current = vec![literal_dir];
349    for part in dir_parts {
350        let part = part.as_os_str().to_string_lossy();
351        // `**` is only recursive as a whole component; elsewhere it acts as `*`
352        if part == "**" {
353            targets.extend(current.into_iter().map(|d| (d, RecursiveMode::Recursive)));
354            return targets;
355        }
356        let Some(matcher) = component_matcher(&part) else {
357            match GlobBuilder::new(&normalize_path_for_glob(pattern)).build() {
358                // A brace or class containing `/` spans components and cannot
359                // be expanded level by level, so watch all it could match.
360                Ok(_) => targets.extend(current.into_iter().map(|d| (d, RecursiveMode::Recursive))),
361                // An invalid pattern never matches, so it needs no watch.
362                Err(e) => log::warn!("Invalid glob pattern '{pattern}': {e}"),
363            }
364            return targets;
365        };
366        // Watch this level too, so a newly created matching directory is seen.
367        targets.extend(
368            current
369                .iter()
370                .map(|d| (d.clone(), RecursiveMode::NonRecursive)),
371        );
372        current = current
373            .iter()
374            .flat_map(|d| matching_subdirs(d, &matcher))
375            .collect();
376    }
377
378    let mode = if file_part.as_os_str() == "**" {
379        RecursiveMode::Recursive
380    } else {
381        RecursiveMode::NonRecursive
382    };
383    targets.extend(current.into_iter().map(|d| (d, mode)));
384    targets
385}
386
387fn is_glob_component(component: &Component) -> bool {
388    component
389        .as_os_str()
390        .to_string_lossy()
391        .contains(['*', '?', '[', '{'])
392}
393
394/// Build a matcher for a single path component, with the same glob semantics
395/// as `path_matches_patterns`.
396fn component_matcher(component: &str) -> Option<GlobMatcher> {
397    GlobBuilder::new(component)
398        .case_insensitive(cfg!(target_os = "windows"))
399        .literal_separator(true)
400        .build()
401        .ok()
402        .map(|glob| glob.compile_matcher())
403}
404
405fn matching_subdirs(dir: &Path, matcher: &GlobMatcher) -> Vec<PathBuf> {
406    let Ok(entries) = std::fs::read_dir(dir) else {
407        return vec![];
408    };
409    entries
410        .flatten()
411        .filter(|e| matcher.is_match(e.file_name()))
412        .map(|e| e.path())
413        .filter(|p| p.is_dir())
414        .collect()
415}
416
417/// Normalize a path string to use forward slashes for glob pattern matching.
418/// This ensures consistent behavior across Windows and Unix platforms.
419///
420/// On Windows, `std::fs::canonicalize()` returns paths with the `\\?\` prefix
421/// (verbatim path). If we don't strip it, canonicalized watcher paths won't
422/// match non-canonicalized glob patterns built from `env::CWD`, causing all
423/// file-change matching to silently fail on Windows.
424///
425/// Verbatim UNC paths (`\\?\UNC\server\share`) are converted to the regular
426/// UNC form (`//server/share`) so they match glob patterns consistently.
427fn normalize_path_for_glob(path: &str) -> String {
428    if let Some(rest) = path.strip_prefix(r"\\?\UNC\") {
429        format!("//{}", rest.replace('\\', "/"))
430    } else {
431        path.strip_prefix(r"\\?\")
432            .unwrap_or(path)
433            .replace('\\', "/")
434    }
435}
436
437/// Escape `text` so globset matches it literally. `\` is an escape outside
438/// Windows, where it can also appear in a directory name.
439fn escape_glob_literal(text: &str) -> String {
440    let text = if cfg!(windows) {
441        text.to_string()
442    } else {
443        text.replace('\\', "\\\\")
444    };
445    globset::escape(&text)
446}
447
448/// Check if a changed path matches any of the watch patterns.
449/// Uses globset which properly supports ** for recursive directory matching.
450pub fn path_matches_patterns(changed_path: &Path, patterns: &[String], base_dir: &Path) -> bool {
451    // Normalize the changed path to use forward slashes for consistent matching
452    let changed_path_str = normalize_path_for_glob(&changed_path.to_string_lossy());
453
454    // Match the same brace alternatives that are watched
455    for pattern in patterns.iter().flat_map(|p| relative_alternatives(p)) {
456        // Strip leading "./" from patterns to handle relative path prefixes
457        let normalized_pattern = pattern.strip_prefix("./").unwrap_or(&pattern);
458
459        // Build the full pattern and normalize to use forward slashes
460        let full_pattern = if Path::new(normalized_pattern).is_absolute() {
461            normalize_path_for_glob(normalized_pattern)
462        } else if Path::new(normalized_pattern).has_root() {
463            // Windows' drive-relative `\src` takes base_dir's drive
464            normalize_path_for_glob(&base_dir.join(normalized_pattern).to_string_lossy())
465        } else {
466            // base_dir is a path, not a pattern: escape what globset would
467            // read as syntax in it, then append the pattern.
468            let base = normalize_path_for_glob(&base_dir.to_string_lossy());
469            let base = escape_glob_literal(base.trim_end_matches('/'));
470            format!("{base}/{}", normalize_path_for_glob(normalized_pattern))
471        };
472
473        // Use globset which properly supports ** for recursive matching
474        let glob = globset::GlobBuilder::new(&full_pattern)
475            .case_insensitive(cfg!(target_os = "windows"))
476            .literal_separator(true) // * doesn't match /, use ** for recursive
477            .build();
478
479        if let Ok(glob) = glob {
480            let matcher = glob.compile_matcher();
481            if matcher.is_match(&changed_path_str) {
482                return true;
483            }
484        }
485    }
486    false
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use std::fs;
493    use tempfile::TempDir;
494
495    #[test]
496    fn test_normalize_watch_path_existing_directory() {
497        let temp_dir = TempDir::new().unwrap();
498        let dir_path = temp_dir.path().join("test_dir");
499        fs::create_dir(&dir_path).unwrap();
500
501        // Canonicalize should work for existing directories
502        let normalized = normalize_watch_path(&dir_path);
503        assert!(normalized.is_absolute());
504        assert!(normalized.exists());
505    }
506
507    #[test]
508    fn test_normalize_watch_path_nonexistent_path() {
509        // Use a platform-appropriate absolute path that doesn't exist.
510        // On Windows, "/nonexistent/..." is not absolute (no drive letter),
511        // so normalize_watch_path would prepend CWD instead of returning as-is.
512        #[cfg(unix)]
513        let path = PathBuf::from("/nonexistent/path/to/dir");
514        #[cfg(windows)]
515        let path = PathBuf::from(r"C:\nonexistent\path\to\dir");
516
517        // Should return the original path when canonicalization fails
518        let normalized = normalize_watch_path(&path);
519        assert_eq!(normalized, path);
520    }
521
522    #[test]
523    fn test_normalize_watch_path_deduplication() {
524        let temp_dir = TempDir::new().unwrap();
525        let dir_path = temp_dir.path().join("test_dir");
526        fs::create_dir(&dir_path).unwrap();
527
528        // Create a subdirectory to test path traversal
529        let subdir = dir_path.join("subdir");
530        fs::create_dir(&subdir).unwrap();
531
532        // Create two different relative paths pointing to the same directory
533        // One is direct, the other uses parent/child traversal
534        let path1 = subdir.clone();
535        let path2 = subdir.join("..").join("subdir");
536
537        let normalized1 = normalize_watch_path(&path1);
538        let normalized2 = normalize_watch_path(&path2);
539
540        // Both should canonicalize to the same path
541        assert_eq!(normalized1, normalized2);
542    }
543
544    fn canon(path: &Path) -> PathBuf {
545        normalize_watch_path(path)
546    }
547
548    fn expand(patterns: &[&str], base_dir: &Path) -> HashMap<PathBuf, RecursiveMode> {
549        let patterns = patterns.iter().map(|p| p.to_string()).collect_vec();
550        expand_watch_patterns(&patterns, base_dir)
551    }
552
553    #[test]
554    fn test_expand_watch_patterns_specific_file() {
555        let temp_dir = TempDir::new().unwrap();
556        let base_dir = temp_dir.path();
557        fs::write(base_dir.join("package.json"), "{}").unwrap();
558        // A large sibling tree must not make the watch recursive
559        fs::create_dir_all(base_dir.join("node_modules/a/b")).unwrap();
560
561        let dirs = expand(&["package.json"], base_dir);
562
563        assert_eq!(
564            dirs,
565            HashMap::from([(canon(base_dir), RecursiveMode::NonRecursive)])
566        );
567    }
568
569    #[test]
570    fn test_expand_watch_patterns_recursive_glob() {
571        let temp_dir = TempDir::new().unwrap();
572        let base_dir = temp_dir.path();
573        let subdir = base_dir.join("src");
574        fs::create_dir_all(subdir.join("nested")).unwrap();
575        fs::write(subdir.join("file1.rs"), "").unwrap();
576        fs::write(subdir.join("nested/file2.rs"), "").unwrap();
577
578        let dirs = expand(&["src/**/*.rs"], base_dir);
579
580        assert_eq!(
581            dirs,
582            HashMap::from([(canon(&subdir), RecursiveMode::Recursive)])
583        );
584    }
585
586    #[test]
587    fn test_expand_watch_patterns_single_level_glob() {
588        let temp_dir = TempDir::new().unwrap();
589        let base_dir = temp_dir.path();
590        fs::create_dir_all(base_dir.join("config/nested")).unwrap();
591
592        let dirs = expand(&["config/*.toml"], base_dir);
593
594        assert_eq!(
595            dirs,
596            HashMap::from([(canon(&base_dir.join("config")), RecursiveMode::NonRecursive)])
597        );
598    }
599
600    #[test]
601    fn test_expand_watch_patterns_glob_directory_component() {
602        let temp_dir = TempDir::new().unwrap();
603        let base_dir = temp_dir.path();
604        fs::create_dir_all(base_dir.join("crates/a/deep")).unwrap();
605        fs::create_dir_all(base_dir.join("crates/b")).unwrap();
606        fs::write(base_dir.join("crates/file.toml"), "").unwrap();
607
608        let dirs = expand(&["crates/*/Cargo.toml"], base_dir);
609
610        // The glob level is watched to notice new crates, plus each match
611        assert_eq!(
612            dirs,
613            HashMap::from([
614                (canon(&base_dir.join("crates")), RecursiveMode::NonRecursive),
615                (
616                    canon(&base_dir.join("crates/a")),
617                    RecursiveMode::NonRecursive
618                ),
619                (
620                    canon(&base_dir.join("crates/b")),
621                    RecursiveMode::NonRecursive
622                ),
623            ])
624        );
625    }
626
627    #[test]
628    fn test_expand_watch_patterns_embedded_double_star() {
629        let temp_dir = TempDir::new().unwrap();
630        let base_dir = temp_dir.path();
631        fs::create_dir_all(base_dir.join("src/a")).unwrap();
632        fs::create_dir_all(base_dir.join("src/b")).unwrap();
633
634        // `**` inside a component matches like `*`, within one level
635        let dirs = expand(&["src/foo**bar.rs", "sr**/x.rs"], base_dir);
636
637        assert_eq!(
638            dirs,
639            HashMap::from([
640                (canon(base_dir), RecursiveMode::NonRecursive),
641                (canon(&base_dir.join("src")), RecursiveMode::NonRecursive),
642            ])
643        );
644    }
645
646    #[test]
647    fn test_expand_watch_patterns_trailing_double_star() {
648        let temp_dir = TempDir::new().unwrap();
649        let base_dir = temp_dir.path();
650        fs::create_dir(base_dir.join("src")).unwrap();
651
652        let dirs = expand(&["src/**"], base_dir);
653
654        assert_eq!(
655            dirs,
656            HashMap::from([(canon(&base_dir.join("src")), RecursiveMode::Recursive)])
657        );
658    }
659
660    #[test]
661    fn test_expand_watch_patterns_alternatives_with_separator() {
662        let temp_dir = TempDir::new().unwrap();
663        let base_dir = temp_dir.path();
664        fs::create_dir_all(base_dir.join("src/api")).unwrap();
665        fs::create_dir(base_dir.join("lib")).unwrap();
666
667        // Each alternative is watched on its own, even across components
668        let dirs = expand(&["{src/api,lib}/*.rs"], base_dir);
669        assert_eq!(
670            dirs,
671            HashMap::from([
672                (
673                    canon(&base_dir.join("src/api")),
674                    RecursiveMode::NonRecursive
675                ),
676                (canon(&base_dir.join("lib")), RecursiveMode::NonRecursive),
677            ])
678        );
679        assert!(path_matches_patterns(
680            &base_dir.join("src/api/main.rs"),
681            &["{src/api,lib}/*.rs".to_string()],
682            base_dir
683        ));
684
685        // A class spanning components can't be expanded level by level, so
686        // everything below it is watched
687        let dirs = expand(&["[a/b]/*.rs"], base_dir);
688        assert_eq!(
689            dirs,
690            HashMap::from([(canon(base_dir), RecursiveMode::Recursive)])
691        );
692
693        // An invalid pattern never matches, so it is not watched
694        assert!(expand(&["[z-a]/*.rs"], base_dir).is_empty());
695    }
696
697    #[test]
698    fn test_expand_braces() {
699        let mut expanded = expand_braces("{src/{a,b},lib}/*.{rs,toml}");
700        expanded.sort();
701        assert_eq!(
702            expanded,
703            [
704                "lib/*.rs",
705                "lib/*.toml",
706                "src/a/*.rs",
707                "src/a/*.toml",
708                "src/b/*.rs",
709                "src/b/*.toml",
710            ]
711        );
712
713        // Braces inside a class or escaped are literal
714        assert_eq!(expand_braces("[{]x}/*.rs"), ["[{]x}/*.rs"]);
715        #[cfg(unix)]
716        assert_eq!(expand_braces(r"\{a,b}.rs"), [r"\{a,b}.rs"]);
717
718        // Too many alternatives are left for the recursive fallback
719        let many = "{a,b,c,d,e}/{a,b,c,d,e}/{a,b,c}/*.rs";
720        assert_eq!(expand_braces(many), [many]);
721    }
722
723    #[test]
724    fn test_expand_watch_patterns_alternatives_stay_relative() {
725        let temp_dir = TempDir::new().unwrap();
726        let base_dir = temp_dir.path();
727        fs::create_dir_all(base_dir.join("src/x")).unwrap();
728
729        // An empty or `/`-leading alternative must not escape to `/`
730        let dirs = expand(&["{src,}/**/*.rs", "{/src/x,lib}/*.rs"], base_dir);
731
732        assert_eq!(
733            dirs,
734            HashMap::from([
735                (canon(base_dir), RecursiveMode::Recursive),
736                (canon(&base_dir.join("src")), RecursiveMode::Recursive),
737                (canon(&base_dir.join("src/x")), RecursiveMode::NonRecursive),
738            ])
739        );
740    }
741
742    #[test]
743    fn test_relative_alternatives() {
744        assert_eq!(relative_alternatives("{src,}/*.rs"), ["*.rs", "src/*.rs"]);
745        // Patterns starting at a root keep it
746        assert_eq!(relative_alternatives("/{a,b}.rs"), ["/b.rs", "/a.rs"]);
747        #[cfg(windows)]
748        assert_eq!(relative_alternatives(r"\src\main.rs"), [r"\src\main.rs"]);
749    }
750
751    #[test]
752    fn test_path_matches_patterns_alternatives_stay_relative() {
753        let temp_dir = TempDir::new().unwrap();
754        let base_dir = temp_dir.path();
755        let patterns = ["{/src/x,lib}/*.rs".to_string()];
756
757        // The watched `src/x` and `lib` are the ones matched
758        assert!(path_matches_patterns(
759            &base_dir.join("src/x/main.rs"),
760            &patterns,
761            base_dir
762        ));
763        assert!(path_matches_patterns(
764            &base_dir.join("lib/main.rs"),
765            &patterns,
766            base_dir
767        ));
768        assert!(!path_matches_patterns(
769            &base_dir.join("src/main.rs"),
770            &patterns,
771            base_dir
772        ));
773    }
774
775    #[test]
776    fn test_expand_watch_patterns_recursive_wins() {
777        let temp_dir = TempDir::new().unwrap();
778        let base_dir = temp_dir.path();
779        fs::create_dir(base_dir.join("src")).unwrap();
780
781        let dirs = expand(&["src/main.rs", "src/**/*.rs"], base_dir);
782
783        assert_eq!(
784            dirs,
785            HashMap::from([(canon(&base_dir.join("src")), RecursiveMode::Recursive)])
786        );
787    }
788
789    #[test]
790    fn test_expand_watch_patterns_nonexistent_file() {
791        let temp_dir = TempDir::new().unwrap();
792        let base_dir = temp_dir.path();
793
794        // Pattern for a file that doesn't exist yet
795        let dirs = expand(&["config.toml"], base_dir);
796
797        assert_eq!(
798            dirs,
799            HashMap::from([(canon(base_dir), RecursiveMode::NonRecursive)])
800        );
801    }
802
803    #[test]
804    fn test_expand_watch_patterns_nonexistent_directory() {
805        let temp_dir = TempDir::new().unwrap();
806        let base_dir = temp_dir.path();
807        fs::create_dir(base_dir.join("config")).unwrap();
808
809        // The nearest existing ancestor is watched until the directory appears
810        let dirs = expand(&["config/app/*.toml", "lib/**/*.ts"], base_dir);
811
812        assert_eq!(
813            dirs,
814            HashMap::from([
815                (canon(base_dir), RecursiveMode::NonRecursive),
816                (canon(&base_dir.join("config")), RecursiveMode::NonRecursive),
817            ])
818        );
819    }
820
821    #[test]
822    fn test_watched_entries() {
823        let temp_dir = TempDir::new().unwrap();
824        let dir = temp_dir.path().join("new");
825        fs::create_dir_all(dir.join("nested")).unwrap();
826        fs::write(dir.join("a.toml"), "").unwrap();
827        fs::write(dir.join("nested/b.toml"), "").unwrap();
828
829        let mut paths = watched_entries(&dir, RecursiveMode::NonRecursive);
830        paths.sort();
831        assert_eq!(paths, vec![dir.join("a.toml"), dir.join("nested")]);
832
833        let mut paths = watched_entries(&dir, RecursiveMode::Recursive);
834        paths.sort();
835        assert_eq!(
836            paths,
837            vec![
838                dir.join("a.toml"),
839                dir.join("nested"),
840                dir.join("nested/b.toml"),
841            ]
842        );
843    }
844
845    #[test]
846    fn test_path_matches_patterns_simple() {
847        let temp_dir = TempDir::new().unwrap();
848        let base_dir = temp_dir.path();
849
850        // Create test files
851        let test_txt = base_dir.join("test.txt");
852        let test_rs = base_dir.join("test.rs");
853        fs::write(&test_txt, "").unwrap();
854        fs::write(&test_rs, "").unwrap();
855
856        // Simple pattern match
857        assert!(path_matches_patterns(
858            &test_txt,
859            &["*.txt".to_string()],
860            base_dir
861        ));
862
863        // Non-matching pattern
864        assert!(!path_matches_patterns(
865            &test_rs,
866            &["*.txt".to_string()],
867            base_dir
868        ));
869    }
870
871    #[test]
872    fn test_path_matches_patterns_recursive_glob() {
873        let temp_dir = TempDir::new().unwrap();
874        let base_dir = temp_dir.path();
875        let src_dir = base_dir.join("src");
876        let deep_dir = src_dir.join("deep");
877        fs::create_dir_all(&deep_dir).unwrap();
878
879        // Create test files
880        let deep_file = deep_dir.join("file.rs");
881        let src_file = src_dir.join("file.rs");
882        fs::write(&deep_file, "").unwrap();
883        fs::write(&src_file, "").unwrap();
884
885        // ** pattern should match any depth
886        assert!(path_matches_patterns(
887            &deep_file,
888            &["src/**/*.rs".to_string()],
889            base_dir
890        ));
891
892        // Should also match top-level
893        assert!(path_matches_patterns(
894            &src_file,
895            &["src/**/*.rs".to_string()],
896            base_dir
897        ));
898    }
899
900    #[test]
901    fn test_path_matches_patterns_multiple_patterns() {
902        let temp_dir = TempDir::new().unwrap();
903        let base_dir = temp_dir.path();
904
905        // Create test files
906        let cargo_toml = base_dir.join("Cargo.toml");
907        let main_rs = base_dir.join("main.rs");
908        let readme_md = base_dir.join("README.md");
909        fs::write(&cargo_toml, "").unwrap();
910        fs::write(&main_rs, "").unwrap();
911        fs::write(&readme_md, "").unwrap();
912
913        // Multiple patterns - should match if any pattern matches
914        let patterns = vec!["*.rs".to_string(), "*.toml".to_string()];
915        assert!(path_matches_patterns(&cargo_toml, &patterns, base_dir));
916        assert!(path_matches_patterns(&main_rs, &patterns, base_dir));
917        assert!(!path_matches_patterns(&readme_md, &patterns, base_dir));
918    }
919
920    #[test]
921    fn test_path_matches_patterns_relative_prefix() {
922        let temp_dir = TempDir::new().unwrap();
923        let base_dir = temp_dir.path();
924
925        // Create a test file
926        let test_file = base_dir.join("config.json");
927        fs::write(&test_file, "{}").unwrap();
928
929        // Pattern with "./" prefix should match the file
930        assert!(path_matches_patterns(
931            &test_file,
932            &["./config.json".to_string()],
933            base_dir
934        ));
935
936        // Same pattern without prefix should also match
937        assert!(path_matches_patterns(
938            &test_file,
939            &["config.json".to_string()],
940            base_dir
941        ));
942    }
943
944    #[test]
945    fn test_expand_watch_patterns_relative_prefix() {
946        let temp_dir = TempDir::new().unwrap();
947        let base_dir = temp_dir.path();
948
949        // Create a test file
950        let test_file = base_dir.join("config.json");
951        fs::write(&test_file, "{}").unwrap();
952
953        // Pattern with "./" prefix should expand correctly
954        let dirs = expand(&["./config.json"], base_dir);
955
956        assert_eq!(
957            dirs,
958            HashMap::from([(canon(base_dir), RecursiveMode::NonRecursive)])
959        );
960    }
961
962    // The project directory is a path, not a pattern: `[1]` in its name must
963    // not be read as a character class matching `app1`.
964    #[test]
965    fn test_glob_characters_in_base_dir_are_literal() {
966        let temp_dir = TempDir::new().unwrap();
967        for name in ["app[1]", "app{a,b}", "app*"] {
968            if cfg!(windows) && name.contains('*') {
969                continue;
970            }
971            let base_dir = temp_dir.path().join(name);
972            fs::create_dir_all(base_dir.join("src")).unwrap();
973            fs::create_dir_all(temp_dir.path().join("app1")).unwrap();
974            let index = base_dir.join("index.js");
975            fs::write(&index, "").unwrap();
976
977            assert_eq!(
978                expand(&["*.js"], &base_dir),
979                HashMap::from([(canon(&base_dir), RecursiveMode::NonRecursive)]),
980                "{name}"
981            );
982            assert_eq!(
983                expand(&["src/**/*.js"], &base_dir),
984                HashMap::from([(canon(&base_dir.join("src")), RecursiveMode::Recursive)]),
985                "{name}"
986            );
987            assert!(
988                path_matches_patterns(&index, &["*.js".to_string()], &base_dir),
989                "{name}"
990            );
991            assert!(
992                !path_matches_patterns(
993                    &temp_dir.path().join("app1/index.js"),
994                    &["*.js".to_string()],
995                    &base_dir
996                ),
997                "{name}"
998            );
999        }
1000    }
1001}