Break the 2338-line vault.rs into vault/ directory with 6 focused modules: - mod.rs: core types (VaultEntry, Frontmatter), parse_md_file, scan_vault - parsing.rs: text processing (snippet extraction, markdown stripping, date parsing) - cache.rs: git-based incremental vault caching - trash.rs: purge_trash with flat iterator chain - rename.rs: note renaming with wikilink updates - image.rs: attachment saving with filename sanitization Also moves frontmatter update/delete wrappers to frontmatter.rs where they belong, and converts public functions to accept &Path instead of &str. CodeScene scores: mod.rs 10.0, parsing.rs 9.68, cache.rs 9.68, trash.rs 9.38, rename.rs 9.68, image.rs 10.0 (all above 9.0 target). All 169 Rust tests pass, 86.28% line coverage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
348 lines
11 KiB
Rust
348 lines
11 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
use super::{parse_md_file, scan_vault, VaultEntry};
|
|
|
|
// --- Vault Cache ---
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct VaultCache {
|
|
commit_hash: String,
|
|
entries: Vec<VaultEntry>,
|
|
}
|
|
|
|
fn cache_path(vault: &Path) -> std::path::PathBuf {
|
|
vault.join(".laputa-cache.json")
|
|
}
|
|
|
|
fn git_head_hash(vault: &Path) -> Option<String> {
|
|
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<String> {
|
|
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()))
|
|
}
|
|
|
|
/// Check if a porcelain status indicates a new/untracked file.
|
|
fn is_new_file_status(status: &str) -> bool {
|
|
status == "??" || status.starts_with('A')
|
|
}
|
|
|
|
/// Extract .md file paths from git diff --name-only output.
|
|
fn collect_md_paths_from_diff(stdout: &str) -> Vec<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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();
|
|
|
|
let uncommitted = run_git(vault, &["status", "--porcelain"])
|
|
.map(|s| collect_md_paths_from_porcelain(&s))
|
|
.unwrap_or_default();
|
|
|
|
for path in uncommitted {
|
|
if !files.contains(&path) {
|
|
files.push(path);
|
|
}
|
|
}
|
|
|
|
files
|
|
}
|
|
|
|
fn git_uncommitted_new_files(vault: &Path) -> Vec<String> {
|
|
let stdout = match run_git(vault, &["status", "--porcelain"]) {
|
|
Some(s) => s,
|
|
None => return Vec::new(),
|
|
};
|
|
stdout
|
|
.lines()
|
|
.filter_map(parse_porcelain_line)
|
|
.filter(|(status, path)| path.ends_with(".md") && is_new_file_status(status))
|
|
.map(|(_, path)| path)
|
|
.collect()
|
|
}
|
|
|
|
fn load_cache(vault: &Path) -> Option<VaultCache> {
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// 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<VaultEntry> {
|
|
rel_paths
|
|
.iter()
|
|
.filter_map(|rel| {
|
|
let abs = vault.join(rel);
|
|
if abs.is_file() {
|
|
parse_md_file(&abs).ok()
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Sort entries by modified_at descending and write the cache.
|
|
fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String) -> Vec<VaultEntry> {
|
|
entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
|
write_cache(
|
|
vault,
|
|
&VaultCache {
|
|
commit_hash: hash,
|
|
entries: entries.clone(),
|
|
},
|
|
);
|
|
entries
|
|
}
|
|
|
|
/// Handle same-commit cache hit: add any uncommitted new files.
|
|
fn update_same_commit(vault: &Path, cache: VaultCache) -> Vec<VaultEntry> {
|
|
let new_files = git_uncommitted_new_files(vault);
|
|
let mut entries = cache.entries;
|
|
let existing: std::collections::HashSet<String> = entries
|
|
.iter()
|
|
.map(|e| to_relative_path(&e.path, vault))
|
|
.collect();
|
|
|
|
let new_entries = parse_files_at(vault, &new_files);
|
|
for entry in new_entries {
|
|
let rel = to_relative_path(&entry.path, vault);
|
|
if !existing.contains(&rel) {
|
|
entries.push(entry);
|
|
}
|
|
}
|
|
|
|
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<VaultEntry> {
|
|
let changed_files = git_changed_files(vault, &cache.commit_hash, ¤t_hash);
|
|
let changed_set: std::collections::HashSet<String> = changed_files.iter().cloned().collect();
|
|
|
|
let mut entries: Vec<VaultEntry> = 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<Vec<VaultEntry>, 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) {
|
|
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_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"));
|
|
}
|
|
}
|