Skip to main content

mbr_core/repo/
snapshot.rs

1//! Change tracking, snapshots and the stat-diff of a [`Repo`] against its
2//! vault.
3//!
4//! Three pieces, all opt-in and all storage-agnostic (no file I/O here):
5//!
6//! - **Stamps.** [`Repo::with_change_tracking`] makes the scan record a
7//!   [`FileStamp`] (size, modification time, availability) for every file it
8//!   indexes. Off by default: the desktop has a watcher and never pays for the
9//!   table.
10//! - **Diff.** [`Repo::diff_vault`] re-lists the vault, applying the scan's own
11//!   ignore rules, and compares each file's stamp with the recorded one. It
12//!   reads no file contents — a listing already carries every stamp — so it is
13//!   the cheap "what changed while we were not looking" pass a platform without
14//!   change events (iOS) runs on foreground. The result is plain
15//!   [`FileChangeEvent`]s, applied through the same invalidation path the
16//!   watcher's events take.
17//! - **Snapshot.** [`Repo::snapshot`] captures what the scan computed that is
18//!   expensive to recompute — above all the frontmatter, which costs a read of
19//!   every note — and [`Repo::restore_snapshot`] puts it back without touching
20//!   storage. A restored repository answers `site.json`, navigation and
21//!   metadata search at once; a diff then brings it up to date.
22//!
23//! Where a snapshot is *kept* is the caller's business. mbr never writes one on
24//! the desktop (no cache files, by policy); the iOS app keeps it in its own
25//! sandboxed `Caches/` directory (`mbr_ffi::mobile`).
26
27use 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
43/// The on-disk shape of a [`RepoSnapshot`]. Bump on any change to the
44/// serialized types; a snapshot of another format is ignored, never migrated.
45pub const SNAPSHOT_FORMAT: u32 = 1;
46
47/// What a listing says about a file, compared across scans to decide whether
48/// it changed. Nanosecond modification times, so two edits within one second
49/// are still told apart (the index's own `modified` is whole seconds).
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51pub struct FileStamp {
52    pub size: u64,
53    /// Nanoseconds since the Unix epoch (0 before it or when unknown).
54    pub modified_ns: u64,
55    /// The bytes were not on the device when this was recorded.
56    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/// What [`Repo::diff_vault`] found, as index keys.
74///
75/// `evicted` files were downloaded when last seen and are not any more, with
76/// the same size and time: their content did not change, so the index keeps
77/// what it knew (their frontmatter included) and only the stamp moves. That is
78/// the distinction Logseq famously missed — an evicted file is not a deleted
79/// one.
80#[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    /// Folders the listing could not enter because a file provider has not
87    /// fetched their contents yet (iCloud Drive on iOS), by canonical key.
88    /// Their download was requested; their files are not in `deleted` — they
89    /// were never listed — and turn up as `created` in the first diff that
90    /// lists them. Not a change: [`RepoDiff::is_empty`] ignores it.
91    pub not_downloaded_folders: Vec<PathBuf>,
92    /// The stamps the listing produced for every created, modified and
93    /// evicted file; [`Repo::record_diff`] stores them.
94    stamps: Vec<(PathBuf, FileStamp)>,
95}
96
97impl RepoDiff {
98    /// Nothing to invalidate (evictions alone change no content).
99    pub fn is_empty(&self) -> bool {
100        self.created.is_empty() && self.modified.is_empty() && self.deleted.is_empty()
101    }
102
103    /// The created, modified and deleted files as the change events the
104    /// watcher would have sent, relative paths against `root`.
105    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/// Why a snapshot was not restored. Never fatal: the caller scans instead.
131#[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/// One note as the scan left it.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143struct SnapshotNote {
144    /// Vault path (`/`-separated, root-relative).
145    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/// One non-markdown file: just enough to list it and serve its basic metadata.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156struct SnapshotAsset {
157    path: String,
158    stamp: FileStamp,
159    created: u64,
160    modified: u64,
161}
162
163/// A serializable picture of a scanned repository. See the module docs.
164#[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    /// JSON, because frontmatter is arbitrary `serde_json::Value`s, which only
174    /// a self-describing format can read back.
175    pub fn to_bytes(&self) -> Vec<u8> {
176        serde_json::to_vec(self).unwrap_or_default()
177    }
178
179    /// Parses a snapshot, refusing other formats. The fingerprint is checked
180    /// against the repository by [`Repo::restore_snapshot`].
181    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    /// Notes plus other files.
197    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    /// Records a [`FileStamp`] for every file the scan indexes, which is what
208    /// [`Self::diff_vault`] and [`Self::snapshot`] compare against. Call
209    /// before scanning.
210    #[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    /// Whether [`Self::with_change_tracking`] was called.
217    pub fn tracks_changes(&self) -> bool {
218        self.stamps.is_some()
219    }
220
221    /// Records `entry`'s stamp under `key`, when tracking.
222    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    /// Identifies everything a snapshot's contents depend on besides the files
229    /// themselves: this build (the frontmatter simplifier changes between
230    /// versions) and the scan configuration (which files are notes, which are
231    /// ignored, how URLs are formed).
232    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    /// The repository's current contents as a [`RepoSnapshot`]: every indexed
252    /// file of the root vault that has a stamp (so only with change tracking).
253    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    /// Fills an empty repository from `snapshot` without reading storage, and
303    /// rebuilds the tag index from the restored frontmatter. Returns the
304    /// number of files restored.
305    ///
306    /// The caller then does what it does after a scan — build the
307    /// relationship and wikilink indexes, mark the scan complete — and runs a
308    /// [`Self::diff_vault`] to catch what changed since the snapshot. Entries
309    /// that no longer parse as vault paths are dropped (the diff re-adds
310    /// them if they exist). Stamps are restored with them, so with change
311    /// tracking the diff compares against the snapshot's view.
312    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(&note.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    /// Re-lists the vault (and an external static overlay) with the scan's
372    /// ignore rules and compares every file with its recorded stamp. Reads no
373    /// file contents. Blocking.
374    ///
375    /// A folder whose listing is not downloaded (see
376    /// [`RepoDiff::not_downloaded_folders`]) is requested from the vault on
377    /// every diff until it lists; the files the index already has under it
378    /// are kept, not deleted.
379    ///
380    /// Without change tracking every listed file is "created" — the diff of a
381    /// repository that recorded nothing.
382    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                // Asked for again on every diff until they are listed: a
399                // provider may drop a request, and asking is idempotent.
400                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                // Markdown in an external overlay has no URL; the scan skips it.
407                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                    // Downloaded since: the index may lack its frontmatter.
422                    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        // A file under a folder that could not be listed was not seen, but it
438        // is not gone either: the provider took the folder's listing back
439        // (or never had it). Keep what the index knows until it lists again.
440        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    /// Stores the stamps of a diff once its events have been applied, so the
463    /// same changes are not reported again. A no-op without change tracking.
464    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    /// Every file of `vault` the scan would index, by the scan's own rules:
485    /// hidden entries, `ignore_dirs` and `ignore_globs` are skipped, judged on
486    /// the same keys the scan builds.
487    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    /// How many folders could not be listed because their contents are not
504    /// downloaded yet, as of the last scan or [`Repo::record_diff`]. Their
505    /// download has been requested; a later [`Repo::diff_vault`] lists them.
506    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    /// Root-relative and `/`-separated on every platform, as the vault paths
557    /// the assertions spell them.
558    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    /// The iOS iCloud Drive report: a folder copied into iCloud lists its
574    /// subfolders, but each one's own listing is not on the device, so the
575    /// scan found no notes in any of them — and a refresh never asked for
576    /// them, so they never appeared.
577    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        // Still not listed: nothing changed, but the folders are reported and
624        // requested again.
625        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        // The provider lists `trips` (one level only: `trips/2024` was never
635        // marked, so it lists too) and the next refresh picks it up.
636        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        // Still titled from the frontmatter read while it was local.
737        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        // A different scan configuration.
793        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        // The server's own write reaches the index as a Modified event; the
817        // next diff must not report it again.
818        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        // And a delete drops the stamp, so it is not "deleted" again either.
829        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        // Ignored or hidden paths never gain one.
833        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}