feat: use git history for note creation/modification dates

Replace unreliable filesystem ctime/mtime with git log timestamps.
A single batch `git log` walks the full commit history to extract
created_at (oldest commit) and modified_at (newest commit) for each
.md file. Falls back to filesystem dates for non-git vaults and
uncommitted files. Cache version bumped to 10 for full rescan.

ADR 0039 documents the decision.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test
2026-04-02 15:00:22 +02:00
parent 990470e5b4
commit e2d0a46608
7 changed files with 389 additions and 47 deletions

View File

@@ -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<String, GitDates>` 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.

233
src-tauri/src/git/dates.rs Normal file
View File

@@ -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<String, GitDates> {
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<String, GitDates> {
let mut map: HashMap<String, GitDates> = HashMap::new();
let mut current_ts: Option<u64> = 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<u64> {
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());
}
}

View File

@@ -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::{

View File

@@ -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<VaultEntry> {
fn parse_files_at(
vault: &Path,
rel_paths: &[String],
git_dates: &HashMap<String, GitDates>,
) -> Vec<VaultEntry> {
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<VaultEntry>, 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<VaultEntry> {
fn update_same_commit(
vault: &Path,
cache: VaultCache,
git_dates: &HashMap<String, GitDates>,
) -> Vec<VaultEntry> {
let changed = git_uncommitted_files(vault);
let mut entries = cache.entries;
if !changed.is_empty() {
let changed_set: std::collections::HashSet<String> = 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<String, GitDates>,
) -> Vec<VaultEntry> {
let changed_files = git_changed_files(vault, &cache.commit_hash, &current_hash);
let changed_set: std::collections::HashSet<String> = 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<Vec<VaultEntry>, 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
};

View File

@@ -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

View File

@@ -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<VaultEntry, String> {
///
/// 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<VaultEntry, String> {
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<VaultEntry, String> {
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<VaultEntry, String> {
}
/// 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<VaultEntry, String> {
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<VaultEntry>) {
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<String, GitDates>,
) -> 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<String, GitDates>,
entries: &mut Vec<VaultEntry>,
) {
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<VaultEntry>) {
/// 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<VaultEntry>) {
fn scan_all_md_files(
vault_path: &Path,
git_dates: &HashMap<String, GitDates>,
entries: &mut Vec<VaultEntry>,
) {
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<VaultEntry>) {
});
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<Vec<VaultEntry>, String> {
/// Pass an empty map for `git_dates` to use filesystem dates only.
pub fn scan_vault(
vault_path: &Path,
git_dates: &HashMap<String, GitDates>,
) -> Result<Vec<VaultEntry>, 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<Vec<VaultEntry>, 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)

View File

@@ -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"