1use std::collections::HashSet as StdHashSet;
28use std::path::{Path, PathBuf};
29use std::time::SystemTime;
30
31use serde::{Deserialize, Serialize};
32use sha2::{Digest, Sha256};
33
34use super::{
35 MarkdownInfo, OtherFileInfo, Repo, StaticFileMetadata, build_markdown_url_path,
36 is_markdown_extension, should_ignore_compiled,
37};
38use crate::change_event::{ChangeEventType, FileChangeEvent};
39use crate::markdown::SimpleMetadata;
40use crate::relationships::RawRelationship;
41use crate::vault::{Availability, Entry, Vault, VaultPath};
42
43pub const SNAPSHOT_FORMAT: u32 = 1;
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51pub struct FileStamp {
52 pub size: u64,
53 pub modified_ns: u64,
55 pub not_downloaded: bool,
57}
58
59impl FileStamp {
60 pub fn of(entry: &Entry) -> Self {
61 let modified_ns = entry
62 .modified
63 .duration_since(SystemTime::UNIX_EPOCH)
64 .map_or(0, |d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX));
65 Self {
66 size: entry.size,
67 modified_ns,
68 not_downloaded: entry.availability == Availability::NotDownloaded,
69 }
70 }
71}
72
73#[derive(Debug, Clone, Default, PartialEq, Eq)]
81pub struct RepoDiff {
82 pub created: Vec<PathBuf>,
83 pub modified: Vec<PathBuf>,
84 pub deleted: Vec<PathBuf>,
85 pub evicted: Vec<PathBuf>,
86 pub not_downloaded_folders: Vec<PathBuf>,
92 stamps: Vec<(PathBuf, FileStamp)>,
95}
96
97impl RepoDiff {
98 pub fn is_empty(&self) -> bool {
100 self.created.is_empty() && self.modified.is_empty() && self.deleted.is_empty()
101 }
102
103 pub fn events(&self, root: &Path) -> Vec<FileChangeEvent> {
106 let event = |key: &PathBuf, kind: ChangeEventType| FileChangeEvent {
107 path: key.to_string_lossy().into_owned(),
108 relative_path: pathdiff::diff_paths(key, root)
109 .map(|p| crate::url_path::path_to_url(&p))
110 .unwrap_or_default(),
111 event: kind,
112 };
113 self.created
114 .iter()
115 .map(|k| event(k, ChangeEventType::Created))
116 .chain(
117 self.modified
118 .iter()
119 .map(|k| event(k, ChangeEventType::Modified)),
120 )
121 .chain(
122 self.deleted
123 .iter()
124 .map(|k| event(k, ChangeEventType::Deleted)),
125 )
126 .collect()
127 }
128}
129
130#[derive(Debug, thiserror::Error, PartialEq, Eq)]
132pub enum SnapshotError {
133 #[error("unreadable snapshot: {0}")]
134 Corrupt(String),
135 #[error("snapshot format {found}, this build reads {SNAPSHOT_FORMAT}")]
136 WrongFormat { found: u32 },
137 #[error("snapshot was taken by another mbr version or scan configuration")]
138 WrongFingerprint,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
143struct SnapshotNote {
144 path: String,
146 stamp: FileStamp,
147 created: u64,
148 modified: u64,
149 frontmatter: Option<SimpleMetadata>,
150 #[serde(default, skip_serializing_if = "Vec::is_empty")]
151 relationships: Vec<RawRelationship>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
156struct SnapshotAsset {
157 path: String,
158 stamp: FileStamp,
159 created: u64,
160 modified: u64,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct RepoSnapshot {
166 format: u32,
167 fingerprint: String,
168 markdown: Vec<SnapshotNote>,
169 other: Vec<SnapshotAsset>,
170}
171
172impl RepoSnapshot {
173 pub fn to_bytes(&self) -> Vec<u8> {
176 serde_json::to_vec(self).unwrap_or_default()
177 }
178
179 pub fn from_bytes(bytes: &[u8]) -> Result<Self, SnapshotError> {
182 #[derive(Deserialize)]
183 struct Header {
184 format: u32,
185 }
186 let header: Header =
187 serde_json::from_slice(bytes).map_err(|e| SnapshotError::Corrupt(e.to_string()))?;
188 if header.format != SNAPSHOT_FORMAT {
189 return Err(SnapshotError::WrongFormat {
190 found: header.format,
191 });
192 }
193 serde_json::from_slice(bytes).map_err(|e| SnapshotError::Corrupt(e.to_string()))
194 }
195
196 pub fn len(&self) -> usize {
198 self.markdown.len() + self.other.len()
199 }
200
201 pub fn is_empty(&self) -> bool {
202 self.len() == 0
203 }
204}
205
206impl Repo {
207 #[must_use]
211 pub fn with_change_tracking(mut self) -> Self {
212 self.stamps = Some(std::sync::Arc::new(papaya::HashMap::new()));
213 self
214 }
215
216 pub fn tracks_changes(&self) -> bool {
218 self.stamps.is_some()
219 }
220
221 pub(super) fn record_stamp(&self, key: &Path, entry: &Entry) {
223 if let Some(stamps) = &self.stamps {
224 stamps.pin().insert(key.to_path_buf(), FileStamp::of(entry));
225 }
226 }
227
228 pub fn snapshot_fingerprint(&self) -> String {
233 let inputs = serde_json::json!({
234 "format": SNAPSHOT_FORMAT,
235 "mbr": env!("CARGO_PKG_VERSION"),
236 "markdown_extensions": self.markdown_extensions,
237 "ignore_dirs": self.ignore_dirs,
238 "ignore_globs": self.ignore_globs,
239 "index_file": self.index_file,
240 "static_folder": self.static_folder,
241 "exempt_hidden_dirs": self
242 .exempt_hidden_vault_paths
243 .iter()
244 .map(VaultPath::as_str)
245 .collect::<Vec<_>>(),
246 });
247 let digest = Sha256::digest(inputs.to_string().as_bytes());
248 digest.iter().map(|b| format!("{b:02x}")).collect()
249 }
250
251 pub fn snapshot(&self) -> RepoSnapshot {
254 let empty = papaya::HashMap::new();
255 let stamps = self.stamps.as_deref().unwrap_or(&empty).pin();
256 let vault = self.vault.as_ref();
257 let in_vault = |key: &Path| {
258 let path = vault.vault_path(key).ok()?;
259 let stamp = *stamps.get(key)?;
260 Some((path.as_str().to_owned(), stamp))
261 };
262 let mut markdown: Vec<SnapshotNote> = self
263 .markdown_files
264 .pin()
265 .iter()
266 .filter_map(|(key, info)| {
267 let (path, stamp) = in_vault(key)?;
268 Some(SnapshotNote {
269 path,
270 stamp,
271 created: info.created,
272 modified: info.modified,
273 frontmatter: info.frontmatter.clone(),
274 relationships: info.relationships.clone(),
275 })
276 })
277 .collect();
278 markdown.sort_unstable_by(|a, b| a.path.cmp(&b.path));
279 let mut other: Vec<SnapshotAsset> = self
280 .other_files
281 .pin()
282 .iter()
283 .filter_map(|(key, info)| {
284 let (path, stamp) = in_vault(key)?;
285 Some(SnapshotAsset {
286 path,
287 stamp,
288 created: info.metadata.created.unwrap_or_default(),
289 modified: info.metadata.modified.unwrap_or_default(),
290 })
291 })
292 .collect();
293 other.sort_unstable_by(|a, b| a.path.cmp(&b.path));
294 RepoSnapshot {
295 format: SNAPSHOT_FORMAT,
296 fingerprint: self.snapshot_fingerprint(),
297 markdown,
298 other,
299 }
300 }
301
302 pub fn restore_snapshot(&self, snapshot: RepoSnapshot) -> Result<usize, SnapshotError> {
313 if snapshot.format != SNAPSHOT_FORMAT {
314 return Err(SnapshotError::WrongFormat {
315 found: snapshot.format,
316 });
317 }
318 if snapshot.fingerprint != self.snapshot_fingerprint() {
319 return Err(SnapshotError::WrongFingerprint);
320 }
321 let vault = self.vault.as_ref();
322 let stamps = self.stamps.as_ref().map(|s| s.pin());
323 let key_of = |path: &str| VaultPath::new(path).ok().map(|p| vault.key(&p));
324 let markdown = self.markdown_files.pin();
325 let mut restored = 0;
326 for note in snapshot.markdown {
327 let Some(key) = key_of(¬e.path) else {
328 continue;
329 };
330 let info = MarkdownInfo {
331 raw_path: self.relative_to_root(&key),
332 url_path: build_markdown_url_path(&key, &self.canonical_root, &self.index_file),
333 created: note.created,
334 modified: note.modified,
335 frontmatter: note.frontmatter,
336 relationships: note.relationships,
337 };
338 if let Some(stamps) = &stamps {
339 stamps.insert(key.clone(), note.stamp);
340 }
341 markdown.insert(key, info);
342 restored += 1;
343 }
344 drop(markdown);
345 let other = self.other_files.pin();
346 for asset in snapshot.other {
347 let Some(key) = key_of(&asset.path) else {
348 continue;
349 };
350 let info = OtherFileInfo {
351 raw_path: key.clone(),
352 url_path: self.static_url_for_key(&key),
353 metadata: StaticFileMetadata::empty(&key).with_details(Some((
354 asset.stamp.size,
355 asset.created,
356 asset.modified,
357 ))),
358 extracted_text: None,
359 };
360 if let Some(stamps) = &stamps {
361 stamps.insert(key.clone(), asset.stamp);
362 }
363 other.insert(key, info);
364 restored += 1;
365 }
366 drop(other);
367 self.rebuild_tag_index();
368 Ok(restored)
369 }
370
371 pub fn diff_vault(&self) -> RepoDiff {
383 let empty = papaya::HashMap::new();
384 let stamps = self.stamps.as_deref().unwrap_or(&empty).pin();
385 let mut diff = RepoDiff::default();
386 let mut seen: StdHashSet<PathBuf> = StdHashSet::new();
387 for (vault, overlay) in std::iter::once((&self.vault, false))
388 .chain(self.static_vault.as_ref().map(|v| (v, true)))
389 {
390 let walk = self.listed_files(vault.as_ref());
391 tracing::info!(
392 "Refresh listed {} folders, {} files under {}",
393 walk.dirs_listed,
394 walk.files.len(),
395 vault.root().display()
396 );
397 if !walk.not_downloaded_dirs.is_empty() {
398 vault.request_download(&walk.not_downloaded_dirs);
401 diff.not_downloaded_folders
402 .extend(walk.not_downloaded_dirs.iter().map(|d| vault.key(d)));
403 }
404 for entry in walk.files {
405 let key = vault.key(&entry.path);
406 if overlay
408 && is_markdown_extension(
409 entry.path.extension().unwrap_or(""),
410 &self.markdown_extensions,
411 )
412 {
413 continue;
414 }
415 let stamp = FileStamp::of(&entry);
416 match stamps.get(&key) {
417 None => diff.created.push(key.clone()),
418 Some(old) if old.size != stamp.size || old.modified_ns != stamp.modified_ns => {
419 diff.modified.push(key.clone());
420 }
421 Some(old) if old.not_downloaded && !stamp.not_downloaded => {
423 diff.modified.push(key.clone());
424 }
425 Some(old) if !old.not_downloaded && stamp.not_downloaded => {
426 diff.evicted.push(key.clone());
427 }
428 Some(_) => {
429 seen.insert(key);
430 continue;
431 }
432 }
433 diff.stamps.push((key.clone(), stamp));
434 seen.insert(key);
435 }
436 }
437 let unlisted = &diff.not_downloaded_folders;
441 diff.deleted = stamps
442 .iter()
443 .map(|(key, _)| key)
444 .filter(|key| !seen.contains(*key))
445 .filter(|key| !unlisted.iter().any(|dir| key.starts_with(dir)))
446 .cloned()
447 .collect();
448 diff.created.sort_unstable();
449 diff.modified.sort_unstable();
450 diff.deleted.sort_unstable();
451 diff.evicted.sort_unstable();
452 diff.not_downloaded_folders.sort_unstable();
453 if !diff.not_downloaded_folders.is_empty() {
454 tracing::info!(
455 "{} folder(s) not downloaded yet; requested them",
456 diff.not_downloaded_folders.len()
457 );
458 }
459 diff
460 }
461
462 pub fn record_diff(&self, diff: &RepoDiff) {
465 {
466 let folders = self.not_downloaded_folders.pin();
467 folders.clear();
468 for key in &diff.not_downloaded_folders {
469 folders.insert(key.clone());
470 }
471 }
472 let Some(stamps) = &self.stamps else {
473 return;
474 };
475 let stamps = stamps.pin();
476 for (key, stamp) in &diff.stamps {
477 stamps.insert(key.clone(), *stamp);
478 }
479 for key in &diff.deleted {
480 stamps.remove(key);
481 }
482 }
483
484 fn listed_files(&self, vault: &dyn Vault) -> crate::vault::VaultWalk {
488 let ignored = |entry: &Entry| {
489 should_ignore_compiled(
490 &vault.key(&entry.path),
491 entry.is_dir(),
492 &self.ignore_dirs,
493 &self.compiled_ignore_globs,
494 &self.exempt_hidden_dirs,
495 )
496 };
497 let mut walk =
498 crate::vault::walk_vault_under(vault, &VaultPath::root(), |dir| !ignored(dir));
499 walk.files.retain(|entry| !ignored(entry));
500 walk
501 }
502
503 pub fn not_downloaded_folder_count(&self) -> usize {
507 self.not_downloaded_folders.len()
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use crate::vault::MemVault;
515 use std::sync::Arc;
516 use std::time::Duration;
517
518 fn repo(vault: Arc<MemVault>) -> Repo {
519 Repo::init_with_vault(
520 vault,
521 "static",
522 &["md".to_string()],
523 &["node_modules".to_string()],
524 &[],
525 "index.md",
526 &[crate::config::TagSource {
527 field: "tags".to_string(),
528 label: None,
529 label_plural: None,
530 }],
531 &[],
532 )
533 .with_change_tracking()
534 }
535
536 fn vault() -> Arc<MemVault> {
537 Arc::new(
538 MemVault::new()
539 .with_file("a.md", "---\ntitle: Alpha\ntags: [x]\n---\n# A\n")
540 .with_file("docs/b.md", "---\ntitle: Beta\n---\nSee [[Alpha]].\n")
541 .with_file("docs/index.md", "# Docs\n")
542 .with_file("static/img.png", "png")
543 .with_file("node_modules/skip.md", "# no")
544 .with_file(".hidden.md", "# no"),
545 )
546 }
547
548 fn scanned(vault: Arc<MemVault>) -> Repo {
549 let repo = repo(vault);
550 repo.scan_all().unwrap();
551 repo.scan_static_folder().unwrap();
552 repo.populate_basic_metadata();
553 repo
554 }
555
556 fn keys(diff: &[PathBuf], root: &Path) -> Vec<String> {
559 diff.iter()
560 .map(|k| crate::url_path::path_to_url(k.strip_prefix(root).unwrap()))
561 .collect()
562 }
563
564 #[test]
565 fn a_fresh_scan_diffs_clean() {
566 let vault = vault();
567 let repo = scanned(Arc::clone(&vault));
568 let diff = repo.diff_vault();
569 assert_eq!(diff, RepoDiff::default(), "{diff:?}");
570 assert!(diff.is_empty());
571 }
572
573 fn icloud_vault() -> Arc<MemVault> {
578 let vault = Arc::new(
579 MemVault::new()
580 .with_file("top.md", "# Top\n")
581 .with_file("trips/rome.md", "# Rome\n")
582 .with_file("trips/2024/oslo.md", "# Oslo\n")
583 .with_file("recipes/soup.md", "# Soup\n"),
584 );
585 vault.set_dir_availability("trips", Availability::NotDownloaded);
586 vault.set_dir_availability("recipes", Availability::NotDownloaded);
587 vault
588 }
589
590 fn markdown_keys(repo: &Repo, root: &Path) -> Vec<String> {
591 let mut keys: Vec<String> = repo
592 .markdown_files
593 .pin()
594 .keys()
595 .map(|k| crate::url_path::path_to_url(k.strip_prefix(root).unwrap()))
596 .collect();
597 keys.sort();
598 keys
599 }
600
601 #[test]
602 fn scan_requests_folders_whose_listing_is_not_downloaded() {
603 let vault = icloud_vault();
604 let repo = scanned(Arc::clone(&vault));
605 let root = vault.root().to_path_buf();
606 assert_eq!(markdown_keys(&repo, &root), ["top.md"]);
607 assert_eq!(repo.not_downloaded_folder_count(), 2);
608 let mut requested: Vec<String> = vault
609 .download_requests()
610 .iter()
611 .map(ToString::to_string)
612 .collect();
613 requested.sort();
614 assert_eq!(requested, ["recipes", "trips"]);
615 }
616
617 #[test]
618 fn refresh_indexes_a_folder_once_its_listing_arrives() {
619 let vault = icloud_vault();
620 let repo = scanned(Arc::clone(&vault));
621 let root = vault.root().to_path_buf();
622
623 let diff = repo.diff_vault();
626 assert!(diff.is_empty(), "{diff:?}");
627 assert_eq!(
628 keys(&diff.not_downloaded_folders, &root),
629 ["recipes", "trips"]
630 );
631 repo.record_diff(&diff);
632 assert_eq!(repo.not_downloaded_folder_count(), 2);
633
634 vault.set_dir_availability("trips", Availability::Local);
637 let diff = repo.diff_vault();
638 assert_eq!(
639 keys(&diff.created, &root),
640 ["trips/2024/oslo.md", "trips/rome.md"]
641 );
642 assert_eq!(keys(&diff.not_downloaded_folders, &root), ["recipes"]);
643 for event in diff.events(&root) {
644 repo.invalidate_file(Path::new(&event.path), &event.event);
645 }
646 repo.record_diff(&diff);
647 assert_eq!(repo.not_downloaded_folder_count(), 1);
648 assert_eq!(
649 markdown_keys(&repo, &root),
650 ["top.md", "trips/2024/oslo.md", "trips/rome.md"]
651 );
652 }
653
654 #[test]
655 fn a_folder_whose_listing_goes_away_keeps_its_files() {
656 let vault = vault();
657 let repo = scanned(Arc::clone(&vault));
658 let root = vault.root().to_path_buf();
659 vault.set_dir_availability("docs", Availability::NotDownloaded);
660 let diff = repo.diff_vault();
661 assert!(diff.deleted.is_empty(), "{diff:?}");
662 assert!(diff.is_empty());
663 assert_eq!(keys(&diff.not_downloaded_folders, &root), ["docs"]);
664 }
665
666 #[test]
667 fn restore_reproduces_site_json_byte_for_byte() {
668 let vault = vault();
669 let original = scanned(Arc::clone(&vault));
670 let bytes = original.snapshot().to_bytes();
671 let restored = repo(Arc::clone(&vault));
672 let count = restored
673 .restore_snapshot(RepoSnapshot::from_bytes(&bytes).unwrap())
674 .unwrap();
675 assert_eq!(count, 4, "3 notes + 1 asset");
676 assert_eq!(restored.to_json().unwrap(), original.to_json().unwrap());
677 assert_eq!(
678 restored.tag_index.get_pages("tags", "x").len(),
679 original.tag_index.get_pages("tags", "x").len()
680 );
681 assert!(restored.diff_vault().is_empty());
682 }
683
684 #[test]
685 fn diff_reports_created_modified_deleted_and_records_them() {
686 let vault = vault();
687 let repo = scanned(Arc::clone(&vault));
688 let root = vault.root().to_path_buf();
689 vault.insert_file("new.md", "# New");
690 vault.insert_file("a.md", "---\ntitle: Alpha 2\n---\n");
691 vault.set_modified(
692 "a.md",
693 SystemTime::UNIX_EPOCH + Duration::from_secs(2_000_000_000),
694 );
695 vault
696 .remove_file(&VaultPath::new("docs/b.md").unwrap())
697 .unwrap();
698 let diff = repo.diff_vault();
699 assert_eq!(keys(&diff.created, &root), ["new.md"]);
700 assert_eq!(keys(&diff.modified, &root), ["a.md"]);
701 assert_eq!(keys(&diff.deleted, &root), ["docs/b.md"]);
702 let events = diff.events(&root);
703 assert_eq!(events.len(), 3);
704 assert!(
705 events
706 .iter()
707 .any(|e| e.relative_path == "docs/b.md" && e.event == ChangeEventType::Deleted)
708 );
709 for event in &events {
710 repo.invalidate_file(Path::new(&event.path), &event.event);
711 }
712 repo.record_diff(&diff);
713 assert!(
714 repo.diff_vault().is_empty(),
715 "applied changes are not re-reported"
716 );
717 assert!(repo.markdown_files.pin().contains_key(&root.join("new.md")));
718 assert!(
719 !repo
720 .markdown_files
721 .pin()
722 .contains_key(&root.join("docs/b.md"))
723 );
724 }
725
726 #[test]
727 fn eviction_keeps_frontmatter_and_download_triggers_a_reread() {
728 let vault = vault();
729 let repo = scanned(Arc::clone(&vault));
730 let root = vault.root().to_path_buf();
731 vault.set_availability("a.md", Availability::NotDownloaded);
732 let diff = repo.diff_vault();
733 assert!(diff.is_empty(), "evictions change no content");
734 assert_eq!(keys(&diff.evicted, &root), ["a.md"]);
735 repo.record_diff(&diff);
736 let snapshot_json = String::from_utf8(repo.snapshot().to_bytes()).unwrap();
738 assert!(snapshot_json.contains("Alpha"));
739 assert!(snapshot_json.contains("\"not_downloaded\":true"));
740
741 vault.set_availability("a.md", Availability::Local);
742 let diff = repo.diff_vault();
743 assert_eq!(keys(&diff.modified, &root), ["a.md"], "downloaded again");
744 }
745
746 #[test]
747 fn a_not_downloaded_note_scans_with_its_stamp_and_rereads_once_local() {
748 let vault = vault();
749 vault.set_availability("docs/b.md", Availability::NotDownloaded);
750 let repo = scanned(Arc::clone(&vault));
751 let root = vault.root().to_path_buf();
752 let info = repo
753 .markdown_files
754 .pin()
755 .get(&root.join("docs/b.md"))
756 .cloned()
757 .expect("listed although not downloaded");
758 assert!(info.frontmatter.is_none());
759 assert!(repo.diff_vault().is_empty());
760 vault.set_availability("docs/b.md", Availability::Local);
761 let diff = repo.diff_vault();
762 assert_eq!(keys(&diff.modified, &root), ["docs/b.md"]);
763 for event in diff.events(&root) {
764 repo.invalidate_file(Path::new(&event.path), &event.event);
765 }
766 let info = repo
767 .markdown_files
768 .pin()
769 .get(&root.join("docs/b.md"))
770 .cloned()
771 .unwrap();
772 assert!(info.frontmatter.is_some());
773 }
774
775 #[test]
776 fn snapshots_of_another_format_version_or_config_are_refused() {
777 let vault = vault();
778 let original = scanned(Arc::clone(&vault));
779 let mut snapshot = original.snapshot();
780 assert!(matches!(
781 RepoSnapshot::from_bytes(b"not json"),
782 Err(SnapshotError::Corrupt(_))
783 ));
784 assert!(matches!(
785 RepoSnapshot::from_bytes(br#"{"format":999}"#),
786 Err(SnapshotError::WrongFormat { found: 999 })
787 ));
788 assert!(matches!(
789 RepoSnapshot::from_bytes(br#"{"format":1,"markdown":7}"#),
790 Err(SnapshotError::Corrupt(_))
791 ));
792 let other_config = Repo::init_with_vault(
794 Arc::clone(&vault) as Arc<dyn Vault>,
795 "static",
796 &["md".to_string(), "markdown".to_string()],
797 &[],
798 &[],
799 "index.md",
800 &[],
801 &[],
802 );
803 assert_eq!(
804 other_config.restore_snapshot(snapshot.clone()),
805 Err(SnapshotError::WrongFingerprint)
806 );
807 snapshot.fingerprint = "tampered".into();
808 assert_eq!(
809 repo(vault).restore_snapshot(snapshot),
810 Err(SnapshotError::WrongFingerprint)
811 );
812 }
813
814 #[test]
815 fn applied_changes_restamp_without_a_diff() {
816 let vault = vault();
819 let repo = scanned(Arc::clone(&vault));
820 let root = vault.root().to_path_buf();
821 vault.insert_file("a.md", "---\ntitle: Written by mbr\n---\n");
822 vault.set_modified(
823 "a.md",
824 SystemTime::UNIX_EPOCH + Duration::from_secs(2_100_000_000),
825 );
826 repo.invalidate_file(&root.join("a.md"), &ChangeEventType::Modified);
827 assert!(repo.diff_vault().is_empty());
828 vault.remove_file(&VaultPath::new("a.md").unwrap()).unwrap();
830 repo.invalidate_file(&root.join("a.md"), &ChangeEventType::Deleted);
831 assert!(repo.diff_vault().is_empty());
832 vault.insert_file(".hidden2.md", "x");
834 repo.invalidate_file(&root.join(".hidden2.md"), &ChangeEventType::Created);
835 assert!(repo.diff_vault().is_empty());
836 }
837
838 #[test]
839 fn untracked_repositories_record_nothing() {
840 let vault = vault();
841 let untracked = Repo::init_with_vault(
842 vault as Arc<dyn Vault>,
843 "static",
844 &["md".to_string()],
845 &[],
846 &[],
847 "index.md",
848 &[],
849 &[],
850 );
851 untracked.scan_all().unwrap();
852 assert!(!untracked.tracks_changes());
853 assert!(untracked.snapshot().is_empty());
854 assert!(!untracked.diff_vault().created.is_empty());
855 }
856}