Skip to main content

with_watch/
snapshot.rs

1use std::{
2    collections::BTreeMap,
3    fmt,
4    fs::{self, File},
5    io::Read,
6    path::{Path, PathBuf},
7    time::{Duration, SystemTime, UNIX_EPOCH},
8};
9
10use globset::Glob;
11use walkdir::WalkDir;
12
13use crate::error::{Result, WithWatchError};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ChangeDetectionMode {
17    ContentHash,
18    MtimeOnly,
19}
20
21impl ChangeDetectionMode {
22    pub fn as_str(self) -> &'static str {
23        match self {
24            Self::ContentHash => "content-hash",
25            Self::MtimeOnly => "mtime-only",
26        }
27    }
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum CommandSource {
32    Argv,
33    Shell,
34    Exec,
35}
36
37impl CommandSource {
38    pub fn as_str(self) -> &'static str {
39        match self {
40            Self::Argv => "argv",
41            Self::Shell => "shell",
42            Self::Exec => "exec",
43        }
44    }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum WatchInputKind {
49    Explicit,
50    Inferred,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum PathSnapshotMode {
55    ContentPath,
56    ContentTree,
57    MetadataPath,
58    MetadataChildren,
59    MetadataTree,
60}
61
62impl PathSnapshotMode {
63    pub fn as_str(self) -> &'static str {
64        match self {
65            Self::ContentPath => "content-path",
66            Self::ContentTree => "content-tree",
67            Self::MetadataPath => "metadata-path",
68            Self::MetadataChildren => "metadata-children",
69            Self::MetadataTree => "metadata-tree",
70        }
71    }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum WatchInput {
76    Path {
77        kind: WatchInputKind,
78        path: PathBuf,
79        watch_anchor: PathBuf,
80        snapshot_mode: PathSnapshotMode,
81    },
82    Glob {
83        kind: WatchInputKind,
84        raw: String,
85        absolute_pattern: String,
86        watch_anchor: PathBuf,
87    },
88}
89
90impl WatchInput {
91    pub fn path(raw: &str, cwd: &Path, kind: WatchInputKind) -> Result<Self> {
92        let absolute_path = absolutize(raw, cwd);
93        let snapshot_mode = default_path_snapshot_mode(&absolute_path);
94        Self::path_with_snapshot_mode(raw, cwd, kind, snapshot_mode)
95    }
96
97    pub fn path_with_snapshot_mode(
98        raw: &str,
99        cwd: &Path,
100        kind: WatchInputKind,
101        snapshot_mode: PathSnapshotMode,
102    ) -> Result<Self> {
103        let absolute_path = absolutize(raw, cwd);
104        let watch_anchor = path_watch_anchor(&absolute_path).ok_or_else(|| {
105            WithWatchError::MissingWatchAnchor {
106                path: absolute_path.clone(),
107            }
108        })?;
109
110        Ok(Self::Path {
111            kind,
112            path: absolute_path,
113            watch_anchor,
114            snapshot_mode,
115        })
116    }
117
118    pub fn glob(raw: &str, cwd: &Path) -> Result<Self> {
119        let absolute_pattern_path = absolutize(raw, cwd);
120        let absolute_pattern = normalize_path_string(&absolute_pattern_path);
121        Glob::new(&absolute_pattern).map_err(|error| WithWatchError::InvalidGlob {
122            pattern: raw.to_string(),
123            message: error.to_string(),
124        })?;
125
126        let anchor_candidate = glob_anchor(raw, cwd);
127        let watch_anchor = nearest_existing_parent(&anchor_candidate).ok_or_else(|| {
128            WithWatchError::MissingWatchAnchor {
129                path: anchor_candidate.clone(),
130            }
131        })?;
132
133        Ok(Self::Glob {
134            kind: WatchInputKind::Explicit,
135            raw: raw.to_string(),
136            absolute_pattern,
137            watch_anchor,
138        })
139    }
140
141    pub fn kind(&self) -> WatchInputKind {
142        match self {
143            Self::Path { kind, .. } | Self::Glob { kind, .. } => *kind,
144        }
145    }
146
147    pub fn watch_anchor(&self) -> &Path {
148        match self {
149            Self::Path { watch_anchor, .. } | Self::Glob { watch_anchor, .. } => watch_anchor,
150        }
151    }
152
153    pub fn snapshot_mode_label(&self) -> &'static str {
154        match self {
155            Self::Path { snapshot_mode, .. } => snapshot_mode.as_str(),
156            Self::Glob { .. } => PathSnapshotMode::ContentTree.as_str(),
157        }
158    }
159}
160
161#[derive(Debug, Clone)]
162pub struct SnapshotState {
163    entries: BTreeMap<PathBuf, SnapshotEntry>,
164}
165
166impl SnapshotState {
167    pub fn is_meaningfully_different(
168        &self,
169        previous: &SnapshotState,
170        mode: ChangeDetectionMode,
171    ) -> bool {
172        if self.entries.len() != previous.entries.len() {
173            return true;
174        }
175
176        for (path, current) in &self.entries {
177            let Some(previous_entry) = previous.entries.get(path) else {
178                return true;
179            };
180
181            if !current.equivalent_to(previous_entry, mode) {
182                return true;
183            }
184        }
185
186        false
187    }
188
189    pub fn len(&self) -> usize {
190        self.entries.len()
191    }
192
193    pub fn is_empty(&self) -> bool {
194        self.entries.is_empty()
195    }
196}
197
198#[derive(Debug, Clone)]
199struct SnapshotEntry {
200    kind: SnapshotEntryKind,
201    modified: Option<SystemTime>,
202    digest: Option<blake3::Hash>,
203}
204
205impl SnapshotEntry {
206    fn equivalent_to(&self, previous: &SnapshotEntry, mode: ChangeDetectionMode) -> bool {
207        if self.kind != previous.kind {
208            return false;
209        }
210
211        match mode {
212            ChangeDetectionMode::ContentHash => self.digest == previous.digest,
213            ChangeDetectionMode::MtimeOnly => self.modified == previous.modified,
214        }
215    }
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum SnapshotEntryKind {
220    File,
221    Directory,
222    Missing,
223}
224
225impl fmt::Display for SnapshotEntryKind {
226    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227        match self {
228            Self::File => write!(f, "file"),
229            Self::Directory => write!(f, "directory"),
230            Self::Missing => write!(f, "missing"),
231        }
232    }
233}
234
235pub fn capture_snapshot(inputs: &[WatchInput], mode: ChangeDetectionMode) -> Result<SnapshotState> {
236    let mut entries = BTreeMap::new();
237
238    for input in inputs {
239        match input {
240            WatchInput::Path {
241                path,
242                snapshot_mode,
243                ..
244            } => {
245                capture_path_input(path, *snapshot_mode, mode, &mut entries)?;
246            }
247            WatchInput::Glob {
248                absolute_pattern,
249                watch_anchor,
250                ..
251            } => {
252                capture_glob_input(absolute_pattern, watch_anchor, mode, &mut entries)?;
253            }
254        }
255    }
256
257    Ok(SnapshotState { entries })
258}
259
260fn capture_path_input(
261    path: &Path,
262    snapshot_mode: PathSnapshotMode,
263    mode: ChangeDetectionMode,
264    entries: &mut BTreeMap<PathBuf, SnapshotEntry>,
265) -> Result<()> {
266    if !path.exists() {
267        insert_missing_entry(path, snapshot_mode, mode, entries);
268        return Ok(());
269    }
270
271    let metadata = fs::metadata(path).map_err(|source| WithWatchError::Metadata {
272        path: path.to_path_buf(),
273        source,
274    })?;
275
276    match snapshot_mode {
277        PathSnapshotMode::ContentPath | PathSnapshotMode::MetadataPath => {
278            insert_existing_entry(path, &metadata, snapshot_mode, mode, entries)?;
279        }
280        PathSnapshotMode::ContentTree | PathSnapshotMode::MetadataTree => {
281            if metadata.is_dir() {
282                capture_directory_tree(path, snapshot_mode, mode, entries)?;
283            } else {
284                insert_existing_entry(path, &metadata, snapshot_mode, mode, entries)?;
285            }
286        }
287        PathSnapshotMode::MetadataChildren => {
288            if metadata.is_dir() {
289                capture_directory_children(path, &metadata, snapshot_mode, mode, entries)?;
290            } else {
291                insert_existing_entry(path, &metadata, snapshot_mode, mode, entries)?;
292            }
293        }
294    }
295
296    Ok(())
297}
298
299fn capture_glob_input(
300    absolute_pattern: &str,
301    watch_anchor: &Path,
302    mode: ChangeDetectionMode,
303    entries: &mut BTreeMap<PathBuf, SnapshotEntry>,
304) -> Result<()> {
305    let matcher = Glob::new(absolute_pattern)
306        .map_err(|error| WithWatchError::InvalidGlob {
307            pattern: absolute_pattern.to_string(),
308            message: error.to_string(),
309        })?
310        .compile_matcher();
311
312    if !watch_anchor.exists() {
313        return Ok(());
314    }
315
316    for entry in WalkDir::new(watch_anchor).follow_links(true) {
317        let entry = entry.map_err(|error| WithWatchError::Metadata {
318            path: watch_anchor.to_path_buf(),
319            source: std::io::Error::other(error.to_string()),
320        })?;
321        let path = entry.path().to_path_buf();
322        let normalized = normalize_path_string(&path);
323        if matcher.is_match(&normalized) {
324            let metadata = fs::metadata(&path).map_err(|source| WithWatchError::Metadata {
325                path: path.clone(),
326                source,
327            })?;
328            insert_existing_entry(
329                &path,
330                &metadata,
331                PathSnapshotMode::ContentTree,
332                mode,
333                entries,
334            )?;
335        }
336    }
337
338    Ok(())
339}
340
341fn capture_directory_tree(
342    path: &Path,
343    snapshot_mode: PathSnapshotMode,
344    mode: ChangeDetectionMode,
345    entries: &mut BTreeMap<PathBuf, SnapshotEntry>,
346) -> Result<()> {
347    for entry in WalkDir::new(path).follow_links(true) {
348        let entry = entry.map_err(|error| WithWatchError::Metadata {
349            path: path.to_path_buf(),
350            source: std::io::Error::other(error.to_string()),
351        })?;
352        let entry_path = entry.path().to_path_buf();
353        let metadata = fs::metadata(&entry_path).map_err(|source| WithWatchError::Metadata {
354            path: entry_path.clone(),
355            source,
356        })?;
357        insert_existing_entry(&entry_path, &metadata, snapshot_mode, mode, entries)?;
358    }
359
360    Ok(())
361}
362
363fn capture_directory_children(
364    path: &Path,
365    metadata: &fs::Metadata,
366    snapshot_mode: PathSnapshotMode,
367    mode: ChangeDetectionMode,
368    entries: &mut BTreeMap<PathBuf, SnapshotEntry>,
369) -> Result<()> {
370    insert_existing_entry(path, metadata, snapshot_mode, mode, entries)?;
371
372    let read_dir = fs::read_dir(path).map_err(|source| WithWatchError::Metadata {
373        path: path.to_path_buf(),
374        source,
375    })?;
376    for entry in read_dir {
377        let entry = entry.map_err(|source| WithWatchError::Metadata {
378            path: path.to_path_buf(),
379            source,
380        })?;
381        let entry_path = entry.path();
382        let child_metadata =
383            fs::metadata(&entry_path).map_err(|source| WithWatchError::Metadata {
384                path: entry_path.clone(),
385                source,
386            })?;
387        insert_existing_entry(&entry_path, &child_metadata, snapshot_mode, mode, entries)?;
388    }
389
390    Ok(())
391}
392
393fn insert_existing_entry(
394    path: &Path,
395    metadata: &fs::Metadata,
396    snapshot_mode: PathSnapshotMode,
397    mode: ChangeDetectionMode,
398    entries: &mut BTreeMap<PathBuf, SnapshotEntry>,
399) -> Result<()> {
400    let kind = if metadata.is_dir() {
401        SnapshotEntryKind::Directory
402    } else {
403        SnapshotEntryKind::File
404    };
405    let modified = snapshot_entry_modified(kind, metadata);
406    let size = snapshot_entry_size(kind, metadata);
407    let digest = snapshot_digest(path, kind, modified, size, snapshot_mode, mode)?;
408
409    entries.insert(
410        path.to_path_buf(),
411        SnapshotEntry {
412            kind,
413            modified,
414            digest,
415        },
416    );
417
418    Ok(())
419}
420
421fn insert_missing_entry(
422    path: &Path,
423    snapshot_mode: PathSnapshotMode,
424    mode: ChangeDetectionMode,
425    entries: &mut BTreeMap<PathBuf, SnapshotEntry>,
426) {
427    let digest = if mode == ChangeDetectionMode::ContentHash {
428        Some(hash_metadata_tuple(
429            SnapshotEntryKind::Missing,
430            None,
431            None,
432            snapshot_mode,
433        ))
434    } else {
435        None
436    };
437
438    entries.insert(
439        path.to_path_buf(),
440        SnapshotEntry {
441            kind: SnapshotEntryKind::Missing,
442            modified: None,
443            digest,
444        },
445    );
446}
447
448fn snapshot_entry_size(kind: SnapshotEntryKind, metadata: &fs::Metadata) -> Option<u64> {
449    match kind {
450        SnapshotEntryKind::File => Some(metadata.len()),
451        SnapshotEntryKind::Directory | SnapshotEntryKind::Missing => None,
452    }
453}
454
455fn snapshot_entry_modified(kind: SnapshotEntryKind, metadata: &fs::Metadata) -> Option<SystemTime> {
456    match kind {
457        SnapshotEntryKind::File => metadata.modified().ok(),
458        SnapshotEntryKind::Directory | SnapshotEntryKind::Missing => None,
459    }
460}
461
462fn snapshot_digest(
463    path: &Path,
464    kind: SnapshotEntryKind,
465    modified: Option<SystemTime>,
466    size: Option<u64>,
467    snapshot_mode: PathSnapshotMode,
468    mode: ChangeDetectionMode,
469) -> Result<Option<blake3::Hash>> {
470    if mode != ChangeDetectionMode::ContentHash {
471        return Ok(None);
472    }
473
474    match snapshot_mode {
475        PathSnapshotMode::ContentPath | PathSnapshotMode::ContentTree => {
476            if kind == SnapshotEntryKind::File {
477                Ok(Some(hash_file(path)?))
478            } else {
479                Ok(None)
480            }
481        }
482        PathSnapshotMode::MetadataPath
483        | PathSnapshotMode::MetadataChildren
484        | PathSnapshotMode::MetadataTree => Ok(Some(hash_metadata_tuple(
485            kind,
486            modified,
487            size,
488            snapshot_mode,
489        ))),
490    }
491}
492
493fn hash_file(path: &Path) -> Result<blake3::Hash> {
494    let mut file = File::open(path).map_err(|source| WithWatchError::HashRead {
495        path: path.to_path_buf(),
496        source,
497    })?;
498    let mut hasher = blake3::Hasher::new();
499    let mut buffer = [0u8; 8192];
500
501    loop {
502        let bytes_read = file
503            .read(&mut buffer)
504            .map_err(|source| WithWatchError::HashRead {
505                path: path.to_path_buf(),
506                source,
507            })?;
508        if bytes_read == 0 {
509            break;
510        }
511        hasher.update(&buffer[..bytes_read]);
512    }
513
514    Ok(hasher.finalize())
515}
516
517fn hash_metadata_tuple(
518    kind: SnapshotEntryKind,
519    modified: Option<SystemTime>,
520    size: Option<u64>,
521    snapshot_mode: PathSnapshotMode,
522) -> blake3::Hash {
523    let mut hasher = blake3::Hasher::new();
524    hasher.update(snapshot_mode.as_str().as_bytes());
525    hasher.update(kind.to_string().as_bytes());
526
527    if let Some(modified) = modified {
528        let (sign, duration) = if let Ok(duration) = modified.duration_since(UNIX_EPOCH) {
529            (0u8, duration)
530        } else {
531            (
532                1u8,
533                UNIX_EPOCH
534                    .duration_since(modified)
535                    .unwrap_or(Duration::ZERO),
536            )
537        };
538        hasher.update(&[sign]);
539        hasher.update(&duration.as_secs().to_le_bytes());
540        hasher.update(&duration.subsec_nanos().to_le_bytes());
541    } else {
542        hasher.update(&[2u8]);
543    }
544
545    match size {
546        Some(size) => {
547            hasher.update(&[1u8]);
548            hasher.update(&size.to_le_bytes());
549        }
550        None => {
551            hasher.update(&[0u8]);
552        }
553    }
554
555    hasher.finalize()
556}
557
558fn default_path_snapshot_mode(path: &Path) -> PathSnapshotMode {
559    match fs::metadata(path) {
560        Ok(metadata) if metadata.is_dir() => PathSnapshotMode::ContentTree,
561        Ok(_) | Err(_) => PathSnapshotMode::ContentPath,
562    }
563}
564
565pub(crate) fn absolutize(raw: &str, cwd: &Path) -> PathBuf {
566    let expanded = expand_tilde(raw);
567    let path = PathBuf::from(expanded);
568    if path.is_absolute() {
569        path
570    } else {
571        cwd.join(path)
572    }
573}
574
575fn expand_tilde(raw: &str) -> String {
576    if let Some(suffix) = raw.strip_prefix("~/") {
577        if let Ok(home) = std::env::var("HOME") {
578            return format!("{home}/{suffix}");
579        }
580    }
581    raw.to_string()
582}
583
584fn nearest_existing_parent(path: &Path) -> Option<PathBuf> {
585    let mut current = Some(path);
586    while let Some(candidate) = current {
587        if candidate.exists() {
588            return Some(candidate.to_path_buf());
589        }
590        current = candidate.parent();
591    }
592    None
593}
594
595fn path_watch_anchor(path: &Path) -> Option<PathBuf> {
596    let nearest = nearest_existing_parent(path)?;
597    if nearest.is_dir() {
598        return Some(nearest);
599    }
600
601    // Watch the containing directory for file inputs so replace-style writers such
602    // as GNU `sed -i` do not orphan the watch after swapping the inode.
603    nearest.parent().map(Path::to_path_buf)
604}
605
606fn glob_anchor(raw: &str, cwd: &Path) -> PathBuf {
607    let expanded = expand_tilde(raw);
608    let original_path = PathBuf::from(&expanded);
609    let is_absolute = original_path.is_absolute();
610    let mut prefix = PathBuf::new();
611
612    for component in expanded.split(['/', '\\']) {
613        if component.is_empty() {
614            continue;
615        }
616        if component.contains('*') || component.contains('?') || component.contains('[') {
617            break;
618        }
619        prefix.push(component);
620    }
621
622    if prefix.as_os_str().is_empty() {
623        if is_absolute {
624            PathBuf::from(std::path::MAIN_SEPARATOR.to_string())
625        } else {
626            cwd.to_path_buf()
627        }
628    } else if is_absolute {
629        PathBuf::from(std::path::MAIN_SEPARATOR.to_string()).join(prefix)
630    } else {
631        cwd.join(prefix)
632    }
633}
634
635fn normalize_path_string(path: &Path) -> String {
636    path.to_string_lossy().replace('\\', "/")
637}
638
639#[cfg(test)]
640mod tests {
641    use std::{fs, thread, time::Duration};
642
643    use super::{
644        capture_snapshot, ChangeDetectionMode, PathSnapshotMode, SnapshotEntryKind, WatchInput,
645        WatchInputKind,
646    };
647
648    #[test]
649    fn glob_inputs_anchor_to_existing_parent() {
650        let temp_dir = tempfile::tempdir().expect("create tempdir");
651        let input = WatchInput::glob("src/**/*.rs", temp_dir.path()).expect("glob input");
652
653        match input {
654            WatchInput::Glob { watch_anchor, .. } => {
655                assert_eq!(watch_anchor, temp_dir.path());
656            }
657            other => panic!("unexpected watch input: {other:?}"),
658        }
659    }
660
661    #[test]
662    fn path_inputs_anchor_to_parent_directory_for_files() {
663        let temp_dir = tempfile::tempdir().expect("create tempdir");
664        let input_path = temp_dir.path().join("input.txt");
665        fs::write(&input_path, "alpha\n").expect("write file");
666
667        let input = WatchInput::path(
668            input_path.to_string_lossy().as_ref(),
669            temp_dir.path(),
670            WatchInputKind::Inferred,
671        )
672        .expect("path input");
673
674        match input {
675            WatchInput::Path { watch_anchor, .. } => {
676                assert_eq!(watch_anchor, temp_dir.path());
677            }
678            other => panic!("unexpected watch input: {other:?}"),
679        }
680    }
681
682    #[test]
683    fn hash_mode_ignores_metadata_only_churn() {
684        let temp_dir = tempfile::tempdir().expect("create tempdir");
685        let file_path = temp_dir.path().join("input.txt");
686        fs::write(&file_path, "hello").expect("write file");
687        let input = WatchInput::path(
688            file_path.to_string_lossy().as_ref(),
689            temp_dir.path(),
690            WatchInputKind::Explicit,
691        )
692        .expect("path input");
693
694        let first = capture_snapshot(
695            std::slice::from_ref(&input),
696            ChangeDetectionMode::ContentHash,
697        )
698        .expect("first snapshot");
699        thread::sleep(Duration::from_millis(20));
700        fs::write(&file_path, "hello").expect("rewrite same content");
701        let second =
702            capture_snapshot(&[input], ChangeDetectionMode::ContentHash).expect("second snapshot");
703
704        assert!(!second.is_meaningfully_different(&first, ChangeDetectionMode::ContentHash));
705    }
706
707    #[test]
708    fn mtime_mode_detects_metadata_only_churn() {
709        let temp_dir = tempfile::tempdir().expect("create tempdir");
710        let file_path = temp_dir.path().join("input.txt");
711        fs::write(&file_path, "hello").expect("write file");
712        let input = WatchInput::path(
713            file_path.to_string_lossy().as_ref(),
714            temp_dir.path(),
715            WatchInputKind::Explicit,
716        )
717        .expect("path input");
718
719        let first = capture_snapshot(std::slice::from_ref(&input), ChangeDetectionMode::MtimeOnly)
720            .expect("first snapshot");
721        thread::sleep(Duration::from_millis(20));
722        fs::write(&file_path, "hello").expect("rewrite same content");
723        let second =
724            capture_snapshot(&[input], ChangeDetectionMode::MtimeOnly).expect("second snapshot");
725
726        assert!(second.is_meaningfully_different(&first, ChangeDetectionMode::MtimeOnly));
727    }
728
729    #[test]
730    fn missing_paths_are_captured_explicitly() {
731        let temp_dir = tempfile::tempdir().expect("create tempdir");
732        let input = WatchInput::path("missing.txt", temp_dir.path(), WatchInputKind::Explicit)
733            .expect("path input");
734        let snapshot =
735            capture_snapshot(&[input], ChangeDetectionMode::ContentHash).expect("capture snapshot");
736
737        assert_eq!(snapshot.len(), 1);
738        let entry = snapshot.entries.values().next().expect("snapshot entry");
739        assert_eq!(entry.kind, SnapshotEntryKind::Missing);
740    }
741
742    #[test]
743    fn metadata_children_excludes_nested_descendants() {
744        let temp_dir = tempfile::tempdir().expect("create tempdir");
745        let root = temp_dir.path().join("root");
746        fs::create_dir_all(root.join("nested")).expect("create nested dir");
747        fs::write(root.join("direct.txt"), "alpha").expect("write direct child");
748        fs::write(root.join("nested").join("deep.txt"), "beta").expect("write nested child");
749
750        let input =
751            metadata_path_input(temp_dir.path(), "root", PathSnapshotMode::MetadataChildren);
752        let snapshot =
753            capture_snapshot(&[input], ChangeDetectionMode::ContentHash).expect("capture snapshot");
754
755        assert!(snapshot.entries.contains_key(&root));
756        assert!(snapshot.entries.contains_key(&root.join("direct.txt")));
757        assert!(snapshot.entries.contains_key(&root.join("nested")));
758        assert!(!snapshot
759            .entries
760            .contains_key(&root.join("nested").join("deep.txt")));
761    }
762
763    #[test]
764    fn metadata_tree_includes_nested_descendants() {
765        let temp_dir = tempfile::tempdir().expect("create tempdir");
766        let root = temp_dir.path().join("root");
767        fs::create_dir_all(root.join("nested")).expect("create nested dir");
768        fs::write(root.join("nested").join("deep.txt"), "beta").expect("write nested child");
769
770        let input = metadata_path_input(temp_dir.path(), "root", PathSnapshotMode::MetadataTree);
771        let snapshot =
772            capture_snapshot(&[input], ChangeDetectionMode::ContentHash).expect("capture snapshot");
773
774        assert!(snapshot.entries.contains_key(&root));
775        assert!(snapshot.entries.contains_key(&root.join("nested")));
776        assert!(snapshot
777            .entries
778            .contains_key(&root.join("nested").join("deep.txt")));
779    }
780
781    #[test]
782    fn metadata_listing_hash_mode_tracks_metadata_without_file_content_hashing() {
783        let temp_dir = tempfile::tempdir().expect("create tempdir");
784        let root = temp_dir.path().join("root");
785        fs::create_dir_all(&root).expect("create root");
786        let file_path = root.join("file.txt");
787        fs::write(&file_path, "hello").expect("write file");
788
789        let input =
790            metadata_path_input(temp_dir.path(), "root", PathSnapshotMode::MetadataChildren);
791        let first = capture_snapshot(
792            std::slice::from_ref(&input),
793            ChangeDetectionMode::ContentHash,
794        )
795        .expect("first snapshot");
796
797        thread::sleep(Duration::from_millis(20));
798        fs::write(&file_path, "hello").expect("rewrite same content");
799
800        let second =
801            capture_snapshot(&[input], ChangeDetectionMode::ContentHash).expect("second snapshot");
802
803        assert!(second.is_meaningfully_different(&first, ChangeDetectionMode::ContentHash));
804    }
805
806    fn metadata_path_input(
807        cwd: &std::path::Path,
808        raw: &str,
809        snapshot_mode: PathSnapshotMode,
810    ) -> WatchInput {
811        WatchInput::path_with_snapshot_mode(raw, cwd, WatchInputKind::Explicit, snapshot_mode)
812            .expect("metadata path input")
813    }
814}