Files
tolaria/src-tauri/src/vault.rs
2026-02-23 20:48:22 +01:00

2692 lines
88 KiB
Rust

use gray_matter::engine::YAML;
use gray_matter::Matter;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::time::UNIX_EPOCH;
use walkdir::WalkDir;
use crate::frontmatter::{update_frontmatter_content, with_frontmatter};
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct VaultEntry {
pub path: String,
pub filename: String,
pub title: String,
#[serde(rename = "isA")]
pub is_a: Option<String>,
pub aliases: Vec<String>,
#[serde(rename = "belongsTo")]
pub belongs_to: Vec<String>,
#[serde(rename = "relatedTo")]
pub related_to: Vec<String>,
pub status: Option<String>,
pub owner: Option<String>,
pub cadence: Option<String>,
pub archived: bool,
pub trashed: bool,
#[serde(rename = "trashedAt")]
pub trashed_at: Option<u64>,
#[serde(rename = "modifiedAt")]
pub modified_at: Option<u64>,
#[serde(rename = "createdAt")]
pub created_at: Option<u64>,
#[serde(rename = "fileSize")]
pub file_size: u64,
pub snippet: String,
/// Generic relationship fields: any frontmatter key whose value contains wikilinks.
/// Key is the original frontmatter field name (e.g. "Has", "Topics", "Events").
pub relationships: HashMap<String, Vec<String>>,
/// Phosphor icon name (kebab-case) for Type entries, e.g. "cooking-pot".
pub icon: Option<String>,
/// Accent color key for Type entries: "red", "purple", "blue", "green", "yellow", "orange".
pub color: Option<String>,
/// Display order for Type entries in sidebar (lower = higher). None = use default order.
pub order: Option<i64>,
}
/// Intermediate struct to capture YAML frontmatter fields.
#[derive(Debug, Deserialize, Default)]
struct Frontmatter {
/// Legacy "Is A" / "is_a" field — migrated to `type` in new notes.
#[serde(rename = "Is A", alias = "is_a")]
is_a: Option<StringOrList>,
/// New canonical "type" field (preferred over is_a).
#[serde(default, rename = "type")]
type_field: Option<StringOrList>,
#[serde(default)]
aliases: Option<StringOrList>,
#[serde(rename = "Belongs to")]
belongs_to: Option<StringOrList>,
#[serde(rename = "Related to")]
related_to: Option<StringOrList>,
#[serde(rename = "Status")]
status: Option<String>,
#[serde(rename = "Owner")]
owner: Option<String>,
#[serde(rename = "Cadence")]
cadence: Option<String>,
#[serde(rename = "Archived")]
archived: Option<bool>,
#[serde(rename = "Trashed")]
trashed: Option<bool>,
#[serde(rename = "Trashed at")]
trashed_at: Option<String>,
#[serde(rename = "Created at")]
created_at: Option<String>,
#[serde(rename = "Created time")]
created_time: Option<String>,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
color: Option<String>,
#[serde(default)]
order: Option<i64>,
}
/// Handles YAML fields that can be either a single string or a list of strings.
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
enum StringOrList {
Single(String),
List(Vec<String>),
}
impl StringOrList {
fn into_vec(self) -> Vec<String> {
match self {
StringOrList::Single(s) => vec![s],
StringOrList::List(v) => v,
}
}
}
/// Extract the title from a markdown file's content.
/// Tries the first H1 heading (`# Title`), falls back to filename without extension.
fn extract_title(content: &str, filename: &str) -> String {
for line in content.lines() {
let trimmed = line.trim();
if let Some(heading) = trimmed.strip_prefix("# ") {
let title = heading.trim();
if !title.is_empty() {
return title.to_string();
}
}
}
// Fallback: filename without .md extension
filename.strip_suffix(".md").unwrap_or(filename).to_string()
}
/// Extract a snippet: first ~160 chars of content after frontmatter/title, stripped of markdown.
fn extract_snippet(content: &str) -> String {
// Remove frontmatter
let without_fm = if let Some(rest) = content.strip_prefix("---") {
if let Some(end) = rest.find("---") {
rest[end + 3..].trim_start()
} else {
content
}
} else {
content
};
// Skip the first H1 heading line
let without_h1 = if let Some(rest) = without_h1_line(without_fm) {
rest
} else {
without_fm
};
// Strip markdown formatting and collapse whitespace
let clean: String = without_h1
.lines()
.filter(|line| {
let t = line.trim();
// Skip blank lines, headings, code fences, horizontal rules
!t.is_empty() && !t.starts_with('#') && !t.starts_with("```") && !t.starts_with("---")
})
.collect::<Vec<&str>>()
.join(" ");
let stripped = strip_markdown_chars(&clean);
if stripped.len() > 160 {
// Find last valid char boundary at or before 160 (floor_char_boundary requires Rust 1.91)
let mut idx = 160;
while idx > 0 && !stripped.is_char_boundary(idx) {
idx -= 1;
}
format!("{}...", &stripped[..idx])
} else {
stripped
}
}
fn without_h1_line(s: &str) -> Option<&str> {
for (i, line) in s.lines().enumerate() {
if line.trim().starts_with("# ") {
// Return everything after this line
let offset: usize = s.lines().take(i + 1).map(|l| l.len() + 1).sum();
return Some(&s[offset.min(s.len())..]);
}
// If we hit non-empty non-heading content first, there's no H1 to skip
if !line.trim().is_empty() {
return None;
}
}
None
}
/// Collect chars until a delimiter, returning the collected string.
fn collect_until(chars: &mut impl Iterator<Item = char>, delimiter: char) -> String {
let mut buf = String::new();
for c in chars.by_ref() {
if c == delimiter {
break;
}
buf.push(c);
}
buf
}
/// Skip all chars until a delimiter (consuming the delimiter).
fn skip_until(chars: &mut impl Iterator<Item = char>, delimiter: char) {
for c in chars.by_ref() {
if c == delimiter {
break;
}
}
}
/// Check if a char is markdown formatting that should be stripped.
fn is_markdown_formatting(ch: char) -> bool {
matches!(ch, '*' | '_' | '`' | '~')
}
fn strip_markdown_chars(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'[' => {
let inner = collect_until(&mut chars, ']');
if chars.peek() == Some(&'(') {
chars.next();
skip_until(&mut chars, ')');
}
result.push_str(&inner);
}
c if is_markdown_formatting(c) => {}
_ => result.push(ch),
}
}
result
}
/// Parse frontmatter from raw YAML data extracted by gray_matter.
fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
// Convert HashMap to serde_json::Value for deserialization
let value =
serde_json::Value::Object(data.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
serde_json::from_value(value).unwrap_or_default()
}
/// Known non-relationship frontmatter keys to skip (case-insensitive comparison).
/// Only skip keys that can never contain wikilinks.
const SKIP_KEYS: &[&str] = &[
"is a",
"is_a",
"type",
"aliases",
"status",
"cadence",
"archived",
"trashed",
"trashed at",
"created at",
"created time",
"icon",
"color",
"order",
];
/// Check if a string contains a wikilink pattern `[[...]]`.
fn contains_wikilink(s: &str) -> bool {
s.contains("[[") && s.contains("]]")
}
/// Extract all wikilink-containing fields from raw YAML frontmatter.
/// Returns a HashMap where each key is the original frontmatter field name
/// and the value is a Vec of wikilink strings found in that field.
/// Handles both single string values and arrays of strings.
fn extract_relationships(
data: &HashMap<String, serde_json::Value>,
) -> HashMap<String, Vec<String>> {
let mut relationships = HashMap::new();
for (key, value) in data {
// Skip known non-relationship keys
if SKIP_KEYS.iter().any(|k| k.eq_ignore_ascii_case(key)) {
continue;
}
match value {
serde_json::Value::String(s) => {
if contains_wikilink(s) {
relationships.insert(key.clone(), vec![s.clone()]);
}
}
serde_json::Value::Array(arr) => {
let wikilinks: Vec<String> = arr
.iter()
.filter_map(|v| v.as_str())
.filter(|s| contains_wikilink(s))
.map(|s| s.to_string())
.collect();
if !wikilinks.is_empty() {
relationships.insert(key.clone(), wikilinks);
}
}
_ => {}
}
}
relationships
}
/// Infer entity type from a parent folder name.
fn infer_type_from_folder(folder: &str) -> String {
match folder {
"person" => "Person",
"project" => "Project",
"procedure" => "Procedure",
"responsibility" => "Responsibility",
"event" => "Event",
"topic" => "Topic",
"experiment" => "Experiment",
"type" => "Type",
"note" => "Note",
"quarter" => "Quarter",
"measure" => "Measure",
"target" => "Target",
"journal" => "Journal",
"month" => "Month",
"essay" => "Essay",
"evergreen" => "Evergreen",
_ => return capitalize_first(folder),
}
.to_string()
}
/// Resolve note type: prefer `type` field, fall back to `is_a`, then parent folder inference.
fn resolve_type(
type_field: Option<StringOrList>,
is_a: Option<StringOrList>,
path: &Path,
) -> Option<String> {
type_field
.or(is_a)
.and_then(|a| a.into_vec().into_iter().next())
.or_else(|| {
path.parent()
.and_then(|p| p.file_name())
.map(|f| infer_type_from_folder(&f.to_string_lossy()))
})
}
/// Parse created_at from frontmatter (prefer "Created at" over "Created time").
fn parse_created_at(fm: &Frontmatter) -> Option<u64> {
fm.created_at
.as_ref()
.and_then(|s| parse_iso_date(s))
.or_else(|| fm.created_time.as_ref().and_then(|s| parse_iso_date(s)))
}
/// Extract frontmatter and relationships from parsed gray_matter data.
fn extract_fm_and_rels(
data: Option<gray_matter::Pod>,
) -> (Frontmatter, HashMap<String, Vec<String>>) {
let hash = match data {
Some(gray_matter::Pod::Hash(map)) => map,
_ => return (Frontmatter::default(), HashMap::new()),
};
let json_map: HashMap<String, serde_json::Value> =
hash.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect();
(
parse_frontmatter(&json_map),
extract_relationships(&json_map),
)
}
/// Read file metadata (modified_at timestamp, file size).
fn read_file_metadata(path: &Path) -> Result<(Option<u64>, u64), String> {
let metadata =
fs::metadata(path).map_err(|e| format!("Failed to stat {}: {}", path.display(), e))?;
let modified_at = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs());
Ok((modified_at, metadata.len()))
}
/// Parse a single markdown file into a VaultEntry.
pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let filename = path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let matter = Matter::<YAML>::new();
let parsed = matter.parse(&content);
let (frontmatter, mut relationships) = extract_fm_and_rels(parsed.data);
let title = extract_title(&parsed.content, &filename);
let snippet = extract_snippet(&content);
let (modified_at, file_size) = read_file_metadata(path)?;
let created_at = parse_created_at(&frontmatter);
let is_a = resolve_type(frontmatter.type_field, frontmatter.is_a, path);
// Add "Type" relationship: isA becomes a navigable link to the type document.
// Skip for type documents themselves (isA == "Type") to avoid self-referential links.
if let Some(ref type_name) = is_a {
if type_name != "Type" {
// If isA is already a wikilink (e.g. "[[type/project]]"), use it directly
let type_link = if type_name.starts_with("[[") && type_name.ends_with("]]") {
type_name.clone()
} else {
format!("[[type/{}]]", type_name.to_lowercase())
};
relationships.insert("Type".to_string(), vec![type_link]);
}
}
Ok(VaultEntry {
path: path.to_string_lossy().to_string(),
filename,
title,
is_a,
snippet,
relationships,
aliases: frontmatter
.aliases
.map(|a| a.into_vec())
.unwrap_or_default(),
belongs_to: frontmatter
.belongs_to
.map(|b| b.into_vec())
.unwrap_or_default(),
related_to: frontmatter
.related_to
.map(|r| r.into_vec())
.unwrap_or_default(),
status: frontmatter.status,
owner: frontmatter.owner,
cadence: frontmatter.cadence,
archived: frontmatter.archived.unwrap_or(false),
trashed: frontmatter.trashed.unwrap_or(false),
trashed_at: frontmatter.trashed_at.as_deref().and_then(parse_iso_date),
modified_at,
created_at,
file_size,
icon: frontmatter.icon,
color: frontmatter.color,
order: frontmatter.order,
})
}
fn capitalize_first(s: &str) -> String {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
}
}
/// Parse an ISO 8601 date string to Unix timestamp (seconds since epoch).
/// Handles "2025-05-23T14:35:00.000Z" and "2025-05-23" formats.
fn parse_iso_date(date_str: &str) -> Option<u64> {
use chrono::{NaiveDate, NaiveDateTime};
let trimmed = date_str.trim().trim_matches('"');
// Try full datetime with optional fractional seconds and Z suffix
if let Ok(dt) = NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.fZ") {
return Some(dt.and_utc().timestamp() as u64);
}
if let Ok(dt) = NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%SZ") {
return Some(dt.and_utc().timestamp() as u64);
}
if let Ok(dt) = NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S") {
return Some(dt.and_utc().timestamp() as u64);
}
// Try date-only
if let Ok(d) = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
return Some(d.and_hms_opt(0, 0, 0)?.and_utc().timestamp() as u64);
}
None
}
/// Convert gray_matter::Pod to serde_json::Value
fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
match pod {
gray_matter::Pod::String(s) => serde_json::Value::String(s),
gray_matter::Pod::Integer(i) => serde_json::json!(i),
gray_matter::Pod::Float(f) => serde_json::json!(f),
gray_matter::Pod::Boolean(b) => serde_json::Value::Bool(b),
gray_matter::Pod::Array(arr) => {
serde_json::Value::Array(arr.into_iter().map(pod_to_json).collect())
}
gray_matter::Pod::Hash(map) => {
let obj: serde_json::Map<String, serde_json::Value> =
map.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect();
serde_json::Value::Object(obj)
}
gray_matter::Pod::Null => serde_json::Value::Null,
}
}
/// Check if a frontmatter section contains legacy `is_a` or `Is A` keys.
fn has_legacy_is_a(fm_content: &str) -> bool {
fm_content.lines().any(|line| {
let t = line.trim_start();
t.starts_with("is_a:")
|| t.starts_with("\"Is A\":")
|| t.starts_with("'Is A':")
|| t.starts_with("Is A:")
})
}
/// Extract the value from a legacy `is_a` / `Is A` line.
fn extract_is_a_value(line: &str) -> Option<&str> {
let t = line.trim_start();
for prefix in &["is_a:", "\"Is A\":", "'Is A':", "Is A:"] {
if let Some(rest) = t.strip_prefix(prefix) {
let v = rest.trim();
return Some(v);
}
}
None
}
/// Migrate a single file's frontmatter from `is_a`/`Is A` to `type`.
/// Returns Ok(true) if the file was modified, Ok(false) if no migration needed.
fn migrate_file_is_a_to_type(path: &Path) -> Result<bool, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
if !content.starts_with("---\n") {
return Ok(false);
}
let fm_end = match content[4..].find("\n---") {
Some(i) => i + 4,
None => return Ok(false),
};
let fm_content = &content[4..fm_end];
if !has_legacy_is_a(fm_content) {
return Ok(false);
}
// Check if `type:` already exists
let has_type = fm_content.lines().any(|line| {
let t = line.trim_start();
t.starts_with("type:")
});
let mut new_lines: Vec<String> = Vec::new();
let mut is_a_value: Option<String> = None;
for line in fm_content.lines() {
if let Some(val) = extract_is_a_value(line) {
is_a_value = Some(val.to_string());
// Skip list continuations after is_a
continue;
}
new_lines.push(line.to_string());
}
// If type: doesn't exist and we found an is_a value, add type:
if !has_type {
if let Some(ref val) = is_a_value {
// Insert type: at the beginning (after other keys is fine too, but beginning is clean)
new_lines.insert(0, format!("type: {}", val));
}
}
let rest = &content[fm_end + 4..];
let new_content = format!("---\n{}\n---{}", new_lines.join("\n"), rest);
fs::write(path, &new_content)
.map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
Ok(true)
}
/// Migrate all markdown files in the vault from `is_a`/`Is A` to `type`.
/// Returns the number of files migrated.
pub fn migrate_is_a_to_type(vault_path: &str) -> Result<usize, String> {
let vault = Path::new(vault_path);
if !vault.exists() || !vault.is_dir() {
return Err(format!(
"Vault path does not exist or is not a directory: {}",
vault_path
));
}
let mut migrated = 0;
for entry in WalkDir::new(vault)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() || path.extension().map(|ext| ext != "md").unwrap_or(true) {
continue;
}
match migrate_file_is_a_to_type(path) {
Ok(true) => {
log::info!("Migrated is_a → type: {}", path.display());
migrated += 1;
}
Ok(false) => {}
Err(e) => {
log::warn!("Failed to migrate {}: {}", path.display(), e);
}
}
}
Ok(migrated)
}
/// Scan all markdown files in the vault and delete those where
/// `Trashed at` frontmatter is more than 30 days ago.
/// Returns the list of deleted file paths.
pub fn purge_trash(vault_path: &str) -> Result<Vec<String>, String> {
use chrono::{NaiveDate, Utc};
let vault = Path::new(vault_path);
if !vault.exists() || !vault.is_dir() {
return Err(format!(
"Vault path does not exist or is not a directory: {}",
vault_path
));
}
let today = Utc::now().date_naive();
let matter = Matter::<YAML>::new();
let mut deleted = Vec::new();
for entry in WalkDir::new(vault)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() || path.extension().map(|ext| ext != "md").unwrap_or(true) {
continue;
}
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
let parsed = matter.parse(&content);
let trashed_at_str = match parsed.data {
Some(gray_matter::Pod::Hash(ref map)) => map
.get("Trashed at")
.or_else(|| map.get("trashed_at"))
.and_then(|v| match v {
gray_matter::Pod::String(s) => Some(s.clone()),
_ => None,
}),
_ => None,
};
if let Some(date_str) = trashed_at_str {
let trimmed = date_str.trim().trim_matches('"');
// Support both "2026-01-01" and "2026-01-01T..." formats
let date_part = trimmed.split('T').next().unwrap_or(trimmed);
if let Ok(trashed_date) = NaiveDate::parse_from_str(date_part, "%Y-%m-%d") {
let age = today.signed_duration_since(trashed_date);
if age.num_days() > 30 {
match fs::remove_file(path) {
Ok(()) => {
log::info!("Purged trashed file: {}", path.display());
deleted.push(path.to_string_lossy().to_string());
}
Err(e) => {
log::warn!("Failed to delete {}: {}", path.display(), e);
}
}
}
}
}
}
Ok(deleted)
}
/// Read the content of a single note file.
pub fn get_note_content(path: &str) -> Result<String, String> {
let file_path = Path::new(path);
if !file_path.exists() {
return Err(format!("File does not exist: {}", path));
}
if !file_path.is_file() {
return Err(format!("Path is not a file: {}", path));
}
fs::read_to_string(file_path).map_err(|e| format!("Failed to read {}: {}", path, e))
}
/// Save the full content (frontmatter + body) of a note to disk.
pub fn save_note_content(path: &str, content: &str) -> Result<(), String> {
let file_path = Path::new(path);
validate_save_path(file_path, path)?;
fs::write(file_path, content).map_err(|e| format!("Failed to save {}: {}", path, e))
}
fn validate_save_path(file_path: &Path, display_path: &str) -> Result<(), String> {
let parent_missing = file_path.parent().is_some_and(|p| !p.exists());
if parent_missing {
return Err(format!(
"Parent directory does not exist: {}",
file_path.parent().unwrap().display()
));
}
let is_readonly = file_path.exists()
&& fs::metadata(file_path)
.map_err(|e| format!("Failed to read file metadata: {}", e))?
.permissions()
.readonly();
if is_readonly {
return Err(format!("File is read-only: {}", display_path));
}
Ok(())
}
/// Scan a directory recursively for .md files and return VaultEntry for each.
pub fn scan_vault(vault_path: &str) -> Result<Vec<VaultEntry>, String> {
let path = Path::new(vault_path);
if !path.exists() {
return Err(format!("Vault path does not exist: {}", vault_path));
}
if !path.is_dir() {
return Err(format!("Vault path is not a directory: {}", vault_path));
}
let mut entries = Vec::new();
for entry in WalkDir::new(path)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let entry_path = entry.path();
if entry_path.is_file()
&& entry_path
.extension()
.map(|ext| ext == "md")
.unwrap_or(false)
{
match parse_md_file(entry_path) {
Ok(vault_entry) => entries.push(vault_entry),
Err(e) => {
log::warn!("Skipping file: {}", e);
}
}
}
}
// Sort by modified date descending (newest first)
entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
Ok(entries)
}
// --- 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, &current_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: &str) -> Result<Vec<VaultEntry>, String> {
let vault = Path::new(vault_path);
if !vault.exists() || !vault.is_dir() {
return Err(format!(
"Vault path does not exist or is not a directory: {}",
vault_path
));
}
let current_hash = match git_head_hash(vault) {
Some(h) => h,
None => return scan_vault(vault_path),
};
if let Some(cache) = load_cache(vault) {
return if cache.commit_hash == current_hash {
Ok(update_same_commit(vault, cache))
} else {
Ok(update_different_commit(vault, cache, current_hash))
};
}
// No cache — full scan and write cache
let entries = scan_vault(vault_path)?;
Ok(finalize_and_cache(vault, entries, current_hash))
}
// Re-export for external consumers
pub use crate::frontmatter::FrontmatterValue;
/// Update a single frontmatter property in a markdown file
pub fn update_frontmatter(
path: &str,
key: &str,
value: FrontmatterValue,
) -> Result<String, String> {
with_frontmatter(path, |content| {
update_frontmatter_content(content, key, Some(value.clone()))
})
}
/// Delete a frontmatter property from a markdown file
pub fn delete_frontmatter_property(path: &str, key: &str) -> Result<String, String> {
with_frontmatter(path, |content| {
update_frontmatter_content(content, key, None)
})
}
/// Check if a character is safe for use in filenames (alphanumeric, dot, dash, underscore).
fn is_safe_filename_char(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '.' | '-' | '_')
}
/// Sanitize a filename by replacing unsafe characters with underscores.
fn sanitize_filename(name: &str) -> String {
name.chars()
.map(|c| if is_safe_filename_char(c) { c } else { '_' })
.collect()
}
/// Save an uploaded image to the vault's attachments directory.
/// Returns the absolute path to the saved file.
pub fn save_image(vault_path: &str, filename: &str, data: &str) -> Result<String, String> {
use base64::Engine;
let vault = Path::new(vault_path);
let attachments_dir = vault.join("attachments");
fs::create_dir_all(&attachments_dir)
.map_err(|e| format!("Failed to create attachments directory: {}", e))?;
// Generate unique filename to avoid collisions
let timestamp = std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let unique_name = format!("{}-{}", timestamp, sanitize_filename(filename));
let target_path = attachments_dir.join(&unique_name);
let bytes = base64::engine::general_purpose::STANDARD
.decode(data)
.map_err(|e| format!("Invalid base64 data: {}", e))?;
fs::write(&target_path, bytes).map_err(|e| format!("Failed to write image: {}", e))?;
Ok(target_path.to_string_lossy().to_string())
}
/// Result of a rename operation
#[derive(Debug, Serialize, Deserialize)]
pub struct RenameResult {
/// New absolute file path after rename
pub new_path: String,
/// Number of other files updated (wiki link replacements)
pub updated_files: usize,
}
/// Convert a title to a filename slug (lowercase, hyphens, no special chars).
fn title_to_slug(title: &str) -> String {
title
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<&str>>()
.join("-")
}
/// Update the first H1 heading in markdown content to a new title.
fn update_h1_title(content: &str, new_title: &str) -> String {
let has_h1 = content.lines().any(|l| l.trim().starts_with("# "));
if !has_h1 {
return content.to_string();
}
let result: Vec<String> = content
.lines()
.map(|l| {
if l.trim().starts_with("# ") {
format!("# {}", new_title)
} else {
l.to_string()
}
})
.collect();
let joined = result.join("\n");
if content.ends_with('\n') && !joined.ends_with('\n') {
format!("{}\n", joined)
} else {
joined
}
}
/// Build a regex that matches wiki links referencing old title or path stem.
fn build_wikilink_pattern(old_title: &str, old_path_stem: &str) -> Option<Regex> {
let pattern_str = format!(
r"\[\[(?:{}|{})(\|[^\]]*?)?\]\]",
regex::escape(old_title),
regex::escape(old_path_stem),
);
Regex::new(&pattern_str).ok()
}
/// Check if a path is a vault markdown file eligible for wikilink replacement.
fn is_replaceable_md_file(path: &Path, exclude: &Path) -> bool {
path.is_file() && path != exclude && path.extension().is_some_and(|ext| ext == "md")
}
/// Replace wikilink references in a single file's content. Returns updated content if changed.
fn replace_wikilinks_in_content(content: &str, re: &Regex, new_title: &str) -> Option<String> {
if !re.is_match(content) {
return None;
}
let replaced = re.replace_all(content, |caps: &regex::Captures| match caps.get(1) {
Some(pipe) => format!("[[{}{}]]", new_title, pipe.as_str()),
None => format!("[[{}]]", new_title),
});
if replaced != content {
Some(replaced.into_owned())
} else {
None
}
}
/// Parameters for a vault-wide wikilink replacement.
struct WikilinkReplacement<'a> {
vault_path: &'a Path,
old_title: &'a str,
new_title: &'a str,
old_path_stem: &'a str,
exclude_path: &'a Path,
}
/// Collect all .md file paths in vault eligible for wikilink replacement.
fn collect_md_files(vault_path: &Path, exclude: &Path) -> Vec<std::path::PathBuf> {
WalkDir::new(vault_path)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
.map(|e| e.into_path())
.filter(|p| is_replaceable_md_file(p, exclude))
.collect()
}
/// Replace wiki link references across all vault markdown files.
fn update_wikilinks_in_vault(params: &WikilinkReplacement) -> usize {
let re = match build_wikilink_pattern(params.old_title, params.old_path_stem) {
Some(r) => r,
None => return 0,
};
let files = collect_md_files(params.vault_path, params.exclude_path);
files
.iter()
.filter(|path| {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return false,
};
match replace_wikilinks_in_content(&content, &re, params.new_title) {
Some(new_content) => fs::write(path, &new_content).is_ok(),
None => false,
}
})
.count()
}
/// Check if frontmatter contains a `title:` key.
fn frontmatter_has_title_key(content: &str) -> bool {
if !content.starts_with("---\n") {
return false;
}
content[4..]
.split("\n---")
.next()
.map(|fm| {
fm.lines().any(|l| {
let t = l.trim_start();
t.starts_with("title:") || t.starts_with("\"title\":")
})
})
.unwrap_or(false)
}
/// Update H1 and optionally the `title:` frontmatter field in content.
fn update_note_title_in_content(content: &str, new_title: &str) -> String {
let mut updated = update_h1_title(content, new_title);
if frontmatter_has_title_key(content) {
let value = crate::frontmatter::FrontmatterValue::String(new_title.to_string());
if let Ok(c) = update_frontmatter_content(&updated, "title", Some(value)) {
updated = c;
}
}
updated
}
/// Strip vault prefix and .md suffix to get the relative path stem (e.g., "project/weekly-review").
fn to_path_stem<'a>(abs_path: &'a str, vault_prefix: &str) -> &'a str {
abs_path
.strip_prefix(vault_prefix)
.unwrap_or(abs_path)
.strip_suffix(".md")
.unwrap_or(abs_path)
}
/// Rename a note: update its title, rename the file, and update wiki links across the vault.
pub fn rename_note(
vault_path: &str,
old_path: &str,
new_title: &str,
) -> Result<RenameResult, String> {
let vault = Path::new(vault_path);
let old_file = Path::new(old_path);
if !old_file.exists() {
return Err(format!("File does not exist: {}", old_path));
}
let new_title = new_title.trim();
if new_title.is_empty() {
return Err("New title cannot be empty".to_string());
}
let content =
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
let old_filename = old_file
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let old_title = extract_title(&content, &old_filename);
if old_title == new_title {
return Ok(RenameResult {
new_path: old_path.to_string(),
updated_files: 0,
});
}
// Update content (H1 + frontmatter title)
let updated_content = update_note_title_in_content(&content, new_title);
// Compute new path and write file
let parent_dir = old_file
.parent()
.ok_or("Cannot determine parent directory")?;
let new_file = parent_dir.join(format!("{}.md", title_to_slug(new_title)));
let new_path_str = new_file.to_string_lossy().to_string();
fs::write(&new_file, &updated_content)
.map_err(|e| format!("Failed to write {}: {}", new_path_str, e))?;
if old_file != new_file {
fs::remove_file(old_file)
.map_err(|e| format!("Failed to remove old file {}: {}", old_path, e))?;
}
// Update wikilinks across the vault
let vault_prefix = format!("{}/", vault.to_string_lossy());
let old_path_stem = to_path_stem(old_path, &vault_prefix);
let updated_files = update_wikilinks_in_vault(&WikilinkReplacement {
vault_path: vault,
old_title: &old_title,
new_title,
old_path_stem,
exclude_path: &new_file,
});
Ok(RenameResult {
new_path: new_path_str,
updated_files,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
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_extract_title_from_h1() {
let content = "---\nIs A: Note\n---\n# My Great Note\n\nSome content here.";
assert_eq!(extract_title(content, "my-great-note.md"), "My Great Note");
}
#[test]
fn test_extract_title_fallback_to_filename() {
let content = "Just some content without a heading.";
assert_eq!(
extract_title(content, "fallback-title.md"),
"fallback-title"
);
}
#[test]
fn test_extract_title_empty_h1_falls_back() {
let content = "# \n\nSome content.";
assert_eq!(extract_title(content, "empty-h1.md"), "empty-h1");
}
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()
}
const FULL_FM_CONTENT: &str = "---\nIs A: Project\naliases:\n - Laputa\n - Castle in the Sky\nBelongs to:\n - Studio Ghibli\nRelated to:\n - Miyazaki\nStatus: Active\nOwner: Luca\nCadence: Weekly\n---\n# Laputa Project\n\nThis is a project note.\n";
#[test]
fn test_parse_full_frontmatter_identity() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(&dir, "laputa.md", FULL_FM_CONTENT);
assert_eq!(entry.title, "Laputa Project");
assert_eq!(entry.is_a, Some("Project".to_string()));
assert_eq!(entry.filename, "laputa.md");
}
#[test]
fn test_parse_full_frontmatter_lists() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(&dir, "laputa.md", FULL_FM_CONTENT);
assert_eq!(entry.aliases, vec!["Laputa", "Castle in the Sky"]);
assert_eq!(entry.belongs_to, vec!["Studio Ghibli"]);
assert_eq!(entry.related_to, vec!["Miyazaki"]);
}
#[test]
fn test_parse_full_frontmatter_scalars() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(&dir, "laputa.md", FULL_FM_CONTENT);
assert_eq!(entry.status, Some("Active".to_string()));
assert_eq!(entry.owner, Some("Luca".to_string()));
assert_eq!(entry.cadence, Some("Weekly".to_string()));
}
#[test]
fn test_parse_empty_frontmatter() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"empty-fm.md",
"---\n---\n# Just a Title\n\nNo frontmatter fields.",
);
assert_eq!(entry.title, "Just a Title");
assert!(entry.aliases.is_empty());
assert!(entry.belongs_to.is_empty());
assert_eq!(entry.status, None);
}
#[test]
fn test_parse_no_frontmatter() {
let dir = TempDir::new().unwrap();
let content = "# A Note Without Frontmatter\n\nJust markdown.";
create_test_file(dir.path(), "no-fm.md", content);
let entry = parse_md_file(&dir.path().join("no-fm.md")).unwrap();
assert_eq!(entry.title, "A Note Without Frontmatter");
// is_a is inferred from parent folder name (temp dir), not None
}
#[test]
fn test_parse_single_string_aliases() {
let dir = TempDir::new().unwrap();
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();
assert_eq!(entry.aliases, vec!["SingleAlias"]);
}
#[test]
fn test_scan_vault_recursive() {
let dir = TempDir::new().unwrap();
create_test_file(dir.path(), "root.md", "# Root Note\n");
create_test_file(
dir.path(),
"sub/nested.md",
"---\nIs A: Task\n---\n# Nested\n",
);
create_test_file(dir.path(), "not-markdown.txt", "This should be ignored");
let entries = scan_vault(dir.path().to_str().unwrap()).unwrap();
assert_eq!(entries.len(), 2);
let filenames: Vec<&str> = entries.iter().map(|e| e.filename.as_str()).collect();
assert!(filenames.contains(&"root.md"));
assert!(filenames.contains(&"nested.md"));
}
#[test]
fn test_scan_vault_nonexistent_path() {
let result = scan_vault("/nonexistent/path/that/does/not/exist");
assert!(result.is_err());
}
#[test]
fn test_parse_malformed_yaml() {
let dir = TempDir::new().unwrap();
// Malformed YAML — gray_matter should handle this gracefully
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"));
// Should still succeed — gray_matter may parse partially or skip
assert!(entry.is_ok());
}
#[test]
fn test_get_note_content() {
let dir = TempDir::new().unwrap();
let content = "---\nIs A: Note\n---\n# Test Note\n\nHello, world!";
create_test_file(dir.path(), "test.md", content);
let path = dir.path().join("test.md");
let result = get_note_content(path.to_str().unwrap());
assert!(result.is_ok());
assert_eq!(result.unwrap(), content);
}
#[test]
fn test_get_note_content_nonexistent() {
let result = get_note_content("/nonexistent/path/file.md");
assert!(result.is_err());
}
#[test]
fn test_extract_snippet_basic() {
let content = "---\nIs A: Note\n---\n# My Note\n\nThis is the first paragraph of content.\n\n## Section Two\n\nMore content here.";
let snippet = extract_snippet(content);
assert!(snippet.starts_with("This is the first paragraph"));
assert!(snippet.contains("More content here"));
}
#[test]
fn test_extract_snippet_strips_markdown() {
let content = "# Title\n\nSome **bold** and *italic* and `code` text.";
let snippet = extract_snippet(content);
assert_eq!(snippet, "Some bold and italic and code text.");
}
#[test]
fn test_extract_snippet_strips_links() {
let content = "# Title\n\nSee [this link](https://example.com) and [[wiki link]].";
let snippet = extract_snippet(content);
assert!(snippet.contains("this link"));
assert!(!snippet.contains("https://example.com"));
}
#[test]
fn test_extract_snippet_truncates() {
let long_content = format!("# Title\n\n{}", "word ".repeat(100));
let snippet = extract_snippet(&long_content);
assert!(snippet.len() <= 165); // 160 + "..."
assert!(snippet.ends_with("..."));
}
#[test]
fn test_extract_snippet_no_content() {
let content = "---\nIs A: Note\n---\n# Just a Title\n";
let snippet = extract_snippet(content);
assert_eq!(snippet, "");
}
#[test]
fn test_parse_md_file_has_snippet() {
let dir = TempDir::new().unwrap();
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();
assert_eq!(entry.snippet, "Hello, world! This is a snippet.");
}
#[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().to_str().unwrap()).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.to_str().unwrap()).unwrap();
assert_eq!(entries.len(), 1);
assert!(cache_path(vault).exists());
// Second call: uses cache (same HEAD)
let entries2 = scan_vault_cached(vault.to_str().unwrap()).unwrap();
assert_eq!(entries2.len(), 1);
assert_eq!(entries2[0].title, "Note");
}
#[test]
fn test_parse_relationships_array() {
let dir = TempDir::new().unwrap();
let content = r#"---
Is A: Responsibility
Has:
- "[[essay/foo|Foo Essay]]"
- "[[essay/bar|Bar Essay]]"
Topics:
- "[[topic/rust]]"
- "[[topic/wasm]]"
Status: Active
---
# Publish Essays
"#;
create_test_file(dir.path(), "publish-essays.md", content);
let entry = parse_md_file(&dir.path().join("publish-essays.md")).unwrap();
assert_eq!(entry.relationships.len(), 3); // Has, Topics, Type
assert_eq!(
entry.relationships.get("Has").unwrap(),
&vec![
"[[essay/foo|Foo Essay]]".to_string(),
"[[essay/bar|Bar Essay]]".to_string()
]
);
assert_eq!(
entry.relationships.get("Topics").unwrap(),
&vec!["[[topic/rust]]".to_string(), "[[topic/wasm]]".to_string()]
);
assert_eq!(
entry.relationships.get("Type").unwrap(),
&vec!["[[type/responsibility]]".to_string()]
);
}
#[test]
fn test_parse_relationships_single_string() {
let dir = TempDir::new().unwrap();
let content = r#"---
Is A: Project
Owner: "[[person/luca-rossi|Luca Rossi]]"
Belongs to:
- "[[responsibility/grow-newsletter]]"
---
# Some Project
"#;
create_test_file(dir.path(), "some-project.md", content);
let entry = parse_md_file(&dir.path().join("some-project.md")).unwrap();
// Owner contains a wikilink, so it should appear in relationships
assert_eq!(
entry.relationships.get("Owner").unwrap(),
&vec!["[[person/luca-rossi|Luca Rossi]]".to_string()]
);
// Belongs to is also a wikilink array, should appear in relationships
assert_eq!(
entry.relationships.get("Belongs to").unwrap(),
&vec!["[[responsibility/grow-newsletter]]".to_string()]
);
// Still parsed in the dedicated field too
assert_eq!(entry.belongs_to, vec!["[[responsibility/grow-newsletter]]"]);
}
#[test]
fn test_parse_relationships_ignores_non_wikilinks() {
let dir = TempDir::new().unwrap();
let content = r#"---
Is A: Note
Status: Active
Tags:
- productivity
- writing
Custom Field: just a plain string
---
# A Note
"#;
create_test_file(dir.path(), "plain-note.md", content);
let entry = parse_md_file(&dir.path().join("plain-note.md")).unwrap();
// Tags and Custom Field don't contain wikilinks — only the auto-generated "Type" relationship
assert_eq!(entry.relationships.len(), 1);
assert_eq!(
entry.relationships.get("Type").unwrap(),
&vec!["[[type/note]]".to_string()]
);
}
const BIG_PROJECT_CONTENT: &str = "---\nIs A: Project\nHas:\n - \"[[deliverable/mvp]]\"\n - \"[[deliverable/v2]]\"\nTopics:\n - \"[[topic/ai]]\"\n - \"[[topic/compilers]]\"\nEvents:\n - \"[[event/launch-day]]\"\nNotes:\n - \"[[note/design-rationale]]\"\n - \"[[note/meeting-2024-01]]\"\n - \"[[note/meeting-2024-02]]\"\nOwner: \"[[person/alice]]\"\nRelated to:\n - \"[[project/sibling-project]]\"\nBelongs to:\n - \"[[area/engineering]]\"\nStatus: Active\n---\n# Big Project\n";
fn parse_big_project_rels() -> HashMap<String, Vec<String>> {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(&dir, "big-project.md", BIG_PROJECT_CONTENT);
entry.relationships
}
#[test]
fn test_parse_relationships_custom_fields() {
let rels = parse_big_project_rels();
assert_eq!(rels.get("Has").unwrap().len(), 2);
assert_eq!(rels.get("Topics").unwrap().len(), 2);
assert_eq!(rels.get("Events").unwrap().len(), 1);
}
#[test]
fn test_parse_relationships_owner_and_notes() {
let rels = parse_big_project_rels();
assert_eq!(rels.get("Notes").unwrap().len(), 3);
assert_eq!(
rels.get("Owner").unwrap(),
&vec!["[[person/alice]]".to_string()]
);
}
#[test]
fn test_parse_relationships_builtin_wikilink_fields() {
let rels = parse_big_project_rels();
assert_eq!(rels.get("Related to").unwrap().len(), 1);
assert_eq!(rels.get("Belongs to").unwrap().len(), 1);
}
#[test]
fn test_parse_relationships_skip_keys_excluded_from_generic() {
let rels = parse_big_project_rels();
assert!(rels.get("Status").is_none());
assert!(rels.get("Is A").is_none());
}
#[test]
fn test_parse_relationships_single_vs_array_wikilinks() {
// Verifies both single wikilink strings and arrays are parsed correctly.
let dir = TempDir::new().unwrap();
let content = r#"---
Mentor: "[[person/bob|Bob Smith]]"
Reviewers:
- "[[person/carol]]"
- "[[person/dave]]"
Context: "[[area/research]]"
---
# A Note
"#;
create_test_file(dir.path(), "single-vs-array.md", content);
let entry = parse_md_file(&dir.path().join("single-vs-array.md")).unwrap();
// Single string → Vec with one element
assert_eq!(
entry.relationships.get("Mentor").unwrap(),
&vec!["[[person/bob|Bob Smith]]".to_string()]
);
// Array → Vec with multiple elements
assert_eq!(
entry.relationships.get("Reviewers").unwrap(),
&vec![
"[[person/carol]]".to_string(),
"[[person/dave]]".to_string()
]
);
// Another single string
assert_eq!(
entry.relationships.get("Context").unwrap(),
&vec!["[[area/research]]".to_string()]
);
}
const SKIP_KEYS_CONTENT: &str = "---\nIs A: \"[[type/project]]\"\nAliases:\n - \"[[alias/foo]]\"\nStatus: \"[[status/active]]\"\nCadence: \"[[cadence/weekly]]\"\nCreated at: \"[[time/2024-01-01]]\"\nCreated time: \"[[time/noon]]\"\nReal Relation: \"[[note/important]]\"\n---\n# Skip Keys Test\n";
fn parse_skip_keys_rels() -> (HashMap<String, Vec<String>>, usize) {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(&dir, "skip-keys.md", SKIP_KEYS_CONTENT);
let len = entry.relationships.len();
(entry.relationships, len)
}
#[test]
fn test_skip_keys_identity_fields_excluded() {
let (rels, _) = parse_skip_keys_rels();
assert!(rels.get("Is A").is_none());
assert!(rels.get("Aliases").is_none());
assert!(rels.get("Status").is_none());
}
#[test]
fn test_skip_keys_temporal_fields_excluded() {
let (rels, _) = parse_skip_keys_rels();
assert!(rels.get("Cadence").is_none());
assert!(rels.get("Created at").is_none());
assert!(rels.get("Created time").is_none());
}
#[test]
fn test_skip_keys_real_relation_included() {
let (rels, len) = parse_skip_keys_rels();
assert_eq!(
rels.get("Real Relation").unwrap(),
&vec!["[[note/important]]".to_string()]
);
// "Real Relation" + auto-generated "Type" (from is_a: "[[type/project]]")
assert_eq!(len, 2);
assert_eq!(
rels.get("Type").unwrap(),
&vec!["[[type/project]]".to_string()]
);
}
#[test]
fn test_parse_relationships_mixed_wikilinks_and_plain_in_array() {
// Verifies that within an array, only wikilink entries are kept.
let dir = TempDir::new().unwrap();
let content = r#"---
References:
- "[[source/paper-a]]"
- "just a plain string"
- "[[source/paper-b]]"
- "no links here"
---
# Mixed Array
"#;
create_test_file(dir.path(), "mixed-array.md", content);
let entry = parse_md_file(&dir.path().join("mixed-array.md")).unwrap();
// Only the wikilink entries should be captured
assert_eq!(
entry.relationships.get("References").unwrap(),
&vec![
"[[source/paper-a]]".to_string(),
"[[source/paper-b]]".to_string()
]
);
}
// --- parse_iso_date tests ---
#[test]
fn test_parse_iso_date_full_datetime_with_z() {
let ts = parse_iso_date("2025-05-23T14:35:00.000Z");
assert!(ts.is_some());
assert_eq!(ts.unwrap(), 1748010900);
}
#[test]
fn test_parse_iso_date_datetime_no_fractional() {
let ts = parse_iso_date("2025-05-23T14:35:00Z");
assert!(ts.is_some());
assert_eq!(ts.unwrap(), 1748010900);
}
#[test]
fn test_parse_iso_date_datetime_no_z() {
let ts = parse_iso_date("2025-05-23T14:35:00");
assert!(ts.is_some());
assert_eq!(ts.unwrap(), 1748010900);
}
#[test]
fn test_parse_iso_date_date_only() {
let ts = parse_iso_date("2025-05-23");
assert!(ts.is_some());
assert_eq!(ts.unwrap(), 1747958400); // midnight UTC
}
#[test]
fn test_parse_iso_date_with_quotes_and_whitespace() {
let ts = parse_iso_date(" \"2025-05-23\" ");
assert!(ts.is_some());
assert_eq!(ts.unwrap(), 1747958400);
}
#[test]
fn test_parse_iso_date_invalid() {
assert!(parse_iso_date("not-a-date").is_none());
assert!(parse_iso_date("").is_none());
assert!(parse_iso_date("2025-13-45").is_none());
}
// --- strip_markdown_chars tests ---
#[test]
fn test_strip_markdown_chars_plain_text() {
assert_eq!(strip_markdown_chars("hello world"), "hello world");
}
#[test]
fn test_strip_markdown_chars_emphasis() {
assert_eq!(
strip_markdown_chars("**bold** and *italic*"),
"bold and italic"
);
}
#[test]
fn test_strip_markdown_chars_backticks() {
assert_eq!(strip_markdown_chars("use `code` here"), "use code here");
}
#[test]
fn test_strip_markdown_chars_strikethrough() {
assert_eq!(strip_markdown_chars("~~deleted~~"), "deleted");
}
#[test]
fn test_strip_markdown_chars_link_with_url() {
assert_eq!(
strip_markdown_chars("[click here](https://example.com)"),
"click here"
);
}
#[test]
fn test_strip_markdown_chars_wikilink() {
// Note: strip_markdown_chars handles single brackets [text](url) well
// but wikilinks [[text]] leave one layer of brackets. This is acceptable
// because extract_snippet already handles the common case.
assert_eq!(strip_markdown_chars("see [[my note]]"), "see [my note]");
}
#[test]
fn test_strip_markdown_chars_bracket_without_url() {
assert_eq!(strip_markdown_chars("[just brackets]"), "just brackets");
}
#[test]
fn test_strip_markdown_chars_empty() {
assert_eq!(strip_markdown_chars(""), "");
}
// --- capitalize_first tests ---
#[test]
fn test_capitalize_first_normal() {
assert_eq!(capitalize_first("person"), "Person");
}
#[test]
fn test_capitalize_first_already_capitalized() {
assert_eq!(capitalize_first("Project"), "Project");
}
#[test]
fn test_capitalize_first_empty() {
assert_eq!(capitalize_first(""), "");
}
#[test]
fn test_capitalize_first_single_char() {
assert_eq!(capitalize_first("a"), "A");
}
// --- infer_type_from_folder tests ---
#[test]
fn test_infer_type_from_known_folders() {
let dir = TempDir::new().unwrap();
let known_folders = vec![
("person", "Person"),
("project", "Project"),
("procedure", "Procedure"),
("responsibility", "Responsibility"),
("event", "Event"),
("topic", "Topic"),
("experiment", "Experiment"),
("note", "Note"),
("quarter", "Quarter"),
("measure", "Measure"),
("target", "Target"),
("journal", "Journal"),
("month", "Month"),
("essay", "Essay"),
("evergreen", "Evergreen"),
];
for (folder, expected_type) in known_folders {
create_test_file(dir.path(), &format!("{}/test.md", folder), "# Test\n");
let entry = parse_md_file(&dir.path().join(folder).join("test.md")).unwrap();
assert_eq!(
entry.is_a,
Some(expected_type.to_string()),
"folder '{}' should infer type '{}'",
folder,
expected_type
);
}
}
#[test]
fn test_infer_type_from_unknown_folder_capitalizes() {
let dir = TempDir::new().unwrap();
create_test_file(dir.path(), "recipe/test.md", "# Test\n");
let entry = parse_md_file(&dir.path().join("recipe/test.md")).unwrap();
assert_eq!(entry.is_a, Some("Recipe".to_string()));
}
#[test]
fn test_infer_type_frontmatter_overrides_folder() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"person/test.md",
"---\nIs A: Custom\n---\n# Test\n",
);
let entry = parse_md_file(&dir.path().join("person/test.md")).unwrap();
assert_eq!(entry.is_a, Some("Custom".to_string()));
}
// --- without_h1_line tests ---
#[test]
fn test_without_h1_line_starts_with_h1() {
let result = without_h1_line("# Title\nBody text");
assert!(result.is_some());
assert_eq!(result.unwrap(), "Body text");
}
#[test]
fn test_without_h1_line_blank_lines_then_h1() {
let result = without_h1_line("\n\n# Title\nBody");
assert!(result.is_some());
assert_eq!(result.unwrap(), "Body");
}
#[test]
fn test_without_h1_line_non_heading_first() {
let result = without_h1_line("Some text\n# Title\n");
assert!(result.is_none());
}
#[test]
fn test_without_h1_line_empty() {
let result = without_h1_line("");
assert!(result.is_none());
}
#[test]
fn test_without_h1_line_only_blank_lines() {
let result = without_h1_line("\n\n\n");
assert!(result.is_none());
}
// --- extract_snippet edge cases ---
#[test]
fn test_extract_snippet_code_fence_delimiters_skipped() {
// Note: the line-based filter skips ``` fence delimiters but not
// content between fences. This is a known limitation.
let content = "# Title\n\n```rust\nfn main() {}\n```\n\nReal content here.";
let snippet = extract_snippet(content);
assert!(!snippet.contains("```"));
assert!(snippet.contains("Real content here"));
}
#[test]
fn test_extract_snippet_only_headings() {
let content = "# Title\n\n## Section One\n\n### Sub Section\n";
let snippet = extract_snippet(content);
assert_eq!(snippet, "");
}
#[test]
fn test_extract_snippet_no_frontmatter_no_h1() {
let content = "Just plain text content without any heading.";
let snippet = extract_snippet(content);
assert_eq!(snippet, "Just plain text content without any heading.");
}
#[test]
fn test_extract_snippet_unclosed_frontmatter() {
let content = "---\nIs A: Note\nThis has no closing fence\n# Title\n\nBody text.";
let snippet = extract_snippet(content);
// unclosed frontmatter means it treats "---" as not having a close
// so the content is the full string
assert!(snippet.contains("Body text"));
}
#[test]
fn test_extract_snippet_horizontal_rules_skipped() {
let content = "# Title\n\n---\n\nContent after rule.";
let snippet = extract_snippet(content);
assert_eq!(snippet, "Content after rule.");
}
// --- contains_wikilink tests ---
#[test]
fn test_contains_wikilink_true() {
assert!(contains_wikilink("[[some note]]"));
assert!(contains_wikilink("text before [[link]] text after"));
}
#[test]
fn test_contains_wikilink_false_plain_text() {
assert!(!contains_wikilink("no links here"));
assert!(!contains_wikilink("[single bracket]"));
}
#[test]
fn test_contains_wikilink_false_partial_markers() {
assert!(!contains_wikilink("only [[ opening"));
assert!(!contains_wikilink("only ]] closing"));
}
// --- scan_vault_cached incremental update ---
#[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.to_str().unwrap()).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.to_str().unwrap()).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"));
}
// --- created_at parsing from frontmatter ---
#[test]
fn test_parse_created_at_from_frontmatter() {
let dir = TempDir::new().unwrap();
let content = "---\nCreated at: 2025-05-23T14:35:00.000Z\n---\n# Test\n";
create_test_file(dir.path(), "test.md", content);
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
assert_eq!(entry.created_at, Some(1748010900));
}
#[test]
fn test_parse_created_time_fallback() {
let dir = TempDir::new().unwrap();
let content = "---\nCreated time: 2025-05-23\n---\n# Test\n";
create_test_file(dir.path(), "test.md", content);
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
assert_eq!(entry.created_at, Some(1747958400));
}
// --- Type relationship tests ---
#[test]
fn test_type_relationship_added_for_regular_entries() {
let dir = TempDir::new().unwrap();
let content = "---\nIs A: Project\n---\n# My Project\n";
let entry = parse_test_entry(&dir, "project/my-project.md", content);
assert_eq!(
entry.relationships.get("Type").unwrap(),
&vec!["[[type/project]]".to_string()]
);
}
#[test]
fn test_type_relationship_skipped_for_type_documents() {
let dir = TempDir::new().unwrap();
let content = "---\nIs A: Type\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert!(entry.relationships.get("Type").is_none());
}
#[test]
fn test_type_relationship_from_folder_inference() {
let dir = TempDir::new().unwrap();
let content = "# A Person\n\nSome content.";
let entry = parse_test_entry(&dir, "person/someone.md", content);
assert_eq!(entry.is_a, Some("Person".to_string()));
assert_eq!(
entry.relationships.get("Type").unwrap(),
&vec!["[[type/person]]".to_string()]
);
}
#[test]
fn test_type_relationship_handles_wikilink_is_a() {
let dir = TempDir::new().unwrap();
let content = "---\nIs A: \"[[type/experiment]]\"\n---\n# Test\n";
let entry = parse_test_entry(&dir, "test.md", content);
assert_eq!(
entry.relationships.get("Type").unwrap(),
&vec!["[[type/experiment]]".to_string()]
);
}
#[test]
fn test_type_folder_inferred_as_type() {
let dir = TempDir::new().unwrap();
let content = "# Some Type\n";
let entry = parse_test_entry(&dir, "type/some-type.md", content);
assert_eq!(entry.is_a, Some("Type".to_string()));
}
// --- save_note_content tests ---
#[test]
fn test_save_note_content_writes_file() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("test.md");
let content = "---\nIs A: Note\n---\n# Test\n\nHello, world!";
// Create file first
create_test_file(dir.path(), "test.md", "original content");
let result = save_note_content(file_path.to_str().unwrap(), content);
assert!(result.is_ok());
let saved = fs::read_to_string(&file_path).unwrap();
assert_eq!(saved, content);
}
#[test]
fn test_save_note_content_creates_new_file() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("new-note.md");
let content = "---\nIs A: Note\n---\n# New Note\n\nContent here.";
let result = save_note_content(file_path.to_str().unwrap(), content);
assert!(result.is_ok());
assert!(file_path.exists());
let saved = fs::read_to_string(&file_path).unwrap();
assert_eq!(saved, content);
}
#[test]
fn test_save_note_content_nonexistent_parent() {
let result = save_note_content("/nonexistent/parent/dir/file.md", "content");
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("Parent directory does not exist"));
}
#[test]
fn test_save_note_content_readonly_file() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("readonly.md");
create_test_file(dir.path(), "readonly.md", "original");
// Make file read-only
let mut perms = fs::metadata(&file_path).unwrap().permissions();
perms.set_readonly(true);
fs::set_permissions(&file_path, perms).unwrap();
let result = save_note_content(file_path.to_str().unwrap(), "new content");
assert!(result.is_err());
assert!(result.unwrap_err().contains("read-only"));
// Cleanup: restore write permissions so tempdir can clean up
let mut perms = fs::metadata(&file_path).unwrap().permissions();
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(false);
fs::set_permissions(&file_path, perms).unwrap();
}
#[test]
fn test_save_note_content_preserves_frontmatter() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("note.md");
let original = "---\nIs A: Project\nStatus: Active\n---\n# My Project\n\nOriginal body.";
create_test_file(dir.path(), "note.md", original);
let updated =
"---\nIs A: Project\nStatus: Active\n---\n# My Project\n\nUpdated body with changes.";
save_note_content(file_path.to_str().unwrap(), updated).unwrap();
let saved = fs::read_to_string(&file_path).unwrap();
assert!(saved.contains("Is A: Project"));
assert!(saved.contains("Status: Active"));
assert!(saved.contains("Updated body with changes"));
}
// Frontmatter update/delete tests are in frontmatter.rs
// --- save_image tests ---
#[test]
fn test_sanitize_filename_safe_chars() {
assert_eq!(sanitize_filename("photo.png"), "photo.png");
assert_eq!(sanitize_filename("my-image_01.jpg"), "my-image_01.jpg");
}
#[test]
fn test_sanitize_filename_unsafe_chars() {
assert_eq!(sanitize_filename("my file (1).png"), "my_file__1_.png");
assert_eq!(sanitize_filename("path/to/img.png"), "path_to_img.png");
}
#[test]
fn test_save_image_creates_file() {
use base64::Engine;
let dir = TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let data = base64::engine::general_purpose::STANDARD.encode(b"fake image data");
let result = save_image(vault_path, "test.png", &data);
assert!(result.is_ok());
let saved_path = result.unwrap();
assert!(std::path::Path::new(&saved_path).exists());
assert!(saved_path.contains("attachments"));
assert!(saved_path.contains("test.png"));
let content = fs::read(&saved_path).unwrap();
assert_eq!(content, b"fake image data");
}
#[test]
fn test_save_image_creates_attachments_dir() {
use base64::Engine;
let dir = TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let attachments = dir.path().join("attachments");
assert!(!attachments.exists());
let data = base64::engine::general_purpose::STANDARD.encode(b"test");
save_image(vault_path, "img.png", &data).unwrap();
assert!(attachments.exists());
}
#[test]
fn test_save_image_invalid_base64() {
let dir = TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let result = save_image(vault_path, "test.png", "not-valid-base64!!!");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid base64"));
}
// --- purge_trash tests ---
#[test]
fn test_purge_trash_deletes_old_trashed_files() {
let dir = TempDir::new().unwrap();
// File trashed 60 days ago — should be deleted
create_test_file(
dir.path(),
"old-trash.md",
"---\nTrashed at: \"2025-01-01\"\n---\n# Old Trash\n",
);
// File trashed recently — should be kept
let recent = chrono::Utc::now()
.date_naive()
.format("%Y-%m-%d")
.to_string();
create_test_file(
dir.path(),
"recent-trash.md",
&format!("---\nTrashed at: \"{}\"\n---\n# Recent Trash\n", recent),
);
// File without trashed_at — should be kept
create_test_file(
dir.path(),
"normal.md",
"---\nIs A: Note\n---\n# Normal Note\n",
);
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
assert_eq!(deleted.len(), 1);
assert!(deleted[0].contains("old-trash.md"));
// Verify old file is actually gone
assert!(!dir.path().join("old-trash.md").exists());
// Verify other files still exist
assert!(dir.path().join("recent-trash.md").exists());
assert!(dir.path().join("normal.md").exists());
}
#[test]
fn test_purge_trash_supports_datetime_format() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"datetime-trash.md",
"---\nTrashed at: \"2025-01-01T10:30:00Z\"\n---\n# Datetime Trash\n",
);
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
assert_eq!(deleted.len(), 1);
assert!(deleted[0].contains("datetime-trash.md"));
}
#[test]
fn test_purge_trash_empty_vault() {
let dir = TempDir::new().unwrap();
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
assert!(deleted.is_empty());
}
#[test]
fn test_purge_trash_nonexistent_path() {
let result = purge_trash("/nonexistent/path/that/does/not/exist");
assert!(result.is_err());
}
#[test]
fn test_purge_trash_exactly_30_days_not_deleted() {
let dir = TempDir::new().unwrap();
let thirty_days_ago = (chrono::Utc::now().date_naive() - chrono::Duration::days(30))
.format("%Y-%m-%d")
.to_string();
create_test_file(
dir.path(),
"borderline.md",
&format!(
"---\nTrashed at: \"{}\"\n---\n# Borderline\n",
thirty_days_ago
),
);
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
assert!(deleted.is_empty());
assert!(dir.path().join("borderline.md").exists());
}
#[test]
fn test_purge_trash_31_days_deleted() {
let dir = TempDir::new().unwrap();
let thirty_one_days_ago = (chrono::Utc::now().date_naive() - chrono::Duration::days(31))
.format("%Y-%m-%d")
.to_string();
create_test_file(
dir.path(),
"expired.md",
&format!(
"---\nTrashed at: \"{}\"\n---\n# Expired\n",
thirty_one_days_ago
),
);
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
assert_eq!(deleted.len(), 1);
assert!(!dir.path().join("expired.md").exists());
}
#[test]
fn test_purge_trash_nested_directories() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"sub/deep/old.md",
"---\nTrashed at: \"2025-01-01\"\n---\n# Deep Old\n",
);
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
assert_eq!(deleted.len(), 1);
assert!(deleted[0].contains("old.md"));
}
// --- rename_note tests ---
#[test]
fn test_title_to_slug() {
assert_eq!(title_to_slug("Weekly Review"), "weekly-review");
assert_eq!(title_to_slug("My Note! "), "my-note");
assert_eq!(title_to_slug("Hello World"), "hello-world");
}
#[test]
fn test_update_h1_title() {
let content = "---\nIs A: Note\n---\n# Old Title\n\nContent here.\n";
let updated = update_h1_title(content, "New Title");
assert!(updated.contains("# New Title"));
assert!(!updated.contains("# Old Title"));
assert!(updated.contains("Content here."));
}
#[test]
fn test_rename_note_basic() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"note/weekly-review.md",
"---\nIs A: Note\n---\n# Weekly Review\n\nContent here.\n",
);
let old_path = vault.join("note/weekly-review.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Sprint Retrospective",
)
.unwrap();
assert!(result.new_path.ends_with("sprint-retrospective.md"));
assert!(!old_path.exists());
assert!(Path::new(&result.new_path).exists());
let new_content = fs::read_to_string(&result.new_path).unwrap();
assert!(new_content.contains("# Sprint Retrospective"));
}
#[test]
fn test_rename_note_updates_wikilinks() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"note/weekly-review.md",
"---\nIs A: Note\n---\n# Weekly Review\n\nContent.\n",
);
create_test_file(
vault,
"note/other.md",
"---\nIs A: Note\n---\n# Other\n\nSee [[Weekly Review]] for details.\n",
);
create_test_file(
vault,
"project/my-project.md",
"---\nIs A: Project\nRelated to:\n - \"[[Weekly Review]]\"\n---\n# My Project\n",
);
let old_path = vault.join("note/weekly-review.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Sprint Retrospective",
)
.unwrap();
assert_eq!(result.updated_files, 2);
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
assert!(other_content.contains("[[Sprint Retrospective]]"));
assert!(!other_content.contains("[[Weekly Review]]"));
let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap();
assert!(project_content.contains("[[Sprint Retrospective]]"));
}
#[test]
fn test_rename_note_same_title_noop() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/my-note.md", "# My Note\n\nContent.\n");
let old_path = vault.join("note/my-note.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"My Note",
)
.unwrap();
assert_eq!(result.new_path, old_path.to_str().unwrap());
assert_eq!(result.updated_files, 0);
}
#[test]
fn test_rename_note_empty_title_error() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/test.md", "# Test\n");
let old_path = vault.join("note/test.md");
let result = rename_note(vault.to_str().unwrap(), old_path.to_str().unwrap(), " ");
assert!(result.is_err());
}
#[test]
fn test_rename_note_preserves_pipe_alias_in_wikilinks() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/weekly-review.md", "# Weekly Review\n");
create_test_file(
vault,
"note/ref.md",
"# Ref\n\nSee [[Weekly Review|my review]] for info.\n",
);
let old_path = vault.join("note/weekly-review.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Sprint Retro",
)
.unwrap();
assert_eq!(result.updated_files, 1);
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
assert!(ref_content.contains("[[Sprint Retro|my review]]"));
}
#[test]
fn test_rename_note_updates_title_frontmatter() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"note/old.md",
"---\ntitle: Old Name\nIs A: Note\n---\n# Old Name\n",
);
let old_path = vault.join("note/old.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"New Name",
)
.unwrap();
let content = fs::read_to_string(&result.new_path).unwrap();
assert!(content.contains("title: New Name"));
assert!(content.contains("# New Name"));
}
// --- type field parsing tests ---
#[test]
fn test_parse_type_field() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"project/my-project.md",
"---\ntype: Project\nStatus: Active\n---\n# My Project\n",
);
assert_eq!(entry.is_a, Some("Project".to_string()));
}
#[test]
fn test_parse_type_field_takes_precedence_over_is_a() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"project/conflict.md",
"---\ntype: Project\nIs A: Note\n---\n# Conflict\n",
);
assert_eq!(entry.is_a, Some("Project".to_string()));
}
#[test]
fn test_parse_legacy_is_a_still_works() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"note/legacy.md",
"---\nIs A: Note\nStatus: Active\n---\n# Legacy\n",
);
assert_eq!(entry.is_a, Some("Note".to_string()));
}
#[test]
fn test_parse_snake_case_is_a_still_works() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"note/snake.md",
"---\nis_a: Note\nStatus: Draft\n---\n# Snake Case\n",
);
assert_eq!(entry.is_a, Some("Note".to_string()));
}
// --- migration tests ---
#[test]
fn test_migrate_file_is_a_to_type() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("note.md");
fs::write(&path, "---\nis_a: Project\nStatus: Active\n---\n# Note\n").unwrap();
assert!(migrate_file_is_a_to_type(&path).unwrap());
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("type: Project"));
assert!(!content.contains("is_a:"));
}
#[test]
fn test_migrate_file_quoted_is_a_to_type() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("note.md");
fs::write(&path, "---\n\"Is A\": Type\norder: 1\n---\n# My Type\n").unwrap();
assert!(migrate_file_is_a_to_type(&path).unwrap());
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("type: Type"));
assert!(!content.contains("Is A"));
}
#[test]
fn test_migrate_file_preserves_existing_type() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("note.md");
fs::write(
&path,
"---\ntype: Project\nis_a: Note\nStatus: Active\n---\n# Note\n",
)
.unwrap();
assert!(migrate_file_is_a_to_type(&path).unwrap());
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("type: Project"));
assert!(!content.contains("is_a:"));
}
#[test]
fn test_migrate_file_no_change_needed() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("note.md");
fs::write(&path, "---\ntype: Project\nStatus: Active\n---\n# Note\n").unwrap();
assert!(!migrate_file_is_a_to_type(&path).unwrap());
}
#[test]
fn test_migrate_vault() {
let dir = TempDir::new().unwrap();
create_test_file(dir.path(), "note/a.md", "---\nis_a: Note\n---\n# A\n");
create_test_file(dir.path(), "project/b.md", "---\ntype: Project\n---\n# B\n");
create_test_file(
dir.path(),
"type/c.md",
"---\n\"Is A\": Type\norder: 1\n---\n# C\n",
);
let migrated = migrate_is_a_to_type(dir.path().to_str().unwrap()).unwrap();
assert_eq!(migrated, 2); // a.md and c.md
let a = fs::read_to_string(dir.path().join("note/a.md")).unwrap();
assert!(a.contains("type: Note"));
assert!(!a.contains("is_a:"));
let b = fs::read_to_string(dir.path().join("project/b.md")).unwrap();
assert!(b.contains("type: Project")); // unchanged
let c = fs::read_to_string(dir.path().join("type/c.md")).unwrap();
assert!(c.contains("type: Type"));
assert!(!c.contains("Is A"));
}
}