use std::collections::HashSet as StdHashSet;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::{
MarkdownInfo, OtherFileInfo, Repo, StaticFileMetadata, build_markdown_url_path,
is_markdown_extension, should_ignore_compiled,
};
use crate::change_event::{ChangeEventType, FileChangeEvent};
use crate::markdown::SimpleMetadata;
use crate::relationships::RawRelationship;
use crate::vault::{Availability, Entry, Vault, VaultPath};
pub const SNAPSHOT_FORMAT: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileStamp {
pub size: u64,
pub modified_ns: u64,
pub not_downloaded: bool,
}
impl FileStamp {
pub fn of(entry: &Entry) -> Self {
let modified_ns = entry
.modified
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX));
Self {
size: entry.size,
modified_ns,
not_downloaded: entry.availability == Availability::NotDownloaded,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RepoDiff {
pub created: Vec<PathBuf>,
pub modified: Vec<PathBuf>,
pub deleted: Vec<PathBuf>,
pub evicted: Vec<PathBuf>,
pub not_downloaded_folders: Vec<PathBuf>,
stamps: Vec<(PathBuf, FileStamp)>,
}
impl RepoDiff {
pub fn is_empty(&self) -> bool {
self.created.is_empty() && self.modified.is_empty() && self.deleted.is_empty()
}
pub fn events(&self, root: &Path) -> Vec<FileChangeEvent> {
let event = |key: &PathBuf, kind: ChangeEventType| FileChangeEvent {
path: key.to_string_lossy().into_owned(),
relative_path: pathdiff::diff_paths(key, root)
.map(|p| crate::url_path::path_to_url(&p))
.unwrap_or_default(),
event: kind,
};
self.created
.iter()
.map(|k| event(k, ChangeEventType::Created))
.chain(
self.modified
.iter()
.map(|k| event(k, ChangeEventType::Modified)),
)
.chain(
self.deleted
.iter()
.map(|k| event(k, ChangeEventType::Deleted)),
)
.collect()
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum SnapshotError {
#[error("unreadable snapshot: {0}")]
Corrupt(String),
#[error("snapshot format {found}, this build reads {SNAPSHOT_FORMAT}")]
WrongFormat { found: u32 },
#[error("snapshot was taken by another mbr version or scan configuration")]
WrongFingerprint,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SnapshotNote {
path: String,
stamp: FileStamp,
created: u64,
modified: u64,
frontmatter: Option<SimpleMetadata>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
relationships: Vec<RawRelationship>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SnapshotAsset {
path: String,
stamp: FileStamp,
created: u64,
modified: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoSnapshot {
format: u32,
fingerprint: String,
markdown: Vec<SnapshotNote>,
other: Vec<SnapshotAsset>,
}
impl RepoSnapshot {
pub fn to_bytes(&self) -> Vec<u8> {
serde_json::to_vec(self).unwrap_or_default()
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, SnapshotError> {
#[derive(Deserialize)]
struct Header {
format: u32,
}
let header: Header =
serde_json::from_slice(bytes).map_err(|e| SnapshotError::Corrupt(e.to_string()))?;
if header.format != SNAPSHOT_FORMAT {
return Err(SnapshotError::WrongFormat {
found: header.format,
});
}
serde_json::from_slice(bytes).map_err(|e| SnapshotError::Corrupt(e.to_string()))
}
pub fn len(&self) -> usize {
self.markdown.len() + self.other.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
impl Repo {
#[must_use]
pub fn with_change_tracking(mut self) -> Self {
self.stamps = Some(std::sync::Arc::new(papaya::HashMap::new()));
self
}
pub fn tracks_changes(&self) -> bool {
self.stamps.is_some()
}
pub(super) fn record_stamp(&self, key: &Path, entry: &Entry) {
if let Some(stamps) = &self.stamps {
stamps.pin().insert(key.to_path_buf(), FileStamp::of(entry));
}
}
pub fn snapshot_fingerprint(&self) -> String {
let inputs = serde_json::json!({
"format": SNAPSHOT_FORMAT,
"mbr": env!("CARGO_PKG_VERSION"),
"markdown_extensions": self.markdown_extensions,
"ignore_dirs": self.ignore_dirs,
"ignore_globs": self.ignore_globs,
"index_file": self.index_file,
"static_folder": self.static_folder,
"exempt_hidden_dirs": self
.exempt_hidden_vault_paths
.iter()
.map(VaultPath::as_str)
.collect::<Vec<_>>(),
});
let digest = Sha256::digest(inputs.to_string().as_bytes());
digest.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn snapshot(&self) -> RepoSnapshot {
let empty = papaya::HashMap::new();
let stamps = self.stamps.as_deref().unwrap_or(&empty).pin();
let vault = self.vault.as_ref();
let in_vault = |key: &Path| {
let path = vault.vault_path(key).ok()?;
let stamp = *stamps.get(key)?;
Some((path.as_str().to_owned(), stamp))
};
let mut markdown: Vec<SnapshotNote> = self
.markdown_files
.pin()
.iter()
.filter_map(|(key, info)| {
let (path, stamp) = in_vault(key)?;
Some(SnapshotNote {
path,
stamp,
created: info.created,
modified: info.modified,
frontmatter: info.frontmatter.clone(),
relationships: info.relationships.clone(),
})
})
.collect();
markdown.sort_unstable_by(|a, b| a.path.cmp(&b.path));
let mut other: Vec<SnapshotAsset> = self
.other_files
.pin()
.iter()
.filter_map(|(key, info)| {
let (path, stamp) = in_vault(key)?;
Some(SnapshotAsset {
path,
stamp,
created: info.metadata.created.unwrap_or_default(),
modified: info.metadata.modified.unwrap_or_default(),
})
})
.collect();
other.sort_unstable_by(|a, b| a.path.cmp(&b.path));
RepoSnapshot {
format: SNAPSHOT_FORMAT,
fingerprint: self.snapshot_fingerprint(),
markdown,
other,
}
}
pub fn restore_snapshot(&self, snapshot: RepoSnapshot) -> Result<usize, SnapshotError> {
if snapshot.format != SNAPSHOT_FORMAT {
return Err(SnapshotError::WrongFormat {
found: snapshot.format,
});
}
if snapshot.fingerprint != self.snapshot_fingerprint() {
return Err(SnapshotError::WrongFingerprint);
}
let vault = self.vault.as_ref();
let stamps = self.stamps.as_ref().map(|s| s.pin());
let key_of = |path: &str| VaultPath::new(path).ok().map(|p| vault.key(&p));
let markdown = self.markdown_files.pin();
let mut restored = 0;
for note in snapshot.markdown {
let Some(key) = key_of(¬e.path) else {
continue;
};
let info = MarkdownInfo {
raw_path: self.relative_to_root(&key),
url_path: build_markdown_url_path(&key, &self.canonical_root, &self.index_file),
created: note.created,
modified: note.modified,
frontmatter: note.frontmatter,
relationships: note.relationships,
};
if let Some(stamps) = &stamps {
stamps.insert(key.clone(), note.stamp);
}
markdown.insert(key, info);
restored += 1;
}
drop(markdown);
let other = self.other_files.pin();
for asset in snapshot.other {
let Some(key) = key_of(&asset.path) else {
continue;
};
let info = OtherFileInfo {
raw_path: key.clone(),
url_path: self.static_url_for_key(&key),
metadata: StaticFileMetadata::empty(&key).with_details(Some((
asset.stamp.size,
asset.created,
asset.modified,
))),
extracted_text: None,
};
if let Some(stamps) = &stamps {
stamps.insert(key.clone(), asset.stamp);
}
other.insert(key, info);
restored += 1;
}
drop(other);
self.rebuild_tag_index();
Ok(restored)
}
pub fn diff_vault(&self) -> RepoDiff {
let empty = papaya::HashMap::new();
let stamps = self.stamps.as_deref().unwrap_or(&empty).pin();
let mut diff = RepoDiff::default();
let mut seen: StdHashSet<PathBuf> = StdHashSet::new();
for (vault, overlay) in std::iter::once((&self.vault, false))
.chain(self.static_vault.as_ref().map(|v| (v, true)))
{
let walk = self.listed_files(vault.as_ref());
tracing::info!(
"Refresh listed {} folders, {} files under {}",
walk.dirs_listed,
walk.files.len(),
vault.root().display()
);
if !walk.not_downloaded_dirs.is_empty() {
vault.request_download(&walk.not_downloaded_dirs);
diff.not_downloaded_folders
.extend(walk.not_downloaded_dirs.iter().map(|d| vault.key(d)));
}
for entry in walk.files {
let key = vault.key(&entry.path);
if overlay
&& is_markdown_extension(
entry.path.extension().unwrap_or(""),
&self.markdown_extensions,
)
{
continue;
}
let stamp = FileStamp::of(&entry);
match stamps.get(&key) {
None => diff.created.push(key.clone()),
Some(old) if old.size != stamp.size || old.modified_ns != stamp.modified_ns => {
diff.modified.push(key.clone());
}
Some(old) if old.not_downloaded && !stamp.not_downloaded => {
diff.modified.push(key.clone());
}
Some(old) if !old.not_downloaded && stamp.not_downloaded => {
diff.evicted.push(key.clone());
}
Some(_) => {
seen.insert(key);
continue;
}
}
diff.stamps.push((key.clone(), stamp));
seen.insert(key);
}
}
let unlisted = &diff.not_downloaded_folders;
diff.deleted = stamps
.iter()
.map(|(key, _)| key)
.filter(|key| !seen.contains(*key))
.filter(|key| !unlisted.iter().any(|dir| key.starts_with(dir)))
.cloned()
.collect();
diff.created.sort_unstable();
diff.modified.sort_unstable();
diff.deleted.sort_unstable();
diff.evicted.sort_unstable();
diff.not_downloaded_folders.sort_unstable();
if !diff.not_downloaded_folders.is_empty() {
tracing::info!(
"{} folder(s) not downloaded yet; requested them",
diff.not_downloaded_folders.len()
);
}
diff
}
pub fn record_diff(&self, diff: &RepoDiff) {
{
let folders = self.not_downloaded_folders.pin();
folders.clear();
for key in &diff.not_downloaded_folders {
folders.insert(key.clone());
}
}
let Some(stamps) = &self.stamps else {
return;
};
let stamps = stamps.pin();
for (key, stamp) in &diff.stamps {
stamps.insert(key.clone(), *stamp);
}
for key in &diff.deleted {
stamps.remove(key);
}
}
fn listed_files(&self, vault: &dyn Vault) -> crate::vault::VaultWalk {
let ignored = |entry: &Entry| {
should_ignore_compiled(
&vault.key(&entry.path),
entry.is_dir(),
&self.ignore_dirs,
&self.compiled_ignore_globs,
&self.exempt_hidden_dirs,
)
};
let mut walk =
crate::vault::walk_vault_under(vault, &VaultPath::root(), |dir| !ignored(dir));
walk.files.retain(|entry| !ignored(entry));
walk
}
pub fn not_downloaded_folder_count(&self) -> usize {
self.not_downloaded_folders.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::MemVault;
use std::sync::Arc;
use std::time::Duration;
fn repo(vault: Arc<MemVault>) -> Repo {
Repo::init_with_vault(
vault,
"static",
&["md".to_string()],
&["node_modules".to_string()],
&[],
"index.md",
&[crate::config::TagSource {
field: "tags".to_string(),
label: None,
label_plural: None,
}],
&[],
)
.with_change_tracking()
}
fn vault() -> Arc<MemVault> {
Arc::new(
MemVault::new()
.with_file("a.md", "---\ntitle: Alpha\ntags: [x]\n---\n# A\n")
.with_file("docs/b.md", "---\ntitle: Beta\n---\nSee [[Alpha]].\n")
.with_file("docs/index.md", "# Docs\n")
.with_file("static/img.png", "png")
.with_file("node_modules/skip.md", "# no")
.with_file(".hidden.md", "# no"),
)
}
fn scanned(vault: Arc<MemVault>) -> Repo {
let repo = repo(vault);
repo.scan_all().unwrap();
repo.scan_static_folder().unwrap();
repo.populate_basic_metadata();
repo
}
fn keys(diff: &[PathBuf], root: &Path) -> Vec<String> {
diff.iter()
.map(|k| crate::url_path::path_to_url(k.strip_prefix(root).unwrap()))
.collect()
}
#[test]
fn a_fresh_scan_diffs_clean() {
let vault = vault();
let repo = scanned(Arc::clone(&vault));
let diff = repo.diff_vault();
assert_eq!(diff, RepoDiff::default(), "{diff:?}");
assert!(diff.is_empty());
}
fn icloud_vault() -> Arc<MemVault> {
let vault = Arc::new(
MemVault::new()
.with_file("top.md", "# Top\n")
.with_file("trips/rome.md", "# Rome\n")
.with_file("trips/2024/oslo.md", "# Oslo\n")
.with_file("recipes/soup.md", "# Soup\n"),
);
vault.set_dir_availability("trips", Availability::NotDownloaded);
vault.set_dir_availability("recipes", Availability::NotDownloaded);
vault
}
fn markdown_keys(repo: &Repo, root: &Path) -> Vec<String> {
let mut keys: Vec<String> = repo
.markdown_files
.pin()
.keys()
.map(|k| crate::url_path::path_to_url(k.strip_prefix(root).unwrap()))
.collect();
keys.sort();
keys
}
#[test]
fn scan_requests_folders_whose_listing_is_not_downloaded() {
let vault = icloud_vault();
let repo = scanned(Arc::clone(&vault));
let root = vault.root().to_path_buf();
assert_eq!(markdown_keys(&repo, &root), ["top.md"]);
assert_eq!(repo.not_downloaded_folder_count(), 2);
let mut requested: Vec<String> = vault
.download_requests()
.iter()
.map(ToString::to_string)
.collect();
requested.sort();
assert_eq!(requested, ["recipes", "trips"]);
}
#[test]
fn refresh_indexes_a_folder_once_its_listing_arrives() {
let vault = icloud_vault();
let repo = scanned(Arc::clone(&vault));
let root = vault.root().to_path_buf();
let diff = repo.diff_vault();
assert!(diff.is_empty(), "{diff:?}");
assert_eq!(
keys(&diff.not_downloaded_folders, &root),
["recipes", "trips"]
);
repo.record_diff(&diff);
assert_eq!(repo.not_downloaded_folder_count(), 2);
vault.set_dir_availability("trips", Availability::Local);
let diff = repo.diff_vault();
assert_eq!(
keys(&diff.created, &root),
["trips/2024/oslo.md", "trips/rome.md"]
);
assert_eq!(keys(&diff.not_downloaded_folders, &root), ["recipes"]);
for event in diff.events(&root) {
repo.invalidate_file(Path::new(&event.path), &event.event);
}
repo.record_diff(&diff);
assert_eq!(repo.not_downloaded_folder_count(), 1);
assert_eq!(
markdown_keys(&repo, &root),
["top.md", "trips/2024/oslo.md", "trips/rome.md"]
);
}
#[test]
fn a_folder_whose_listing_goes_away_keeps_its_files() {
let vault = vault();
let repo = scanned(Arc::clone(&vault));
let root = vault.root().to_path_buf();
vault.set_dir_availability("docs", Availability::NotDownloaded);
let diff = repo.diff_vault();
assert!(diff.deleted.is_empty(), "{diff:?}");
assert!(diff.is_empty());
assert_eq!(keys(&diff.not_downloaded_folders, &root), ["docs"]);
}
#[test]
fn restore_reproduces_site_json_byte_for_byte() {
let vault = vault();
let original = scanned(Arc::clone(&vault));
let bytes = original.snapshot().to_bytes();
let restored = repo(Arc::clone(&vault));
let count = restored
.restore_snapshot(RepoSnapshot::from_bytes(&bytes).unwrap())
.unwrap();
assert_eq!(count, 4, "3 notes + 1 asset");
assert_eq!(restored.to_json().unwrap(), original.to_json().unwrap());
assert_eq!(
restored.tag_index.get_pages("tags", "x").len(),
original.tag_index.get_pages("tags", "x").len()
);
assert!(restored.diff_vault().is_empty());
}
#[test]
fn diff_reports_created_modified_deleted_and_records_them() {
let vault = vault();
let repo = scanned(Arc::clone(&vault));
let root = vault.root().to_path_buf();
vault.insert_file("new.md", "# New");
vault.insert_file("a.md", "---\ntitle: Alpha 2\n---\n");
vault.set_modified(
"a.md",
SystemTime::UNIX_EPOCH + Duration::from_secs(2_000_000_000),
);
vault
.remove_file(&VaultPath::new("docs/b.md").unwrap())
.unwrap();
let diff = repo.diff_vault();
assert_eq!(keys(&diff.created, &root), ["new.md"]);
assert_eq!(keys(&diff.modified, &root), ["a.md"]);
assert_eq!(keys(&diff.deleted, &root), ["docs/b.md"]);
let events = diff.events(&root);
assert_eq!(events.len(), 3);
assert!(
events
.iter()
.any(|e| e.relative_path == "docs/b.md" && e.event == ChangeEventType::Deleted)
);
for event in &events {
repo.invalidate_file(Path::new(&event.path), &event.event);
}
repo.record_diff(&diff);
assert!(
repo.diff_vault().is_empty(),
"applied changes are not re-reported"
);
assert!(repo.markdown_files.pin().contains_key(&root.join("new.md")));
assert!(
!repo
.markdown_files
.pin()
.contains_key(&root.join("docs/b.md"))
);
}
#[test]
fn eviction_keeps_frontmatter_and_download_triggers_a_reread() {
let vault = vault();
let repo = scanned(Arc::clone(&vault));
let root = vault.root().to_path_buf();
vault.set_availability("a.md", Availability::NotDownloaded);
let diff = repo.diff_vault();
assert!(diff.is_empty(), "evictions change no content");
assert_eq!(keys(&diff.evicted, &root), ["a.md"]);
repo.record_diff(&diff);
let snapshot_json = String::from_utf8(repo.snapshot().to_bytes()).unwrap();
assert!(snapshot_json.contains("Alpha"));
assert!(snapshot_json.contains("\"not_downloaded\":true"));
vault.set_availability("a.md", Availability::Local);
let diff = repo.diff_vault();
assert_eq!(keys(&diff.modified, &root), ["a.md"], "downloaded again");
}
#[test]
fn a_not_downloaded_note_scans_with_its_stamp_and_rereads_once_local() {
let vault = vault();
vault.set_availability("docs/b.md", Availability::NotDownloaded);
let repo = scanned(Arc::clone(&vault));
let root = vault.root().to_path_buf();
let info = repo
.markdown_files
.pin()
.get(&root.join("docs/b.md"))
.cloned()
.expect("listed although not downloaded");
assert!(info.frontmatter.is_none());
assert!(repo.diff_vault().is_empty());
vault.set_availability("docs/b.md", Availability::Local);
let diff = repo.diff_vault();
assert_eq!(keys(&diff.modified, &root), ["docs/b.md"]);
for event in diff.events(&root) {
repo.invalidate_file(Path::new(&event.path), &event.event);
}
let info = repo
.markdown_files
.pin()
.get(&root.join("docs/b.md"))
.cloned()
.unwrap();
assert!(info.frontmatter.is_some());
}
#[test]
fn snapshots_of_another_format_version_or_config_are_refused() {
let vault = vault();
let original = scanned(Arc::clone(&vault));
let mut snapshot = original.snapshot();
assert!(matches!(
RepoSnapshot::from_bytes(b"not json"),
Err(SnapshotError::Corrupt(_))
));
assert!(matches!(
RepoSnapshot::from_bytes(br#"{"format":999}"#),
Err(SnapshotError::WrongFormat { found: 999 })
));
assert!(matches!(
RepoSnapshot::from_bytes(br#"{"format":1,"markdown":7}"#),
Err(SnapshotError::Corrupt(_))
));
let other_config = Repo::init_with_vault(
Arc::clone(&vault) as Arc<dyn Vault>,
"static",
&["md".to_string(), "markdown".to_string()],
&[],
&[],
"index.md",
&[],
&[],
);
assert_eq!(
other_config.restore_snapshot(snapshot.clone()),
Err(SnapshotError::WrongFingerprint)
);
snapshot.fingerprint = "tampered".into();
assert_eq!(
repo(vault).restore_snapshot(snapshot),
Err(SnapshotError::WrongFingerprint)
);
}
#[test]
fn applied_changes_restamp_without_a_diff() {
let vault = vault();
let repo = scanned(Arc::clone(&vault));
let root = vault.root().to_path_buf();
vault.insert_file("a.md", "---\ntitle: Written by mbr\n---\n");
vault.set_modified(
"a.md",
SystemTime::UNIX_EPOCH + Duration::from_secs(2_100_000_000),
);
repo.invalidate_file(&root.join("a.md"), &ChangeEventType::Modified);
assert!(repo.diff_vault().is_empty());
vault.remove_file(&VaultPath::new("a.md").unwrap()).unwrap();
repo.invalidate_file(&root.join("a.md"), &ChangeEventType::Deleted);
assert!(repo.diff_vault().is_empty());
vault.insert_file(".hidden2.md", "x");
repo.invalidate_file(&root.join(".hidden2.md"), &ChangeEventType::Created);
assert!(repo.diff_vault().is_empty());
}
#[test]
fn untracked_repositories_record_nothing() {
let vault = vault();
let untracked = Repo::init_with_vault(
vault as Arc<dyn Vault>,
"static",
&["md".to_string()],
&[],
&[],
"index.md",
&[],
&[],
);
untracked.scan_all().unwrap();
assert!(!untracked.tracks_changes());
assert!(untracked.snapshot().is_empty());
assert!(!untracked.diff_vault().created.is_empty());
}
}