use serde::{Deserialize, Serialize}; use std::fs; use std::path::Path; use super::{parse_md_file, scan_vault, VaultEntry}; // --- Vault Cache --- /// Bump this when VaultEntry fields change to force a full rescan. const CACHE_VERSION: u32 = 5; #[derive(Debug, Serialize, Deserialize)] struct VaultCache { #[serde(default = "default_cache_version")] version: u32, /// The vault path when the cache was written. Used to detect stale caches /// from a different machine or a moved vault directory. #[serde(default)] vault_path: String, commit_hash: String, entries: Vec, } fn default_cache_version() -> u32 { 1 } fn cache_path(vault: &Path) -> std::path::PathBuf { vault.join(".laputa-cache.json") } fn git_head_hash(vault: &Path) -> Option { run_git(vault, &["rev-parse", "HEAD"]).map(|s| s.trim().to_string()) } /// Run a git command in the given directory and return stdout if successful. fn run_git(vault: &Path, args: &[&str]) -> Option { let output = std::process::Command::new("git") .args(args) .current_dir(vault) .output() .ok()?; if !output.status.success() { return None; } Some(String::from_utf8_lossy(&output.stdout).to_string()) } /// Parse a git status porcelain line into (status_code, file_path). fn parse_porcelain_line(line: &str) -> Option<(&str, String)> { if line.len() < 3 { return None; } Some((&line[..2], line[3..].trim().to_string())) } /// Extract .md file paths from git diff --name-only output. fn collect_md_paths_from_diff(stdout: &str) -> Vec { stdout .lines() .filter(|line| !line.is_empty() && line.ends_with(".md")) .map(|line| line.to_string()) .collect() } /// Extract .md file paths from git status --porcelain output. fn collect_md_paths_from_porcelain(stdout: &str) -> Vec { stdout .lines() .filter_map(parse_porcelain_line) .filter(|(_, path)| path.ends_with(".md")) .map(|(_, path)| path) .collect() } fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec { let diff_arg = format!("{}..{}", from_hash, to_hash); let mut files = run_git(vault, &["diff", &diff_arg, "--name-only"]) .map(|s| collect_md_paths_from_diff(&s)) .unwrap_or_default(); // Include uncommitted changes (modified, staged, and untracked files). let uncommitted = git_uncommitted_files(vault); for path in uncommitted.into_iter() { if !files.contains(&path) { files.push(path); } } files } fn git_uncommitted_files(vault: &Path) -> Vec { // Modified/staged tracked files from git status --porcelain let mut files: Vec = run_git(vault, &["status", "--porcelain"]) .map(|s| collect_md_paths_from_porcelain(&s)) .unwrap_or_default(); // Untracked files via ls-files (lists individual files, not just directories). // git status --porcelain shows `?? dir/` for new directories, hiding individual // files inside — ls-files resolves them so the cache picks up all new .md files. let untracked = run_git(vault, &["ls-files", "--others", "--exclude-standard"]) .map(|s| { s.lines() .filter(|l| !l.is_empty() && l.ends_with(".md")) .map(|l| l.to_string()) .collect::>() }) .unwrap_or_default(); for path in untracked { if !files.contains(&path) { files.push(path); } } files } fn load_cache(vault: &Path) -> Option { let data = fs::read_to_string(cache_path(vault)).ok()?; serde_json::from_str(&data).ok() } fn write_cache(vault: &Path, cache: &VaultCache) { if let Ok(data) = serde_json::to_string(cache) { let _ = fs::write(cache_path(vault), data); } ensure_cache_excluded(vault); } /// Normalize an absolute path to a relative path for comparison with git output. fn to_relative_path(abs_path: &str, vault: &Path) -> String { let vault_str = vault.to_string_lossy(); let with_slash = format!("{}/", vault_str); abs_path .strip_prefix(&with_slash) .or_else(|| abs_path.strip_prefix(vault_str.as_ref())) .unwrap_or(abs_path) .to_string() } /// Parse .md files from a list of relative paths, skipping any that don't exist. fn parse_files_at(vault: &Path, rel_paths: &[String]) -> Vec { rel_paths .iter() .filter_map(|rel| { let abs = vault.join(rel); if abs.is_file() { parse_md_file(&abs).ok() } else { None } }) .collect() } /// Machine-local files that should never be git-tracked in any vault. /// These are either caches with absolute paths or per-machine settings. const UNTRACKED_FILES: &[&str] = &[".laputa-cache.json", ".laputa/settings.json"]; /// Ensure machine-local files are excluded from git via `.git/info/exclude` /// and un-tracked if they were previously committed (git rm --cached). /// Called on every cache write so existing vaults self-heal automatically. fn ensure_cache_excluded(vault: &Path) { let git_dir = vault.join(".git"); if !git_dir.is_dir() { return; } let exclude_path = git_dir.join("info").join("exclude"); // 1. Add each entry to .git/info/exclude so git ignores it going forward. let existing = fs::read_to_string(&exclude_path).unwrap_or_default(); let mut to_add: Vec<&str> = UNTRACKED_FILES .iter() .filter(|e| !existing.lines().any(|line| line.trim() == **e)) .copied() .collect(); if !to_add.is_empty() { to_add.sort(); let separator = if existing.ends_with('\n') || existing.is_empty() { "" } else { "\n" }; let additions = to_add.join("\n"); let _ = fs::write(&exclude_path, format!("{existing}{separator}{additions}\n")); } // 2. Un-track each file if git currently tracks it. // `git rm --cached --quiet --ignore-unmatch` exits 0 even if the file isn't tracked. // This fixes existing vaults where these files were committed before this guard. let _ = std::process::Command::new("git") .args(["rm", "--cached", "--quiet", "--ignore-unmatch", "--"]) .args(UNTRACKED_FILES) .current_dir(vault) .output(); } /// Sort entries by modified_at descending and write the cache. fn finalize_and_cache(vault: &Path, mut entries: Vec, hash: String) -> Vec { entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at)); write_cache( vault, &VaultCache { version: CACHE_VERSION, vault_path: vault.to_string_lossy().to_string(), commit_hash: hash, entries: entries.clone(), }, ); entries } /// Handle same-commit cache hit: re-parse any uncommitted changes (new or modified files). fn update_same_commit(vault: &Path, cache: VaultCache) -> Vec { let changed = git_uncommitted_files(vault); if changed.is_empty() { return cache.entries; } let changed_set: std::collections::HashSet = changed.iter().cloned().collect(); let mut entries: Vec = cache .entries .into_iter() .filter(|e| !changed_set.contains(&to_relative_path(&e.path, vault))) .collect(); entries.extend(parse_files_at(vault, &changed)); finalize_and_cache(vault, entries, cache.commit_hash) } /// Handle different-commit cache: incremental update via git diff. fn update_different_commit( vault: &Path, cache: VaultCache, current_hash: String, ) -> Vec { let changed_files = git_changed_files(vault, &cache.commit_hash, ¤t_hash); let changed_set: std::collections::HashSet = changed_files.iter().cloned().collect(); let mut entries: Vec = cache .entries .into_iter() .filter(|e| !changed_set.contains(&to_relative_path(&e.path, vault))) .collect(); entries.extend(parse_files_at(vault, &changed_files)); finalize_and_cache(vault, entries, current_hash) } /// Scan vault with incremental caching via git. /// Falls back to full scan if cache is missing/corrupt or git is unavailable. pub fn scan_vault_cached(vault_path: &Path) -> Result, String> { if !vault_path.exists() || !vault_path.is_dir() { return Err(format!( "Vault path does not exist or is not a directory: {}", vault_path.display() )); } let current_hash = match git_head_hash(vault_path) { Some(h) => h, None => return scan_vault(vault_path), }; if let Some(cache) = load_cache(vault_path) { let current_vault_str = vault_path.to_string_lossy(); let cache_stale = cache.version != CACHE_VERSION || (!cache.vault_path.is_empty() && cache.vault_path != current_vault_str.as_ref()); if cache_stale { let entries = scan_vault(vault_path)?; return Ok(finalize_and_cache(vault_path, entries, current_hash)); } return if cache.commit_hash == current_hash { Ok(update_same_commit(vault_path, cache)) } else { Ok(update_different_commit(vault_path, cache, current_hash)) }; } // No cache — full scan and write cache let entries = scan_vault(vault_path)?; Ok(finalize_and_cache(vault_path, entries, current_hash)) } #[cfg(test)] mod tests { use super::*; use std::io::Write; use tempfile::TempDir; fn create_test_file(dir: &Path, name: &str, content: &str) { let file_path = dir.join(name); if let Some(parent) = file_path.parent() { fs::create_dir_all(parent).unwrap(); } let mut file = fs::File::create(file_path).unwrap(); file.write_all(content.as_bytes()).unwrap(); } #[test] fn test_scan_vault_cached_no_git() { // Without git, scan_vault_cached falls back to scan_vault let dir = TempDir::new().unwrap(); create_test_file(dir.path(), "note.md", "# Note\n\nContent here."); let entries = scan_vault_cached(dir.path()).unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].title, "Note"); assert_eq!(entries[0].snippet, "Content here."); } #[test] fn test_scan_vault_cached_with_git() { let dir = TempDir::new().unwrap(); let vault = dir.path(); // Init git repo std::process::Command::new("git") .args(["init"]) .current_dir(vault) .output() .unwrap(); std::process::Command::new("git") .args(["config", "user.email", "test@test.com"]) .current_dir(vault) .output() .unwrap(); std::process::Command::new("git") .args(["config", "user.name", "Test"]) .current_dir(vault) .output() .unwrap(); create_test_file(vault, "note.md", "# Note\n\nFirst version."); std::process::Command::new("git") .args(["add", "."]) .current_dir(vault) .output() .unwrap(); std::process::Command::new("git") .args(["commit", "-m", "init"]) .current_dir(vault) .output() .unwrap(); // First call: full scan, writes cache let entries = scan_vault_cached(vault).unwrap(); assert_eq!(entries.len(), 1); assert!(cache_path(vault).exists()); // Second call: uses cache (same HEAD) let entries2 = scan_vault_cached(vault).unwrap(); assert_eq!(entries2.len(), 1); assert_eq!(entries2[0].title, "Note"); } #[test] fn test_scan_vault_cached_invalidates_stale_vault_path() { let dir = TempDir::new().unwrap(); let vault = dir.path(); // Init git repo std::process::Command::new("git") .args(["init"]) .current_dir(vault) .output() .unwrap(); std::process::Command::new("git") .args(["config", "user.email", "test@test.com"]) .current_dir(vault) .output() .unwrap(); std::process::Command::new("git") .args(["config", "user.name", "Test"]) .current_dir(vault) .output() .unwrap(); create_test_file(vault, "note.md", "# Note\n\nContent."); std::process::Command::new("git") .args(["add", "."]) .current_dir(vault) .output() .unwrap(); std::process::Command::new("git") .args(["commit", "-m", "init"]) .current_dir(vault) .output() .unwrap(); // Build cache normally let entries = scan_vault_cached(vault).unwrap(); assert_eq!(entries.len(), 1); assert!( entries[0] .path .starts_with(&vault.to_string_lossy().as_ref()), "Entry path should start with vault path" ); // Tamper with cache to simulate a clone from a different machine let cache_file = cache_path(vault); let cache_data = fs::read_to_string(&cache_file).unwrap(); let tampered = cache_data.replace( &vault.to_string_lossy().as_ref(), "/Users/other-machine/OtherVault", ); fs::write(&cache_file, tampered).unwrap(); // Rescanning should invalidate the stale cache and produce correct paths let entries2 = scan_vault_cached(vault).unwrap(); assert_eq!(entries2.len(), 1); assert!( entries2[0] .path .starts_with(&vault.to_string_lossy().as_ref()), "After stale-cache invalidation, paths should use the current vault path, got: {}", entries2[0].path ); } #[test] fn test_scan_vault_cached_incremental_different_commit() { let dir = TempDir::new().unwrap(); let vault = dir.path(); // Init git repo std::process::Command::new("git") .args(["init"]) .current_dir(vault) .output() .unwrap(); std::process::Command::new("git") .args(["config", "user.email", "test@test.com"]) .current_dir(vault) .output() .unwrap(); std::process::Command::new("git") .args(["config", "user.name", "Test"]) .current_dir(vault) .output() .unwrap(); create_test_file(vault, "first.md", "# First\n\nFirst note."); std::process::Command::new("git") .args(["add", "."]) .current_dir(vault) .output() .unwrap(); std::process::Command::new("git") .args(["commit", "-m", "first"]) .current_dir(vault) .output() .unwrap(); // Build cache let entries = scan_vault_cached(vault).unwrap(); assert_eq!(entries.len(), 1); // Add a second file and commit create_test_file(vault, "second.md", "# Second\n\nSecond note."); std::process::Command::new("git") .args(["add", "."]) .current_dir(vault) .output() .unwrap(); std::process::Command::new("git") .args(["commit", "-m", "second"]) .current_dir(vault) .output() .unwrap(); // Incremental update: cache has old commit, new commit adds second.md let entries2 = scan_vault_cached(vault).unwrap(); assert_eq!(entries2.len(), 2); let titles: Vec<&str> = entries2.iter().map(|e| e.title.as_str()).collect(); assert!(titles.contains(&"First")); assert!(titles.contains(&"Second")); } #[test] fn test_update_same_commit_picks_up_modified_file() { let dir = TempDir::new().unwrap(); let vault = dir.path(); std::process::Command::new("git") .args(["init"]) .current_dir(vault) .output() .unwrap(); std::process::Command::new("git") .args(["config", "user.email", "test@test.com"]) .current_dir(vault) .output() .unwrap(); std::process::Command::new("git") .args(["config", "user.name", "Test"]) .current_dir(vault) .output() .unwrap(); // Commit a type note without sidebar label create_test_file(vault, "type/news.md", "---\ntype: Type\n---\n# News\n"); std::process::Command::new("git") .args(["add", "."]) .current_dir(vault) .output() .unwrap(); std::process::Command::new("git") .args(["commit", "-m", "init"]) .current_dir(vault) .output() .unwrap(); // Prime the cache (same commit hash) let entries = scan_vault_cached(vault).unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].sidebar_label, None); // User edits the type note to add sidebar label (uncommitted) create_test_file( vault, "type/news.md", "---\ntype: Type\nsidebar label: News\n---\n# News\n", ); // Reload with same git HEAD — must pick up the modification let entries2 = scan_vault_cached(vault).unwrap(); assert_eq!(entries2.len(), 1); assert_eq!( entries2[0].sidebar_label, Some("News".to_string()), "sidebarLabel must reflect the uncommitted edit" ); } #[test] fn test_update_same_commit_new_file_still_added() { let dir = TempDir::new().unwrap(); let vault = dir.path(); std::process::Command::new("git") .args(["init"]) .current_dir(vault) .output() .unwrap(); std::process::Command::new("git") .args(["config", "user.email", "test@test.com"]) .current_dir(vault) .output() .unwrap(); std::process::Command::new("git") .args(["config", "user.name", "Test"]) .current_dir(vault) .output() .unwrap(); create_test_file(vault, "existing.md", "# Existing\n"); std::process::Command::new("git") .args(["add", "."]) .current_dir(vault) .output() .unwrap(); std::process::Command::new("git") .args(["commit", "-m", "init"]) .current_dir(vault) .output() .unwrap(); // Prime cache let entries = scan_vault_cached(vault).unwrap(); assert_eq!(entries.len(), 1); // Create new untracked file create_test_file(vault, "new-note.md", "# New Note\n"); // Cache still same commit — new untracked file must appear let entries2 = scan_vault_cached(vault).unwrap(); assert_eq!(entries2.len(), 2); let titles: Vec<&str> = entries2.iter().map(|e| e.title.as_str()).collect(); assert!(titles.contains(&"Existing")); assert!(titles.contains(&"New Note")); } #[test] fn test_update_same_commit_new_files_in_new_subdirectory() { let dir = TempDir::new().unwrap(); let vault = dir.path(); std::process::Command::new("git") .args(["init"]) .current_dir(vault) .output() .unwrap(); std::process::Command::new("git") .args(["config", "user.email", "test@test.com"]) .current_dir(vault) .output() .unwrap(); std::process::Command::new("git") .args(["config", "user.name", "Test"]) .current_dir(vault) .output() .unwrap(); create_test_file(vault, "existing.md", "# Existing\n"); std::process::Command::new("git") .args(["add", "."]) .current_dir(vault) .output() .unwrap(); std::process::Command::new("git") .args(["commit", "-m", "init"]) .current_dir(vault) .output() .unwrap(); // Prime cache let entries = scan_vault_cached(vault).unwrap(); assert_eq!(entries.len(), 1); // Create files in a new subdirectory (simulates restore_default_themes) create_test_file( vault, "theme/default.md", "---\nIs A: Theme\n---\n# Default Theme\n", ); create_test_file( vault, "theme/dark.md", "---\nIs A: Theme\n---\n# Dark Theme\n", ); // Cache same commit — files in new subdirectory must appear let entries2 = scan_vault_cached(vault).unwrap(); assert_eq!( entries2.len(), 3, "must pick up files in new untracked subdirectory" ); let titles: Vec<&str> = entries2.iter().map(|e| e.title.as_str()).collect(); assert!(titles.contains(&"Existing")); assert!(titles.contains(&"Default Theme")); assert!(titles.contains(&"Dark Theme")); } #[test] fn test_update_same_commit_visible_removed_from_type_note() { let dir = TempDir::new().unwrap(); let vault = dir.path(); std::process::Command::new("git") .args(["init"]) .current_dir(vault) .output() .unwrap(); std::process::Command::new("git") .args(["config", "user.email", "test@test.com"]) .current_dir(vault) .output() .unwrap(); std::process::Command::new("git") .args(["config", "user.name", "Test"]) .current_dir(vault) .output() .unwrap(); // Commit a type note with visible: false create_test_file( vault, "type/topic.md", "---\ntype: Type\nvisible: false\n---\n# Topic\n", ); std::process::Command::new("git") .args(["add", "."]) .current_dir(vault) .output() .unwrap(); std::process::Command::new("git") .args(["commit", "-m", "init"]) .current_dir(vault) .output() .unwrap(); // Prime the cache let entries = scan_vault_cached(vault).unwrap(); assert_eq!(entries.len(), 1); assert_eq!( entries[0].visible, Some(false), "visible must be false initially" ); // User removes visible field (uncommitted edit) create_test_file(vault, "type/topic.md", "---\ntype: Type\n---\n# Topic\n"); // Reload — must reflect the removal (visible defaults to None) let entries2 = scan_vault_cached(vault).unwrap(); assert_eq!(entries2.len(), 1); assert_eq!( entries2[0].visible, None, "visible must be None after removing the field" ); } }