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