diff --git a/docs/adr/0039-git-history-for-note-dates.md b/docs/adr/0039-git-history-for-note-dates.md new file mode 100644 index 00000000..5b9003d6 --- /dev/null +++ b/docs/adr/0039-git-history-for-note-dates.md @@ -0,0 +1,40 @@ +--- +type: ADR +id: "0039" +title: "Use git history for note creation and modification dates" +status: active +date: 2026-04-02 +--- + +## Context + +Filesystem metadata (`ctime`/`mtime`) is unreliable for a git-backed vault. After `git clone`, `git pull`, or iCloud sync, files appear "newly created" even when they have years of history. This causes incorrect sort ordering in the note list and wrong dates in the inspector panel. + +## Decision + +**Use `git log` to determine the true creation and modification dates for notes.** A single batch `git log --format="COMMIT %aI" --name-only` command walks the full commit history and extracts: + +- **modified_at** = author date of the most recent commit that touched the file +- **created_at** = author date of the oldest commit that touched the file + +The batch approach runs once per vault scan (not per-file), parsing the log output in a single linear pass. Results are stored in a `HashMap` keyed by vault-relative path and threaded through the existing `parse_md_file` / `scan_vault` / `scan_vault_cached` pipeline. + +### Fallback to filesystem dates + +- **Non-git vaults** (no `.git` directory): all notes use filesystem `mtime`/`ctime`. +- **Uncommitted new files**: not in git log output, so filesystem dates are used automatically. +- **Single-file reloads** (`reload_entry`): use filesystem dates since the file was just saved and the most accurate timestamp is the filesystem one. + +## Options considered + +- **Per-file `git log`**: Correct but O(n) subprocesses. Too slow for vaults with 500+ notes. +- **Frontmatter dates** (e.g., `created: 2025-01-15`): Requires user discipline. Not automatic. Breaks when users forget to set them. +- **Filesystem metadata** (current): Unreliable across clones, pulls, and cloud sync. +- **Single batch `git log`** (chosen): One subprocess, O(n) parsing, correct dates for all committed files. + +## Consequences + +- Note sort-by-created and sort-by-modified now reflect true git history, stable across clones and machines. +- First vault scan runs one `git log` over the full history. For a vault with 1000 files and 500 commits, output is ~100KB and parses in <100ms. +- Renamed files get `created_at` set to the rename commit date (not the original creation). Acceptable trade-off vs. the complexity of rename tracking. +- `CACHE_VERSION` bumped from 9 to 10 to force a full rescan with git dates on upgrade. diff --git a/src-tauri/src/git/dates.rs b/src-tauri/src/git/dates.rs new file mode 100644 index 00000000..86a0335f --- /dev/null +++ b/src-tauri/src/git/dates.rs @@ -0,0 +1,233 @@ +use chrono::DateTime; +use std::collections::HashMap; +use std::path::Path; +use std::process::Command; + +/// Git-derived creation and modification timestamps for a file. +#[derive(Debug, Clone)] +pub struct GitDates { + pub created_at: u64, + pub modified_at: u64, +} + +/// Run a single `git log` to collect creation and modification dates for all +/// tracked files in the repository. Returns a map from relative path to dates. +/// +/// - **modified_at** = author date of the most recent commit touching the file +/// - **created_at** = author date of the oldest commit touching the file +/// +/// Files not yet committed (untracked / only staged) will not appear in the map; +/// callers should fall back to filesystem metadata for those. +pub fn get_all_file_dates(vault_path: &Path) -> HashMap { + let output = match Command::new("git") + .args(["log", "--format=COMMIT %aI", "--name-only"]) + .current_dir(vault_path) + .output() + { + Ok(o) if o.status.success() => o, + _ => return HashMap::new(), + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_git_log_output(&stdout) +} + +/// Parse the output of `git log --format="COMMIT %aI" --name-only`. +/// +/// Output looks like: +/// ```text +/// COMMIT 2026-03-15T10:00:00+02:00 +/// +/// file-a.md +/// file-b.md +/// +/// COMMIT 2026-03-10T08:00:00+02:00 +/// +/// file-a.md +/// ``` +/// +/// Commits are ordered newest-first. For each file: +/// - First occurrence → sets `modified_at` +/// - Every subsequent occurrence overwrites `created_at` (last one = oldest commit wins) +fn parse_git_log_output(stdout: &str) -> HashMap { + let mut map: HashMap = HashMap::new(); + let mut current_ts: Option = None; + + for line in stdout.lines() { + if let Some(date_str) = line.strip_prefix("COMMIT ") { + current_ts = parse_author_date(date_str); + continue; + } + + let path = line.trim(); + if path.is_empty() || current_ts.is_none() { + continue; + } + // Only process .md files + if !path.ends_with(".md") { + continue; + } + + let ts = current_ts.unwrap(); + map.entry(path.to_string()) + .and_modify(|d| d.created_at = ts) + .or_insert(GitDates { + created_at: ts, + modified_at: ts, + }); + } + + map +} + +fn parse_author_date(s: &str) -> Option { + DateTime::parse_from_rfc3339(s.trim()) + .ok() + .map(|dt| dt.timestamp() as u64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_git_log_single_commit() { + let output = "\ +COMMIT 2026-03-15T10:00:00+00:00 + +file-a.md +file-b.md +"; + let map = parse_git_log_output(output); + assert_eq!(map.len(), 2); + assert_eq!(map["file-a.md"].created_at, 1773568800); + assert_eq!(map["file-a.md"].modified_at, 1773568800); + } + + #[test] + fn test_parse_git_log_multiple_commits() { + let output = "\ +COMMIT 2026-03-15T10:00:00+00:00 + +file-a.md + +COMMIT 2026-03-10T08:00:00+00:00 + +file-a.md +file-b.md +"; + let map = parse_git_log_output(output); + assert_eq!(map.len(), 2); + // file-a: modified = newest (2026-03-15), created = oldest (2026-03-10) + assert_eq!(map["file-a.md"].modified_at, 1773568800); + assert_eq!(map["file-a.md"].created_at, 1773129600); + // file-b: only in second commit + assert_eq!(map["file-b.md"].modified_at, 1773129600); + assert_eq!(map["file-b.md"].created_at, 1773129600); + } + + #[test] + fn test_non_md_files_filtered_out() { + let output = "\ +COMMIT 2026-03-15T10:00:00+00:00 + +README.txt +note.md +image.png +"; + let map = parse_git_log_output(output); + assert_eq!(map.len(), 1); + assert!(map.contains_key("note.md")); + } + + #[test] + fn test_empty_output() { + let map = parse_git_log_output(""); + assert!(map.is_empty()); + } + + #[test] + fn test_subdirectory_paths() { + let output = "\ +COMMIT 2026-03-15T10:00:00+00:00 + +docs/adr/0001-stack.md +notes/daily.md +"; + let map = parse_git_log_output(output); + assert_eq!(map.len(), 2); + assert!(map.contains_key("docs/adr/0001-stack.md")); + assert!(map.contains_key("notes/daily.md")); + } + + #[test] + fn test_get_all_file_dates_in_real_repo() { + let dir = tempfile::TempDir::new().unwrap(); + let vault = dir.path(); + + // Init 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(); + + // First commit with one file + std::fs::write(vault.join("first.md"), "# First\n").unwrap(); + 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(); + + // Second commit with another file + modify first + std::fs::write(vault.join("first.md"), "# First\nUpdated.\n").unwrap(); + std::fs::write(vault.join("second.md"), "# Second\n").unwrap(); + 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(); + + let map = get_all_file_dates(vault); + assert_eq!(map.len(), 2); + assert!(map.contains_key("first.md")); + assert!(map.contains_key("second.md")); + + // first.md: created in commit 1, modified in commit 2 + // So modified_at > created_at (or equal if commits are same second) + assert!(map["first.md"].modified_at >= map["first.md"].created_at); + // second.md: only in commit 2 + assert_eq!( + map["second.md"].modified_at, + map["second.md"].created_at + ); + } + + #[test] + fn test_get_all_file_dates_no_git_repo() { + let dir = tempfile::TempDir::new().unwrap(); + let map = get_all_file_dates(dir.path()); + assert!(map.is_empty()); + } +} diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs index 46792bc5..ae858dcf 100644 --- a/src-tauri/src/git/mod.rs +++ b/src-tauri/src/git/mod.rs @@ -1,5 +1,6 @@ mod commit; mod conflict; +mod dates; mod history; mod pulse; mod remote; @@ -13,6 +14,7 @@ pub use conflict::{ get_conflict_files, get_conflict_mode, git_commit_conflict_resolution, git_resolve_conflict, is_merge_in_progress, is_rebase_in_progress, }; +pub use dates::{get_all_file_dates, GitDates}; pub use history::{get_file_diff, get_file_diff_at_commit, get_file_history}; pub use pulse::{get_last_commit_info, get_vault_pulse, LastCommitInfo, PulseCommit, PulseFile}; pub use remote::{ diff --git a/src-tauri/src/vault/cache.rs b/src-tauri/src/vault/cache.rs index 9a682cc7..adea4b2b 100644 --- a/src-tauri/src/vault/cache.rs +++ b/src-tauri/src/vault/cache.rs @@ -3,12 +3,15 @@ use std::fs; use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; +use crate::git::{get_all_file_dates, GitDates}; +use std::collections::HashMap; + 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 = 9; +const CACHE_VERSION: u32 = 10; #[derive(Debug, Serialize, Deserialize)] struct VaultCache { @@ -173,13 +176,20 @@ fn to_relative_path(abs_path: &str, vault: &Path) -> 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 { +fn parse_files_at( + vault: &Path, + rel_paths: &[String], + git_dates: &HashMap, +) -> Vec { rel_paths .iter() .filter_map(|rel| { let abs = vault.join(rel); if abs.is_file() { - parse_md_file(&abs).ok() + let dates = git_dates + .get(rel.as_str()) + .map(|d| (d.modified_at, d.created_at)); + parse_md_file(&abs, dates).ok() } else { None } @@ -264,13 +274,17 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec, hash: String) /// Handle same-commit cache hit: re-parse any uncommitted changes (new or modified files). /// Always prunes stale entries even when git reports no changes, so that files /// deleted outside git (e.g., via Finder) are removed from the cache on vault open. -fn update_same_commit(vault: &Path, cache: VaultCache) -> Vec { +fn update_same_commit( + vault: &Path, + cache: VaultCache, + git_dates: &HashMap, +) -> Vec { let changed = git_uncommitted_files(vault); let mut entries = cache.entries; if !changed.is_empty() { let changed_set: std::collections::HashSet = changed.iter().cloned().collect(); entries.retain(|e| !changed_set.contains(&to_relative_path(&e.path, vault))); - entries.extend(parse_files_at(vault, &changed)); + entries.extend(parse_files_at(vault, &changed, git_dates)); } // Always finalize: prune_stale_entries inside finalize_and_cache removes // entries for files deleted outside git (e.g., via Finder or another app). @@ -282,6 +296,7 @@ fn update_different_commit( vault: &Path, cache: VaultCache, current_hash: String, + git_dates: &HashMap, ) -> Vec { let changed_files = git_changed_files(vault, &cache.commit_hash, ¤t_hash); let changed_set: std::collections::HashSet = changed_files.iter().cloned().collect(); @@ -291,7 +306,7 @@ fn update_different_commit( .into_iter() .filter(|e| !changed_set.contains(&to_relative_path(&e.path, vault))) .collect(); - entries.extend(parse_files_at(vault, &changed_files)); + entries.extend(parse_files_at(vault, &changed_files, git_dates)); finalize_and_cache(vault, entries, current_hash) } @@ -319,26 +334,34 @@ pub fn scan_vault_cached(vault_path: &Path) -> Result, String> { let current_hash = match git_head_hash(vault_path) { Some(h) => h, - None => return scan_vault(vault_path), + None => return scan_vault(vault_path, &HashMap::new()), }; + // Build git dates map once — used by all code paths below + let git_dates = get_all_file_dates(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)?; + let entries = scan_vault(vault_path, &git_dates)?; return Ok(finalize_and_cache(vault_path, entries, current_hash)); } return if cache.commit_hash == current_hash { - Ok(update_same_commit(vault_path, cache)) + Ok(update_same_commit(vault_path, cache, &git_dates)) } else { - Ok(update_different_commit(vault_path, cache, current_hash)) + Ok(update_different_commit( + vault_path, + cache, + current_hash, + &git_dates, + )) }; } // No cache — full scan and write cache - let entries = scan_vault(vault_path)?; + let entries = scan_vault(vault_path, &git_dates)?; Ok(finalize_and_cache(vault_path, entries, current_hash)) } @@ -930,7 +953,7 @@ mod tests { // Simulate a stale cache written by old code that parsed Archived: Yes as false let stale_entry = { - let mut e = parse_md_file(&vault.join("note.md")).unwrap(); + let mut e = parse_md_file(&vault.join("note.md"), None).unwrap(); e.archived = false; // simulate old parser behavior e }; diff --git a/src-tauri/src/vault/getting_started.rs b/src-tauri/src/vault/getting_started.rs index 2968cde4..b4ad8aa5 100644 --- a/src-tauri/src/vault/getting_started.rs +++ b/src-tauri/src/vault/getting_started.rs @@ -503,7 +503,7 @@ mod tests { let vault_path = dir.path().join("parse-vault"); create_getting_started_vault(vault_path.to_str().unwrap()).unwrap(); - let entries = crate::vault::scan_vault(&vault_path).unwrap(); + let entries = crate::vault::scan_vault(&vault_path, &std::collections::HashMap::new()).unwrap(); // SAMPLE_FILES + AGENTS.md assert_eq!(entries.len(), SAMPLE_FILES.len() + 1); } @@ -537,7 +537,7 @@ mod tests { let vault_path = dir.path().join("agents-parse-vault"); create_getting_started_vault(vault_path.to_str().unwrap()).unwrap(); - let entry = crate::vault::parse_md_file(&vault_path.join("AGENTS.md")).unwrap(); + let entry = crate::vault::parse_md_file(&vault_path.join("AGENTS.md"), None).unwrap(); // No frontmatter title → derived from filename slug (H1 is body content) assert_eq!(entry.title, "AGENTS"); // Config files have no frontmatter type field — type is None diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index f19cb9f6..16bfd7dc 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -35,7 +35,14 @@ use std::path::Path; use walkdir::WalkDir; /// Parse a single markdown file into a VaultEntry. -pub fn parse_md_file(path: &Path) -> Result { +/// +/// If `git_dates` is provided, those timestamps override filesystem metadata +/// for `modified_at` and `created_at`. Pass `None` to use filesystem dates +/// (appropriate for newly-saved files not yet committed, or non-git vaults). +pub fn parse_md_file( + path: &Path, + git_dates: Option<(u64, u64)>, +) -> Result { let content = fs::read_to_string(path) .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; let filename = path @@ -51,7 +58,11 @@ pub fn parse_md_file(path: &Path) -> Result { let snippet = extract_snippet(&content); let word_count = count_body_words(&content); let outgoing_links = extract_outgoing_links(&parsed.content); - let (modified_at, created_at, file_size) = read_file_metadata(path)?; + let (fs_modified, fs_created, file_size) = read_file_metadata(path)?; + let (modified_at, created_at) = match git_dates { + Some((git_mod, git_create)) => (Some(git_mod), Some(git_create)), + None => (fs_modified, fs_created), + }; let is_a = resolve_is_a(frontmatter.is_a); // Add "Type" relationship: isA becomes a navigable link to the type document. @@ -111,11 +122,12 @@ pub fn parse_md_file(path: &Path) -> Result { } /// Re-read a single file from disk and return a fresh VaultEntry. +/// Uses filesystem dates (no git lookup) since the file was likely just saved. pub fn reload_entry(path: &Path) -> Result { if !path.exists() { return Err(format!("File does not exist: {}", path.display())); } - parse_md_file(path) + parse_md_file(path, None) } /// Directories that are never shown in the folder tree or scanned for notes. @@ -129,8 +141,32 @@ fn is_md_file(path: &Path) -> bool { path.is_file() && path.extension().is_some_and(|ext| ext == "md") } -fn try_parse_md(path: &Path, entries: &mut Vec) { - match parse_md_file(path) { +use crate::git::GitDates; +use std::collections::HashMap; + +fn lookup_git_dates( + path: &Path, + vault_path: &Path, + git_dates: &HashMap, +) -> Option<(u64, u64)> { + let rel = path + .strip_prefix(vault_path) + .ok()? + .to_string_lossy() + .to_string(); + git_dates + .get(&rel) + .map(|d| (d.modified_at, d.created_at)) +} + +fn try_parse_md( + path: &Path, + vault_path: &Path, + git_dates: &HashMap, + entries: &mut Vec, +) { + let dates = lookup_git_dates(path, vault_path, git_dates); + match parse_md_file(path, dates) { Ok(vault_entry) => entries.push(vault_entry), Err(e) => log::warn!("Skipping file: {}", e), } @@ -138,7 +174,11 @@ fn try_parse_md(path: &Path, entries: &mut Vec) { /// Scan all .md files in the vault, including subdirectories. /// Hidden directories (starting with `.`) are excluded. -fn scan_all_md_files(vault_path: &Path, entries: &mut Vec) { +fn scan_all_md_files( + vault_path: &Path, + git_dates: &HashMap, + entries: &mut Vec, +) { let walker = WalkDir::new(vault_path) .follow_links(true) .into_iter() @@ -155,13 +195,17 @@ fn scan_all_md_files(vault_path: &Path, entries: &mut Vec) { }); for entry in walker.filter_map(|e| e.ok()) { if is_md_file(entry.path()) { - try_parse_md(entry.path(), entries); + try_parse_md(entry.path(), vault_path, git_dates, entries); } } } /// Scan a directory recursively for .md files and return VaultEntry for each. -pub fn scan_vault(vault_path: &Path) -> Result, String> { +/// Pass an empty map for `git_dates` to use filesystem dates only. +pub fn scan_vault( + vault_path: &Path, + git_dates: &HashMap, +) -> Result, String> { if !vault_path.exists() { return Err(format!( "Vault path does not exist: {}", @@ -176,7 +220,7 @@ pub fn scan_vault(vault_path: &Path) -> Result, String> { } let mut entries = Vec::new(); - scan_all_md_files(vault_path, &mut entries); + scan_all_md_files(vault_path, git_dates, &mut entries); entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at)); Ok(entries) diff --git a/src-tauri/src/vault/mod_tests.rs b/src-tauri/src/vault/mod_tests.rs index ab98dde9..77e9e891 100644 --- a/src-tauri/src/vault/mod_tests.rs +++ b/src-tauri/src/vault/mod_tests.rs @@ -15,7 +15,7 @@ fn create_test_file(dir: &Path, name: &str, content: &str) { fn parse_test_entry(dir: &TempDir, name: &str, content: &str) -> VaultEntry { create_test_file(dir.path(), name, content); - parse_md_file(&dir.path().join(name)).unwrap() + parse_md_file(&dir.path().join(name), None).unwrap() } #[test] @@ -108,7 +108,7 @@ fn test_parse_no_frontmatter() { let content = "# A Note Without Frontmatter\n\nJust markdown."; create_test_file(dir.path(), "a-note-without-frontmatter.md", content); - let entry = parse_md_file(&dir.path().join("a-note-without-frontmatter.md")).unwrap(); + let entry = parse_md_file(&dir.path().join("a-note-without-frontmatter.md"), None).unwrap(); // No title in frontmatter → derived from filename assert_eq!(entry.title, "A Note Without Frontmatter"); } @@ -119,7 +119,7 @@ fn test_parse_single_string_aliases() { let content = "---\naliases: SingleAlias\n---\n# Test\n"; create_test_file(dir.path(), "single-alias.md", content); - let entry = parse_md_file(&dir.path().join("single-alias.md")).unwrap(); + let entry = parse_md_file(&dir.path().join("single-alias.md"), None).unwrap(); assert_eq!(entry.aliases, vec!["SingleAlias"]); } @@ -135,7 +135,7 @@ fn test_scan_vault_root_and_protected_folders() { create_test_file(dir.path(), "attachments/notes.md", "# Attachment note\n"); create_test_file(dir.path(), "not-markdown.txt", "This should be ignored"); - let entries = scan_vault(dir.path()).unwrap(); + let entries = scan_vault(dir.path(), &HashMap::new()).unwrap(); assert_eq!(entries.len(), 3); let filenames: Vec<&str> = entries.iter().map(|e| e.filename.as_str()).collect(); @@ -159,7 +159,7 @@ fn test_scan_vault_includes_subdirectory_notes() { "---\ntype: Project\n---\n# Old\n", ); - let entries = scan_vault(dir.path()).unwrap(); + let entries = scan_vault(dir.path(), &HashMap::new()).unwrap(); assert_eq!( entries.len(), 3, @@ -178,7 +178,7 @@ fn test_scan_vault_includes_all_protected_folders() { create_test_file(dir.path(), "attachments/notes.md", "# Attachment note\n"); create_test_file(dir.path(), "assets/image.md", "# Asset\n"); - let entries = scan_vault(dir.path()).unwrap(); + let entries = scan_vault(dir.path(), &HashMap::new()).unwrap(); assert_eq!(entries.len(), 3); } @@ -189,14 +189,14 @@ fn test_scan_vault_skips_hidden_folders() { create_test_file(dir.path(), ".laputa/cache.md", "# Cache\n"); create_test_file(dir.path(), ".git/objects.md", "# Git\n"); - let entries = scan_vault(dir.path()).unwrap(); + let entries = scan_vault(dir.path(), &HashMap::new()).unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].filename, "root.md"); } #[test] fn test_scan_vault_nonexistent_path() { - let result = scan_vault(Path::new("/nonexistent/path/that/does/not/exist")); + let result = scan_vault(Path::new("/nonexistent/path/that/does/not/exist"), &HashMap::new()); assert!(result.is_err()); } @@ -207,7 +207,7 @@ fn test_parse_malformed_yaml() { let content = "---\nIs A: [unclosed bracket\n---\n# Malformed\n"; create_test_file(dir.path(), "malformed.md", content); - let entry = parse_md_file(&dir.path().join("malformed.md")); + let entry = parse_md_file(&dir.path().join("malformed.md"), None); // Should still succeed — gray_matter may parse partially or skip assert!(entry.is_ok()); } @@ -236,7 +236,7 @@ fn test_parse_md_file_has_snippet() { let content = "---\nIs A: Note\n---\n# Test Note\n\nHello, world! This is a snippet."; create_test_file(dir.path(), "test.md", content); - let entry = parse_md_file(&dir.path().join("test.md")).unwrap(); + let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap(); assert_eq!(entry.snippet, "Hello, world! This is a snippet."); } @@ -247,7 +247,7 @@ fn test_parse_md_file_has_word_count() { "---\nIs A: Note\n---\n# Test Note\n\nHello world. This is a test with seven words."; create_test_file(dir.path(), "test.md", content); - let entry = parse_md_file(&dir.path().join("test.md")).unwrap(); + let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap(); assert_eq!(entry.word_count, 9); } @@ -257,7 +257,7 @@ fn test_parse_md_file_word_count_empty_body() { let content = "---\nIs A: Note\n---\n# Empty Note\n"; create_test_file(dir.path(), "test.md", content); - let entry = parse_md_file(&dir.path().join("test.md")).unwrap(); + let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap(); assert_eq!(entry.word_count, 0); } @@ -278,7 +278,7 @@ Status: Active "#; create_test_file(dir.path(), "publish-essays.md", content); - let entry = parse_md_file(&dir.path().join("publish-essays.md")).unwrap(); + let entry = parse_md_file(&dir.path().join("publish-essays.md"), None).unwrap(); assert_eq!(entry.relationships.len(), 3); // Has, Topics, Type assert_eq!( entry.relationships.get("Has").unwrap(), @@ -310,7 +310,7 @@ Belongs to: "#; create_test_file(dir.path(), "some-project.md", content); - let entry = parse_md_file(&dir.path().join("some-project.md")).unwrap(); + let entry = parse_md_file(&dir.path().join("some-project.md"), None).unwrap(); // Owner with wikilink should appear in relationships assert!(entry.relationships.get("Owner").is_some()); @@ -342,7 +342,7 @@ Custom Field: just a plain string "#; create_test_file(dir.path(), "plain-note.md", content); - let entry = parse_md_file(&dir.path().join("plain-note.md")).unwrap(); + let entry = parse_md_file(&dir.path().join("plain-note.md"), None).unwrap(); // Tags and Custom Field don't contain wikilinks — only the auto-generated "Type" relationship assert_eq!(entry.relationships.len(), 1); assert_eq!( @@ -404,7 +404,7 @@ Context: "[[area/research]]" "#; create_test_file(dir.path(), "single-vs-array.md", content); - let entry = parse_md_file(&dir.path().join("single-vs-array.md")).unwrap(); + let entry = parse_md_file(&dir.path().join("single-vs-array.md"), None).unwrap(); // Single string → Vec with one element assert_eq!( @@ -481,7 +481,7 @@ References: "#; create_test_file(dir.path(), "mixed-array.md", content); - let entry = parse_md_file(&dir.path().join("mixed-array.md")).unwrap(); + let entry = parse_md_file(&dir.path().join("mixed-array.md"), None).unwrap(); // Only the wikilink entries should be captured assert_eq!( @@ -545,7 +545,7 @@ title: No Code # No Code "#; create_test_file(dir.path(), "no-code.md", content); - let entry = parse_md_file(&dir.path().join("no-code.md")).unwrap(); + let entry = parse_md_file(&dir.path().join("no-code.md"), None).unwrap(); let notes = entry .relationships @@ -572,7 +572,7 @@ title: No Code fn test_type_from_frontmatter_only() { let dir = TempDir::new().unwrap(); create_test_file(dir.path(), "test.md", "---\ntype: Custom\n---\n# Test\n"); - let entry = parse_md_file(&dir.path().join("test.md")).unwrap(); + let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap(); assert_eq!(entry.is_a, Some("Custom".to_string())); } @@ -580,7 +580,7 @@ fn test_type_from_frontmatter_only() { fn test_no_type_when_frontmatter_missing() { let dir = TempDir::new().unwrap(); create_test_file(dir.path(), "note/test.md", "# Test\n"); - let entry = parse_md_file(&dir.path().join("note/test.md")).unwrap(); + let entry = parse_md_file(&dir.path().join("note/test.md"), None).unwrap(); assert_eq!(entry.is_a, None, "type should not be inferred from folder"); } @@ -592,7 +592,7 @@ fn test_created_at_from_filesystem() { let content = "---\nIs A: Note\n---\n# Test\n"; create_test_file(dir.path(), "test.md", content); - let entry = parse_md_file(&dir.path().join("test.md")).unwrap(); + let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap(); // created_at should be set from filesystem metadata (not None) assert!( entry.created_at.is_some(), @@ -1168,7 +1168,7 @@ fn test_parse_real_engineering_management_file() { if !path.exists() { return; // Skip when the Laputa vault is not available } - let entry = parse_md_file(path).unwrap(); + let entry = parse_md_file(path, None).unwrap(); assert!( entry.trashed, "engineering-management.md must be trashed (has Trashed: true in frontmatter)" @@ -1204,7 +1204,7 @@ fn test_fallback_parser_extracts_trashed_from_malformed_yaml() { ]; let content = fm.join("\n"); create_test_file(dir.path(), "eng-mgmt.md", &content); - let entry = parse_md_file(&dir.path().join("eng-mgmt.md")).unwrap(); + let entry = parse_md_file(&dir.path().join("eng-mgmt.md"), None).unwrap(); assert!( entry.trashed, "Trashed must be true even when YAML is malformed (fallback parser)" @@ -1231,7 +1231,7 @@ fn test_fallback_parser_extracts_archived_from_malformed_yaml() { ]; let content = fm.join("\n"); create_test_file(dir.path(), "archived-essay.md", &content); - let entry = parse_md_file(&dir.path().join("archived-essay.md")).unwrap(); + let entry = parse_md_file(&dir.path().join("archived-essay.md"), None).unwrap(); assert!( entry.archived, "Archived must be true even when YAML is malformed"