Skip to main content

webui_dev_server/
watch.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Debounced filesystem watcher.
5//!
6//! Wraps `notify-debouncer-mini` so callers don't need to deal with the
7//! debouncer event type. Returns a [`WatcherHandle`] that owns the
8//! background thread; **the handle must be kept alive** for the watcher
9//! to run.
10
11use std::collections::{HashMap, HashSet};
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14use std::time::Duration;
15
16use anyhow::{Context, Result};
17use notify::{
18    event::{AccessKind, AccessMode},
19    Event, EventHandler, EventKind, RecommendedWatcher, RecursiveMode, Watcher, WatcherKind,
20};
21use notify_debouncer_mini::{
22    new_debouncer_opt, Config as DebouncerConfig, DebounceEventResult, Debouncer,
23};
24
25#[path = "watch_hash.rs"]
26mod hash;
27
28#[cfg(test)]
29#[path = "watch_hash_tests.rs"]
30mod hash_tests;
31
32use hash::{hash_file, HASH_BUFFER_SIZE};
33
34/// Owns the watcher background thread. Drop to stop watching.
35///
36/// `notify-debouncer-mini` spawns its background thread inside the
37/// debouncer struct; dropping the struct kills the thread. Consumers
38/// should hold this handle for the lifetime of their server.
39pub struct WatcherHandle {
40    _debouncer: Debouncer<ContentChangeWatcher>,
41}
42
43struct ContentChangeWatcher(RecommendedWatcher);
44
45impl Watcher for ContentChangeWatcher {
46    fn new<F: EventHandler>(mut event_handler: F, config: notify::Config) -> notify::Result<Self> {
47        RecommendedWatcher::new(
48            move |event| {
49                if matches!(&event, Ok(event) if is_read_only_access_event(event)) {
50                    return;
51                }
52                event_handler.handle_event(event);
53            },
54            config,
55        )
56        .map(Self)
57    }
58
59    fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> notify::Result<()> {
60        self.0.watch(path, recursive_mode)
61    }
62
63    fn unwatch(&mut self, path: &Path) -> notify::Result<()> {
64        self.0.unwatch(path)
65    }
66
67    fn configure(&mut self, config: notify::Config) -> notify::Result<bool> {
68        self.0.configure(config)
69    }
70
71    fn kind() -> WatcherKind {
72        RecommendedWatcher::kind()
73    }
74}
75
76fn is_read_only_access_event(event: &Event) -> bool {
77    matches!(
78        event.kind,
79        EventKind::Access(
80            AccessKind::Read
81                | AccessKind::Open(AccessMode::Read)
82                | AccessKind::Close(AccessMode::Read)
83        )
84    )
85}
86
87/// Configuration for [`spawn_watcher`].
88pub struct WatchConfig {
89    /// Roots to watch recursively. Non-existent entries are silently
90    /// skipped.
91    pub paths: Vec<PathBuf>,
92    /// Exact files to watch through their parent directories.
93    ///
94    /// These files bypass `ignore` filtering, which lets a manifest under an
95    /// ignored output directory act as an explicit synchronization point
96    /// without forwarding unrelated bundle writes.
97    /// Missing parents are watched non-recursively. Directory changes are
98    /// forwarded as dependent file paths so hosts can refresh their watches.
99    pub explicit_files: Vec<PathBuf>,
100    /// Subtrees to ignore. An event is suppressed when its path lives
101    /// underneath any entry here. Typical values: the build's `out_dir`,
102    /// `node_modules`, `.git`, `target`.
103    ///
104    /// Each entry is canonicalized at registration time so symlink and
105    /// path-form differences (`./dist` vs `dist`) compare correctly.
106    pub ignore: Vec<PathBuf>,
107    /// Debounce window — events arriving within this window are
108    /// coalesced into a single callback invocation.
109    pub debounce: Duration,
110    /// Optional predicate that allows byte-identical file events through.
111    ///
112    /// `webui serve --watch` uses this while a rebuild error is active so a
113    /// no-op save can retry transient failures. When the predicate returns
114    /// `false` (the common clean state), identical saves are still dropped.
115    pub retry_unchanged_when: Option<Arc<dyn Fn() -> bool + Send + Sync>>,
116}
117
118/// Start a debounced recursive watcher.
119///
120/// The closure `on_event` is invoked once per debounce window with the
121/// owned, deduplicated list of paths that changed outside any
122/// `cfg.ignore` root. If every event in a window targets an ignored
123/// subtree, the callback is not invoked.
124///
125/// Paths whose file content is byte-identical to the previous event are dropped
126/// in the clean state, so a no-op save (e.g. repeated Ctrl+S that rewrites the
127/// same bytes) triggers **no** rebuild. If `retry_unchanged_when` returns true,
128/// unchanged events are forwarded so callers can retry an active error.
129/// Deletions and files larger than an internal cap always count as changed.
130///
131/// Non-existent paths in `cfg.paths` are silently skipped; this matches
132/// the dev-server use case where some watched directories (e.g. an
133/// optional `public/`) may not exist yet.
134///
135/// # Errors
136///
137/// Returns an error if the watcher cannot be created or if a path that
138/// exists cannot be watched (typically a permissions issue).
139pub fn spawn_watcher<F>(cfg: WatchConfig, on_event: F) -> Result<WatcherHandle>
140where
141    F: Fn(Vec<PathBuf>) + Send + 'static,
142{
143    // Canonicalize ignore paths once, up front. Non-existent ignore
144    // entries are kept as-is so they still match if the path appears
145    // mid-session (e.g. a fresh `dist/` created by the first build).
146    let ignore: Vec<PathBuf> = cfg
147        .ignore
148        .iter()
149        .map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
150        .collect();
151    let explicit_files: Vec<PathBuf> = cfg
152        .explicit_files
153        .iter()
154        .filter_map(|path| normalize_explicit_file(path))
155        .collect();
156
157    let mut content_hashes: HashMap<PathBuf, u64> = HashMap::new();
158    let mut hash_buffer = [0_u8; HASH_BUFFER_SIZE];
159    for file in &explicit_files {
160        content_changed(&mut content_hashes, file, &mut hash_buffer);
161    }
162    let retry_unchanged_when = cfg.retry_unchanged_when.clone();
163    let explicit_filter = explicit_files.clone();
164    let notify_config = notify::Config::default().with_follow_symlinks(false);
165    let debouncer_config = DebouncerConfig::default()
166        .with_timeout(cfg.debounce)
167        .with_notify_config(notify_config);
168    let mut debouncer = new_debouncer_opt::<_, ContentChangeWatcher>(
169        debouncer_config,
170        move |res: DebounceEventResult| match res {
171            Ok(events) => {
172                // Filter out ignored paths and dedupe (notify can emit
173                // duplicate events for the same path within a window).
174                let mut paths: Vec<PathBuf> = Vec::with_capacity(events.len());
175                let mut seen: HashSet<PathBuf> = HashSet::with_capacity(events.len());
176                for e in events {
177                    collect_event_paths(&e.path, &ignore, &explicit_filter, &mut seen, &mut paths);
178                }
179                // Recursive backends emit directory metadata events alongside
180                // file writes. Existing directories are not source changes and
181                // would otherwise retrigger a rebuild after every build; keep
182                // missing paths so directory/file deletions still rebuild.
183                paths.retain(|path| !path.is_dir());
184                // Drop paths whose content is byte-identical to the last event.
185                // Editors fire a write on every Ctrl+S even when nothing changed;
186                // rebuilding then is pure wasted work, so a no-op save triggers no
187                // rebuild at all in the clean state. When the caller reports that a
188                // rebuild error is active, unchanged events are allowed through so a
189                // no-op save can retry transient failures. Deletions and oversized
190                // files always count as changed (see `content_changed`).
191                let retry_unchanged = retry_unchanged_when
192                    .as_ref()
193                    .is_some_and(|predicate| predicate());
194                paths.retain(|path| {
195                    should_forward_path(
196                        &mut content_hashes,
197                        path,
198                        retry_unchanged,
199                        &mut hash_buffer,
200                    )
201                });
202                if !paths.is_empty() {
203                    on_event(paths);
204                }
205            }
206            Err(e) => {
207                eprintln!("watcher error: {e:?}");
208            }
209        },
210    )
211    .context("Cannot start file watcher")?;
212
213    let mut watched_roots = Vec::with_capacity(cfg.paths.len());
214    for p in &cfg.paths {
215        if p.exists() {
216            watched_roots.push(std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()));
217            debouncer
218                .watcher()
219                .watch(p, RecursiveMode::Recursive)
220                .with_context(|| format!("Cannot watch {}", p.display()))?;
221        }
222    }
223    watch_explicit_parents(debouncer.watcher(), &explicit_files, &watched_roots)?;
224
225    Ok(WatcherHandle {
226        _debouncer: debouncer,
227    })
228}
229
230fn watch_explicit_parents(
231    watcher: &mut dyn Watcher,
232    explicit_files: &[PathBuf],
233    watched_roots: &[PathBuf],
234) -> Result<()> {
235    let mut explicit_parents = HashSet::new();
236    for file in explicit_files {
237        if watched_roots.iter().any(|root| file.starts_with(root)) {
238            continue;
239        }
240        while let Some(parent) = file.ancestors().skip(1).find(|parent| parent.is_dir()) {
241            if !explicit_parents.insert(parent.to_path_buf()) {
242                break;
243            }
244            watcher
245                .watch(parent, RecursiveMode::NonRecursive)
246                .with_context(|| format!("Cannot watch {}", parent.display()))?;
247            // A generator can create more parents during registration. Advance
248            // before returning without subscribing to unrelated descendants.
249        }
250    }
251
252    Ok(())
253}
254
255fn normalize_explicit_file(path: &Path) -> Option<PathBuf> {
256    if let Ok(canonical) = std::fs::canonicalize(path) {
257        return Some(canonical);
258    }
259    let path = std::path::absolute(path).ok()?;
260    for ancestor in path.ancestors().skip(1) {
261        if let Ok(canonical) = std::fs::canonicalize(ancestor) {
262            return Some(canonical.join(path.strip_prefix(ancestor).ok()?));
263        }
264    }
265    None
266}
267
268fn is_explicit_file(event_path: &Path, explicit_files: &[PathBuf]) -> bool {
269    let normalized = std::fs::canonicalize(event_path).unwrap_or_else(|_| event_path.to_path_buf());
270    explicit_files.iter().any(|path| path == &normalized)
271}
272
273fn should_ignore_event(event_path: &Path, ignore: &[PathBuf], explicit_files: &[PathBuf]) -> bool {
274    is_ignored(event_path, ignore) && !is_explicit_file(event_path, explicit_files)
275}
276
277fn collect_event_paths(
278    event_path: &Path,
279    ignore: &[PathBuf],
280    explicit_files: &[PathBuf],
281    seen: &mut HashSet<PathBuf>,
282    paths: &mut Vec<PathBuf>,
283) {
284    let is_directory = event_path.is_dir();
285    if is_directory || !event_path.exists() {
286        if let Some(directory) = normalize_explicit_file(event_path) {
287            // Recursive backends can attach after a new directory is populated,
288            // leaving its creation event as the only signal for included files.
289            for file in explicit_files {
290                if file != &directory && file.starts_with(&directory) && seen.insert(file.clone()) {
291                    paths.push(file.clone());
292                }
293            }
294        }
295    }
296    if !is_directory
297        && !should_ignore_event(event_path, ignore, explicit_files)
298        && seen.insert(event_path.to_path_buf())
299    {
300        paths.push(event_path.to_path_buf());
301    }
302}
303
304/// Returns true when `event_path` lives under any ignored root.
305///
306/// Matching uses two strategies:
307/// 1. **Absolute roots** (e.g. canonicalized `dist/`): the event path
308///    must literally start with the root. Handles "ignore this exact
309///    output directory" cases.
310/// 2. **Single-component relative roots** (e.g. `node_modules`,
311///    `.git`, `target`): match if any component of the event path
312///    equals that name. This is what makes `default_ignore_paths()`
313///    work universally — `target` matches `/repo/target/...`,
314///    `/repo/sub/target/...`, etc., regardless of where the watcher
315///    cwd was when the path was registered.
316fn is_ignored(event_path: &Path, ignore: &[PathBuf]) -> bool {
317    if ignore.is_empty() {
318        return false;
319    }
320    let canon = std::fs::canonicalize(event_path).ok();
321    let candidate: &Path = canon.as_deref().unwrap_or(event_path);
322
323    for root in ignore {
324        if root.is_absolute() {
325            if candidate.starts_with(root) {
326                return true;
327            }
328            continue;
329        }
330        // Relative root: treat as a name to match against any path
331        // component. Skips the absolute-prefix mismatch trap that
332        // would otherwise let `target` / `node_modules` through.
333        let mut components = root.components();
334        let first = components.next();
335        let only_one_component = components.next().is_none();
336        if let (Some(first), true) = (first, only_one_component) {
337            let name = first.as_os_str();
338            // Check the lexical event path before its canonical form. pnpm
339            // dependencies are symlinks, so canonicalization removes the
340            // `node_modules` component that identifies the ignored subtree.
341            if event_path.components().any(|c| c.as_os_str() == name)
342                || candidate.components().any(|c| c.as_os_str() == name)
343            {
344                return true;
345            }
346        } else if candidate.starts_with(root) {
347            // Multi-component relative root — fall back to prefix.
348            return true;
349        }
350    }
351    false
352}
353
354/// Default ignore subtrees common to dev servers. Includes the universal
355/// junk: `node_modules`, `.git`, `target`. Callers should append their
356/// own build-output directory and any tool-specific cache directories
357/// before passing this to [`spawn_watcher`].
358#[must_use]
359pub fn default_ignore_paths() -> Vec<PathBuf> {
360    vec![
361        PathBuf::from("node_modules"),
362        PathBuf::from(".git"),
363        PathBuf::from("target"),
364    ]
365}
366
367/// Whether `path`'s content changed since the previous event, updating `cache`.
368///
369/// A path that cannot be read as a regular file within the size cap (deleted,
370/// a directory, a permissions error, or oversized) is treated as **changed** so
371/// deletions still trigger a rebuild and large files are never silently skipped.
372fn content_changed(
373    cache: &mut HashMap<PathBuf, u64>,
374    path: &Path,
375    buffer: &mut [u8; HASH_BUFFER_SIZE],
376) -> bool {
377    match hash_file(path, buffer) {
378        Some(hash) => match cache.insert(path.to_path_buf(), hash) {
379            Some(previous) => previous != hash,
380            None => true,
381        },
382        None => {
383            cache.remove(path);
384            true
385        }
386    }
387}
388
389fn should_forward_path(
390    cache: &mut HashMap<PathBuf, u64>,
391    path: &Path,
392    retry_unchanged: bool,
393    buffer: &mut [u8; HASH_BUFFER_SIZE],
394) -> bool {
395    content_changed(cache, path, buffer) || retry_unchanged
396}
397
398#[cfg(test)]
399#[allow(clippy::disallowed_methods)]
400mod tests {
401    use super::*;
402
403    #[derive(Default)]
404    struct RecordingWatcher {
405        paths: Vec<(PathBuf, RecursiveMode)>,
406    }
407
408    impl Watcher for RecordingWatcher {
409        fn new<F: EventHandler>(_: F, _: notify::Config) -> notify::Result<Self> {
410            Ok(Self::default())
411        }
412
413        fn watch(&mut self, path: &Path, mode: RecursiveMode) -> notify::Result<()> {
414            self.paths.push((path.to_path_buf(), mode));
415            Ok(())
416        }
417
418        fn unwatch(&mut self, _: &Path) -> notify::Result<()> {
419            Ok(())
420        }
421
422        fn kind() -> WatcherKind {
423            RecommendedWatcher::kind()
424        }
425    }
426
427    #[test]
428    fn missing_include_does_not_recursively_watch_ignored_ancestor() {
429        let root = tempfile::tempdir().unwrap();
430        let target = root.path().join("target");
431        std::fs::create_dir_all(target.join("debug/build/unrelated")).unwrap();
432        let file = normalize_explicit_file(&target.join("generated/new/state.json")).unwrap();
433        let mut watcher = RecordingWatcher::default();
434        watch_explicit_parents(&mut watcher, &[file], &[]).unwrap();
435        assert_eq!(
436            watcher.paths,
437            vec![(
438                std::fs::canonicalize(target).unwrap(),
439                RecursiveMode::NonRecursive
440            )]
441        );
442    }
443
444    #[test]
445    fn is_ignored_matches_descendant() {
446        let dir = tempfile::tempdir().unwrap();
447        let dist = dir.path().join("dist");
448        std::fs::create_dir(&dist).unwrap();
449        let nested = dist.join("a/b.html");
450        std::fs::create_dir_all(nested.parent().unwrap()).unwrap();
451        std::fs::write(&nested, "x").unwrap();
452
453        let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
454        assert!(is_ignored(&nested, &ignore));
455    }
456
457    #[test]
458    fn explicit_manifest_bypasses_ignored_output_directory() {
459        let dir = tempfile::tempdir().unwrap();
460        let dist = dir.path().join("dist");
461        std::fs::create_dir(&dist).unwrap();
462        let manifest = dist.join("webui-projection.json");
463        let bundle = dist.join("index.js");
464        std::fs::write(&manifest, "{}").unwrap();
465        std::fs::write(&bundle, "export {};").unwrap();
466
467        let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
468        let explicit = vec![std::fs::canonicalize(&manifest).unwrap()];
469        assert!(!should_ignore_event(&manifest, &ignore, &explicit));
470        assert!(should_ignore_event(&bundle, &ignore, &explicit));
471    }
472
473    #[test]
474    fn explicit_file_tracks_missing_parent_directories() -> std::io::Result<()> {
475        let dir = tempfile::tempdir()?;
476        let file = dir.path().join("target/new/state.json");
477        let normalized = normalize_explicit_file(&file)
478            .ok_or_else(|| std::io::Error::other("missing include was discarded"))?;
479        assert_eq!(
480            normalized,
481            std::fs::canonicalize(dir.path())?.join("target/new/state.json")
482        );
483        std::fs::create_dir_all(
484            file.parent()
485                .ok_or_else(|| std::io::Error::other("missing state parent"))?,
486        )?;
487        std::fs::write(&file, "{}")?;
488        assert!(!should_ignore_event(
489            &file,
490            &[PathBuf::from("target")],
491            &[normalized]
492        ));
493        Ok(())
494    }
495
496    #[test]
497    fn directory_event_rechecks_explicit_files_created_before_recursive_watch() {
498        let dir = tempfile::tempdir().unwrap();
499        let directory = dir.path().join("target/new");
500        let file = directory.join("state.json");
501        let explicit = vec![normalize_explicit_file(&file).unwrap()];
502        std::fs::create_dir_all(&directory).unwrap();
503        std::fs::write(&file, "{}").unwrap();
504        let mut seen = HashSet::new();
505        let mut paths = Vec::new();
506        collect_event_paths(
507            &directory,
508            &[PathBuf::from("target")],
509            &explicit,
510            &mut seen,
511            &mut paths,
512        );
513        assert_eq!(paths, explicit);
514    }
515
516    #[test]
517    fn directory_rechecks_dedupe_and_skip_unchanged_explicit_files() {
518        let dir = tempfile::tempdir().unwrap();
519        let directory = dir.path().join("target/new");
520        std::fs::create_dir_all(&directory).unwrap();
521        let file = directory.join("state.json");
522        std::fs::write(&file, "{}").unwrap();
523        let explicit = vec![normalize_explicit_file(&file).unwrap()];
524        let mut hashes = HashMap::new();
525        let mut buffer = [0_u8; HASH_BUFFER_SIZE];
526        assert!(content_changed(&mut hashes, &explicit[0], &mut buffer));
527        let mut seen = HashSet::new();
528        let mut paths = Vec::new();
529        for _ in 0..2 {
530            collect_event_paths(
531                &directory,
532                &[PathBuf::from("target")],
533                &explicit,
534                &mut seen,
535                &mut paths,
536            );
537        }
538        assert_eq!(paths, explicit);
539        assert!(!should_forward_path(
540            &mut hashes,
541            &paths[0],
542            false,
543            &mut buffer
544        ));
545        std::fs::write(&file, "{\"count\":7}").unwrap();
546        assert!(should_forward_path(
547            &mut hashes,
548            &paths[0],
549            false,
550            &mut buffer
551        ));
552    }
553
554    #[test]
555    fn directory_events_ignore_unrelated_files_and_recheck_removed_parents() {
556        let dir = tempfile::tempdir().unwrap();
557        let directory = dir.path().join("target/new");
558        let unrelated = dir.path().join("target/other");
559        std::fs::create_dir_all(&directory).unwrap();
560        std::fs::create_dir_all(&unrelated).unwrap();
561        let file = directory.join("state.json");
562        std::fs::write(&file, "{}").unwrap();
563        let explicit = vec![normalize_explicit_file(&file).unwrap()];
564        let ignore = vec![PathBuf::from("target")];
565        let mut seen = HashSet::new();
566        let mut paths = Vec::new();
567        collect_event_paths(&unrelated, &ignore, &explicit, &mut seen, &mut paths);
568        assert!(paths.is_empty());
569        std::fs::remove_file(&file).unwrap();
570        std::fs::remove_dir(&directory).unwrap();
571        collect_event_paths(&directory, &ignore, &explicit, &mut seen, &mut paths);
572        assert_eq!(paths, explicit);
573    }
574
575    #[test]
576    fn is_ignored_misses_sibling() {
577        let dir = tempfile::tempdir().unwrap();
578        let dist = dir.path().join("dist");
579        let other = dir.path().join("src/a.md");
580        std::fs::create_dir(&dist).unwrap();
581        std::fs::create_dir_all(other.parent().unwrap()).unwrap();
582        std::fs::write(&other, "x").unwrap();
583
584        let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
585        assert!(!is_ignored(&other, &ignore));
586    }
587
588    #[test]
589    fn is_ignored_is_false_when_no_ignores() {
590        let p = Path::new("/anything/at/all");
591        assert!(!is_ignored(p, &[]));
592    }
593
594    #[test]
595    fn is_ignored_matches_relative_component_anywhere() {
596        // `target` (single relative component) must match no matter
597        // how deep — this is how default_ignore_paths() actually
598        // works in the wild against absolute paths from notify.
599        let ignore = vec![PathBuf::from("target")];
600        assert!(is_ignored(Path::new("/repo/target/debug/build"), &ignore));
601        assert!(is_ignored(
602            Path::new("/repo/sub/crate/target/foo.rs"),
603            &ignore
604        ));
605        assert!(!is_ignored(Path::new("/repo/src/target_name.rs"), &ignore));
606    }
607
608    #[test]
609    fn is_ignored_handles_node_modules_and_git() {
610        let ignore = default_ignore_paths();
611        assert!(is_ignored(
612            Path::new("/repo/node_modules/foo/index.js"),
613            &ignore
614        ));
615        assert!(is_ignored(Path::new("/repo/.git/HEAD"), &ignore));
616        assert!(!is_ignored(Path::new("/repo/src/index.ts"), &ignore));
617    }
618
619    #[test]
620    fn read_only_access_events_are_not_content_changes() {
621        use notify::event::ModifyKind;
622
623        for kind in [
624            AccessKind::Read,
625            AccessKind::Open(AccessMode::Read),
626            AccessKind::Close(AccessMode::Read),
627        ] {
628            assert!(is_read_only_access_event(&Event::new(EventKind::Access(
629                kind
630            ))));
631        }
632        for kind in [
633            AccessKind::Any,
634            AccessKind::Open(AccessMode::Write),
635            AccessKind::Close(AccessMode::Write),
636        ] {
637            assert!(!is_read_only_access_event(&Event::new(EventKind::Access(
638                kind
639            ))));
640        }
641        assert!(!is_read_only_access_event(&Event::new(EventKind::Modify(
642            ModifyKind::Any
643        ))));
644    }
645
646    #[cfg(unix)]
647    #[test]
648    fn is_ignored_matches_node_modules_symlink_path() {
649        use std::os::unix::fs::symlink;
650
651        let dir = tempfile::tempdir().unwrap();
652        let package = dir.path().join("packages/example");
653        let node_modules = dir.path().join("node_modules");
654        std::fs::create_dir_all(&package).unwrap();
655        std::fs::create_dir(&node_modules).unwrap();
656        std::fs::write(package.join("index.js"), "export {};").unwrap();
657        symlink(&package, node_modules.join("example")).unwrap();
658
659        let linked_file = node_modules.join("example/index.js");
660        let canonical = std::fs::canonicalize(&linked_file).unwrap();
661        assert!(!canonical
662            .components()
663            .any(|component| component.as_os_str() == "node_modules"));
664        assert!(is_ignored(&linked_file, &[PathBuf::from("node_modules")]));
665    }
666
667    #[test]
668    fn content_changed_skips_identical_saves() {
669        let dir = tempfile::tempdir().unwrap();
670        let file = dir.path().join("a.css");
671        std::fs::write(&file, "a { color: red; }").unwrap();
672        let mut cache = HashMap::new();
673        let mut buffer = [0_u8; HASH_BUFFER_SIZE];
674
675        // First sighting → changed (nothing cached yet).
676        assert!(content_changed(&mut cache, &file, &mut buffer));
677        // Re-saving identical bytes (repeated Ctrl+S) → no change → no rebuild.
678        assert!(!content_changed(&mut cache, &file, &mut buffer));
679        assert!(!content_changed(&mut cache, &file, &mut buffer));
680
681        // A real edit → changed.
682        std::fs::write(&file, "a { color: blue; }").unwrap();
683        assert!(content_changed(&mut cache, &file, &mut buffer));
684        // Identical again → unchanged.
685        assert!(!content_changed(&mut cache, &file, &mut buffer));
686
687        // Deletion → changed, so a rebuild can clear stale output.
688        std::fs::remove_file(&file).unwrap();
689        assert!(content_changed(&mut cache, &file, &mut buffer));
690        // The cache forgot it, so a later recreation is a fresh change.
691        std::fs::write(&file, "a { color: blue; }").unwrap();
692        assert!(content_changed(&mut cache, &file, &mut buffer));
693    }
694
695    #[test]
696    fn unchanged_save_can_retry_when_error_active() {
697        let dir = tempfile::tempdir().unwrap();
698        let file = dir.path().join("a.css");
699        std::fs::write(&file, "a { color: red; }").unwrap();
700        let mut cache = HashMap::new();
701        let mut buffer = [0_u8; HASH_BUFFER_SIZE];
702
703        assert!(should_forward_path(&mut cache, &file, false, &mut buffer));
704        assert!(!should_forward_path(&mut cache, &file, false, &mut buffer));
705        assert!(should_forward_path(&mut cache, &file, true, &mut buffer));
706        assert!(!should_forward_path(&mut cache, &file, false, &mut buffer));
707
708        std::fs::write(&file, "a { color: blue; }").unwrap();
709        assert!(should_forward_path(&mut cache, &file, true, &mut buffer));
710        assert!(!should_forward_path(&mut cache, &file, false, &mut buffer));
711    }
712
713    #[test]
714    fn unhashable_paths_forget_cached_content() -> std::io::Result<()> {
715        let dir = tempfile::tempdir()?;
716        let file = dir.path().join("a.css");
717        let mut cache = HashMap::new();
718        let mut buffer = [0_u8; HASH_BUFFER_SIZE];
719
720        for replacement in ["deleted", "directory", "oversized"] {
721            std::fs::write(&file, "original")?;
722            assert!(content_changed(&mut cache, &file, &mut buffer));
723            assert!(!content_changed(&mut cache, &file, &mut buffer));
724
725            std::fs::remove_file(&file)?;
726            match replacement {
727                "directory" => std::fs::create_dir(&file)?,
728                "oversized" => std::fs::File::create(&file)?.set_len(8 * 1024 * 1024 + 1)?,
729                _ => {}
730            }
731            for _ in 0..2 {
732                assert!(content_changed(&mut cache, &file, &mut buffer));
733                assert!(!cache.contains_key(&file));
734            }
735            match replacement {
736                "directory" => std::fs::remove_dir(&file)?,
737                "oversized" => std::fs::remove_file(&file)?,
738                _ => {}
739            }
740        }
741        std::fs::write(&file, "original")?;
742        assert!(content_changed(&mut cache, &file, &mut buffer));
743        Ok(())
744    }
745
746    #[test]
747    fn hash_buffer_is_reused_across_files_and_event_batches() -> std::io::Result<()> {
748        let dir = tempfile::tempdir()?;
749        let paths = [
750            dir.path().join("large.css"),
751            dir.path().join("empty.css"),
752            dir.path().join("small.css"),
753        ];
754        for (path, size) in paths.iter().zip([3 * HASH_BUFFER_SIZE + 1, 0, 7]) {
755            std::fs::write(path, vec![b'x'; size])?;
756        }
757        let mut cache = HashMap::new();
758        let mut buffer = [0_u8; HASH_BUFFER_SIZE];
759        for changed in [true, false, false] {
760            for path in &paths {
761                assert_eq!(content_changed(&mut cache, path, &mut buffer), changed);
762            }
763        }
764        Ok(())
765    }
766
767    #[cfg(unix)]
768    #[test]
769    fn unreadable_symlink_loop_forgets_cached_content() -> std::io::Result<()> {
770        let dir = tempfile::tempdir()?;
771        let file = dir.path().join("unreadable.css");
772        std::fs::write(&file, "original")?;
773        let mut cache = HashMap::new();
774        let mut buffer = [0_u8; HASH_BUFFER_SIZE];
775        assert!(content_changed(&mut cache, &file, &mut buffer));
776        std::fs::remove_file(&file)?;
777        std::os::unix::fs::symlink("unreadable.css", &file)?;
778        assert!(content_changed(&mut cache, &file, &mut buffer));
779        assert!(!cache.contains_key(&file));
780        std::fs::remove_file(&file)?;
781        std::fs::write(&file, "original")?;
782        assert!(content_changed(&mut cache, &file, &mut buffer));
783        Ok(())
784    }
785}