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