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 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}