709 lines
23 KiB
Rust
709 lines
23 KiB
Rust
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<VaultEntry>,
|
|
}
|
|
|
|
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<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()))
|
|
}
|
|
|
|
/// 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();
|
|
|
|
// 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<String> {
|
|
// Modified/staged tracked files from git status --porcelain
|
|
let mut files: Vec<String> = 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::<Vec<_>>()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
for path in untracked {
|
|
if !files.contains(&path) {
|
|
files.push(path);
|
|
}
|
|
}
|
|
|
|
files
|
|
}
|
|
|
|
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);
|
|
}
|
|
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<VaultEntry> {
|
|
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<VaultEntry>, hash: String) -> Vec<VaultEntry> {
|
|
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<VaultEntry> {
|
|
let changed = git_uncommitted_files(vault);
|
|
if changed.is_empty() {
|
|
return cache.entries;
|
|
}
|
|
let changed_set: std::collections::HashSet<String> = changed.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));
|
|
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) {
|
|
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"
|
|
);
|
|
}
|
|
}
|