Files
tolaria/src-tauri/src/vault/mod.rs
Test 13d3b2d375 feat: add batch_delete_notes and empty_trash Rust commands
Add two new Tauri commands for trash management:
- batch_delete_notes: permanently delete multiple note files from disk
- empty_trash: scan vault and delete ALL trashed notes regardless of age

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:33:43 +01:00

1672 lines
57 KiB
Rust

mod cache;
mod config_seed;
mod getting_started;
mod image;
mod migration;
mod parsing;
mod rename;
mod trash;
pub use cache::{invalidate_cache, scan_vault_cached};
pub use config_seed::{migrate_agents_md, repair_config_files, seed_config_files};
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
pub use image::{copy_image_to_vault, save_image};
pub use migration::migrate_is_a_to_type;
pub use rename::{move_note_to_type_folder, rename_note, MoveResult, RenameResult};
pub use trash::{batch_delete_notes, delete_note, empty_trash, is_file_trashed, purge_trash};
use parsing::{
contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet, extract_title,
parse_iso_date, title_case_folder,
};
use gray_matter::engine::YAML;
use gray_matter::Matter;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::time::UNIX_EPOCH;
use walkdir::WalkDir;
#[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>,
/// Custom sidebar section label for Type entries, overriding auto-pluralization.
#[serde(rename = "sidebarLabel")]
pub sidebar_label: Option<String>,
/// Markdown template for notes of this Type. When a new note is created
/// with this type, the template body is pre-filled after the frontmatter.
pub template: Option<String>,
/// Default sort preference for the note list when viewing instances of this Type.
/// Stored as "option:direction" (e.g. "modified:desc", "title:asc", "property:Priority:asc").
pub sort: Option<String>,
/// Default view mode for the note list when viewing instances of this Type.
/// Stored as a string: "all", "editor-list", or "editor-only".
pub view: Option<String>,
/// Whether this Type is visible in the sidebar. Defaults to true when absent.
pub visible: Option<bool>,
/// Word count of the note body (excludes frontmatter and H1 title).
#[serde(rename = "wordCount")]
pub word_count: u32,
/// All wikilink targets found in the note body (excludes frontmatter).
/// Extracted from `[[target]]` and `[[target|display]]` patterns.
#[serde(rename = "outgoingLinks", default)]
pub outgoing_links: Vec<String>,
/// Custom scalar frontmatter properties (non-relationship, non-structural).
/// Only includes strings, numbers, and booleans — arrays/objects are excluded.
#[serde(default)]
pub properties: HashMap<String, serde_json::Value>,
}
/// Intermediate struct to capture YAML frontmatter fields.
#[derive(Debug, Deserialize, Default)]
struct Frontmatter {
#[serde(rename = "type", alias = "Is A", alias = "is_a")]
is_a: 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",
alias = "archived",
default,
deserialize_with = "deserialize_bool_or_string"
)]
archived: Option<bool>,
#[serde(
rename = "Trashed",
alias = "trashed",
default,
deserialize_with = "deserialize_bool_or_string"
)]
trashed: Option<bool>,
#[serde(rename = "Trashed at", alias = "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>,
#[serde(rename = "sidebar label", default)]
sidebar_label: Option<String>,
#[serde(default)]
template: Option<String>,
#[serde(default)]
sort: Option<String>,
#[serde(default)]
view: Option<String>,
#[serde(default)]
visible: Option<bool>,
}
/// Custom deserializer for boolean fields that may arrive as strings.
/// YAML `Yes`/`No` get converted to JSON strings by gray_matter, so we
/// need to accept both actual booleans and their string representations.
fn deserialize_bool_or_string<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
struct BoolOrStringVisitor;
impl<'de> de::Visitor<'de> for BoolOrStringVisitor {
type Value = Option<bool>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a boolean or a string representing a boolean")
}
fn visit_bool<E: de::Error>(self, v: bool) -> Result<Self::Value, E> {
Ok(Some(v))
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
match v.to_lowercase().as_str() {
"true" | "yes" | "1" => Ok(Some(true)),
"false" | "no" | "0" | "" => Ok(Some(false)),
_ => Ok(Some(false)),
}
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Self::Value, E> {
Ok(Some(v != 0))
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Self::Value, E> {
Ok(Some(v != 0))
}
fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
}
deserializer.deserialize_any(BoolOrStringVisitor)
}
/// 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,
}
}
}
/// 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",
"type",
"aliases",
"status",
"cadence",
"archived",
"trashed",
"trashed at",
"created at",
"created time",
"icon",
"color",
"order",
"sidebar label",
"template",
"sort",
"view",
"visible",
];
/// 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
}
/// Additional keys to skip when extracting custom properties.
/// These are already first-class fields on VaultEntry, so including them
/// in `properties` would duplicate information.
const PROPERTY_EXTRA_SKIP: &[&str] = &["belongs to", "related to", "owner"];
/// Extract custom scalar properties from raw YAML frontmatter.
/// Captures string, number, and boolean values that are not structural fields
/// and do not contain wikilinks. Arrays and objects are excluded.
fn extract_properties(
data: &HashMap<String, serde_json::Value>,
) -> HashMap<String, serde_json::Value> {
let mut properties = HashMap::new();
for (key, value) in data {
let lower = key.to_ascii_lowercase();
if SKIP_KEYS.iter().any(|k| k.eq_ignore_ascii_case(&lower))
|| PROPERTY_EXTRA_SKIP
.iter()
.any(|k| k.eq_ignore_ascii_case(&lower))
{
continue;
}
match value {
serde_json::Value::String(s) => {
if !contains_wikilink(s) {
properties.insert(key.clone(), value.clone());
}
}
serde_json::Value::Number(_) | serde_json::Value::Bool(_) => {
properties.insert(key.clone(), value.clone());
}
_ => {}
}
}
properties
}
/// 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",
"config" => "Config",
"essay" => "Essay",
"evergreen" => "Evergreen",
_ => return title_case_folder(folder),
}
.to_string()
}
/// Resolve `is_a` from frontmatter, falling back to parent folder inference.
fn resolve_is_a(fm_is_a: Option<StringOrList>, path: &Path) -> Option<String> {
fm_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, relationships, and custom properties from parsed gray_matter data.
fn extract_fm_and_rels(
data: Option<gray_matter::Pod>,
) -> (
Frontmatter,
HashMap<String, Vec<String>>,
HashMap<String, serde_json::Value>,
) {
let hash = match data {
Some(gray_matter::Pod::Hash(map)) => map,
_ => return (Frontmatter::default(), HashMap::new(), 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),
extract_properties(&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, properties) = extract_fm_and_rels(parsed.data);
let title = extract_title(&parsed.content, &filename);
let snippet = extract_snippet(&content);
let word_count = count_body_words(&content);
let outgoing_links = extract_outgoing_links(&parsed.content);
let (modified_at, file_size) = read_file_metadata(path)?;
let created_at = parse_created_at(&frontmatter);
let is_a = resolve_is_a(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,
sidebar_label: frontmatter.sidebar_label,
template: frontmatter.template,
sort: frontmatter.sort,
view: frontmatter.view,
visible: frontmatter.visible,
word_count,
outgoing_links,
properties,
})
}
/// 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,
}
}
/// Re-read a single file from disk and return a fresh VaultEntry.
/// Used after failed optimistic updates to restore the true filesystem state.
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)
}
/// Read the content of a single note file.
pub fn get_note_content(path: &Path) -> Result<String, String> {
if !path.exists() {
return Err(format!("File does not exist: {}", path.display()));
}
if !path.is_file() {
return Err(format!("Path is not a file: {}", path.display()));
}
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {}", path.display(), 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()
&& file_path
.metadata()
.map(|m| m.permissions().readonly())
.unwrap_or(false);
if is_readonly {
return Err(format!("File is read-only: {}", display_path));
}
Ok(())
}
/// Write content to a note file. Creates parent directory if needed, validates path,
/// then writes content to disk.
pub fn save_note_content(path: &str, content: &str) -> Result<(), String> {
let file_path = Path::new(path);
if let Some(parent) = file_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
}
}
validate_save_path(file_path, path)?;
fs::write(file_path, content).map_err(|e| format!("Failed to save {}: {}", path, e))
}
/// Scan a directory recursively for .md files and return VaultEntry for each.
pub fn scan_vault(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
if !vault_path.exists() {
return Err(format!(
"Vault path does not exist: {}",
vault_path.display()
));
}
if !vault_path.is_dir() {
return Err(format!(
"Vault path is not a directory: {}",
vault_path.display()
));
}
let mut entries = Vec::new();
for entry in WalkDir::new(vault_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)
}
#[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();
}
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()
}
#[test]
fn test_reload_entry_returns_fresh_data() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"note.md",
"---\nStatus: Active\n---\n# My Note\n\nOriginal.",
);
let entry = reload_entry(&dir.path().join("note.md")).unwrap();
assert_eq!(entry.title, "My Note");
assert_eq!(entry.status, Some("Active".to_string()));
// Modify on disk and reload — must see the new content
create_test_file(
dir.path(),
"note.md",
"---\nStatus: Done\n---\n# My Note\n\nUpdated.",
);
let fresh = reload_entry(&dir.path().join("note.md")).unwrap();
assert_eq!(fresh.status, Some("Done".to_string()));
}
#[test]
fn test_reload_entry_nonexistent_file() {
let result = reload_entry(std::path::Path::new("/nonexistent/path/note.md"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
}
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()).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(Path::new("/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);
assert!(result.is_ok());
assert_eq!(result.unwrap(), content);
}
#[test]
fn test_get_note_content_nonexistent() {
let result = get_note_content(Path::new("/nonexistent/path/file.md"));
assert!(result.is_err());
}
#[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_parse_md_file_has_word_count() {
let dir = TempDir::new().unwrap();
let content =
"---\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();
assert_eq!(entry.word_count, 9);
}
#[test]
fn test_parse_md_file_word_count_empty_body() {
let dir = TempDir::new().unwrap();
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();
assert_eq!(entry.word_count, 0);
}
#[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()
]
);
}
// --- 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_from_hyphenated_folder_title_cases() {
let dir = TempDir::new().unwrap();
let cases = vec![
("monday-ideas", "Monday Ideas"),
("key-result", "Key Result"),
("my_custom_type", "My Custom Type"),
("mix-and_match", "Mix And Match"),
];
for (folder, expected) in cases {
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.to_string()),
"folder '{}' should infer type '{}'",
folder,
expected
);
}
}
#[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()));
}
// --- 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()));
}
// --- type key (post-migration) tests ---
#[test]
fn test_parse_type_key_lowercase() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Project\n---\n# My Project\n";
let entry = parse_test_entry(&dir, "project/my-project.md", content);
assert_eq!(entry.is_a, Some("Project".to_string()));
}
#[test]
fn test_type_key_generates_type_relationship() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Person\n---\n# Alice\n";
let entry = parse_test_entry(&dir, "person/alice.md", content);
assert_eq!(
entry.relationships.get("Type").unwrap(),
&vec!["[[type/person]]".to_string()]
);
}
#[test]
fn test_type_key_not_in_relationships_as_generic() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Note\nHas:\n - \"[[task/foo]]\"\n---\n# Test\n";
let entry = parse_test_entry(&dir, "note/test.md", content);
// "type" key itself should not appear as a relationship (it's in SKIP_KEYS)
// Only "Has" and the auto-generated "Type" should be relationships
assert_eq!(entry.relationships.len(), 2);
assert!(entry.relationships.get("Has").is_some());
assert!(entry.relationships.get("Type").is_some());
}
// --- outgoing_links tests ---
#[test]
fn test_outgoing_links_extracted_from_content_body() {
let dir = TempDir::new().unwrap();
let content = "---\nIs A: Note\n---\n# My Note\n\nSee [[person/alice]] and [[topic/rust]].";
let entry = parse_test_entry(&dir, "note/my-note.md", content);
assert_eq!(entry.outgoing_links, vec!["person/alice", "topic/rust"]);
}
#[test]
fn test_outgoing_links_excludes_frontmatter_wikilinks() {
let dir = TempDir::new().unwrap();
let content = "---\nHas:\n - \"[[task/design]]\"\n---\n# Note\n\nSee [[person/bob]].";
let entry = parse_test_entry(&dir, "note/test.md", content);
assert!(!entry.outgoing_links.contains(&"task/design".to_string()));
assert!(entry.outgoing_links.contains(&"person/bob".to_string()));
}
#[test]
fn test_outgoing_links_handles_pipe_syntax() {
let dir = TempDir::new().unwrap();
let content = "# Note\n\nSee [[project/alpha|Alpha Project]] for details.";
let entry = parse_test_entry(&dir, "test.md", content);
assert!(entry.outgoing_links.contains(&"project/alpha".to_string()));
}
// --- save_note_content tests ---
#[test]
fn test_save_note_content_creates_parent_directory() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("new-type/untitled-note.md");
let content = "---\ntitle: Untitled note\n---\n# Untitled note\n\n";
assert!(!path.parent().unwrap().exists());
save_note_content(path.to_str().unwrap(), content).unwrap();
assert!(path.exists());
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
#[test]
fn test_save_note_content_existing_directory() {
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("note")).unwrap();
let path = dir.path().join("note/test.md");
let content = "# Test\n";
save_note_content(path.to_str().unwrap(), content).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
#[test]
fn test_save_note_content_deeply_nested_new_directory() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a/b/c/deep-note.md");
let content = "---\ntitle: Deep\n---\n";
save_note_content(path.to_str().unwrap(), content).unwrap();
assert!(path.exists());
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
// --- sidebar_label tests ---
#[test]
fn test_parse_sidebar_label_from_type_entry() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nsidebar label: News\n---\n# News\n";
let entry = parse_test_entry(&dir, "type/news.md", content);
assert_eq!(entry.sidebar_label, Some("News".to_string()));
}
#[test]
fn test_parse_sidebar_label_missing_defaults_to_none() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert_eq!(entry.sidebar_label, None);
}
#[test]
fn test_sidebar_label_not_in_relationships() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nsidebar label: My Series\n---\n# Series\n";
let entry = parse_test_entry(&dir, "type/series.md", content);
assert!(entry.relationships.get("sidebar label").is_none());
}
// --- template field tests ---
#[test]
fn test_parse_template_from_type_entry() {
let dir = TempDir::new().unwrap();
let content =
"---\ntype: Type\ntemplate: \"## Objective\\n\\n## Timeline\"\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert!(entry.template.is_some());
}
#[test]
fn test_parse_template_block_scalar() {
let dir = TempDir::new().unwrap();
let content =
"---\ntype: Type\ntemplate: |\n ## Objective\n \n ## Timeline\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert!(entry.template.is_some());
let tmpl = entry.template.unwrap();
assert!(tmpl.contains("## Objective"));
assert!(tmpl.contains("## Timeline"));
}
#[test]
fn test_parse_template_missing_defaults_to_none() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\n---\n# Note\n";
let entry = parse_test_entry(&dir, "type/note.md", content);
assert_eq!(entry.template, None);
}
#[test]
fn test_template_not_in_relationships() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\ntemplate: \"## Heading\"\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert!(entry.relationships.get("template").is_none());
}
// --- sort field tests ---
#[test]
fn test_parse_sort_from_type_entry() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nsort: \"modified:desc\"\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert_eq!(entry.sort, Some("modified:desc".to_string()));
}
#[test]
fn test_parse_sort_missing_defaults_to_none() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert_eq!(entry.sort, None);
}
#[test]
fn test_sort_not_in_relationships() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nsort: \"title:asc\"\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert!(entry.relationships.get("sort").is_none());
}
#[test]
fn test_sort_not_in_properties() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nsort: \"title:asc\"\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert!(entry.properties.get("sort").is_none());
}
// --- custom properties tests ---
#[test]
fn test_extract_properties_scalar_values() {
let dir = TempDir::new().unwrap();
let content = r#"---
Is A: Project
Status: Active
Priority: High
Rating: 5
Due date: 2026-06-15
Reviewed: true
---
# Test
"#;
let entry = parse_test_entry(&dir, "project/test.md", content);
let expected: HashMap<String, serde_json::Value> = [
("Priority".into(), serde_json::Value::String("High".into())),
("Rating".into(), serde_json::json!(5)),
(
"Due date".into(),
serde_json::Value::String("2026-06-15".into()),
),
("Reviewed".into(), serde_json::Value::Bool(true)),
]
.into_iter()
.collect();
assert_eq!(entry.properties, expected);
}
#[test]
fn test_extract_properties_skips_structural_fields() {
let dir = TempDir::new().unwrap();
let content = r#"---
Is A: Project
Status: Active
Owner: Luca
Cadence: Weekly
Archived: false
Priority: High
---
# Test
"#;
let entry = parse_test_entry(&dir, "project/test.md", content);
// Only Priority should survive — all others are structural
assert_eq!(entry.properties.len(), 1);
assert_eq!(
entry.properties.get("Priority").and_then(|v| v.as_str()),
Some("High")
);
}
#[test]
fn test_extract_properties_skips_wikilinks() {
let dir = TempDir::new().unwrap();
let content = r#"---
Mentor: "[[person/alice]]"
Company: Acme Corp
---
# Test
"#;
let entry = parse_test_entry(&dir, "test.md", content);
assert!(entry.properties.get("Mentor").is_none());
assert_eq!(
entry.properties.get("Company").and_then(|v| v.as_str()),
Some("Acme Corp")
);
}
#[test]
fn test_extract_properties_skips_arrays() {
let dir = TempDir::new().unwrap();
let content = r#"---
Tags:
- productivity
- writing
Company: Acme Corp
---
# Test
"#;
let entry = parse_test_entry(&dir, "test.md", content);
assert!(entry.properties.get("Tags").is_none());
assert_eq!(
entry.properties.get("Company").and_then(|v| v.as_str()),
Some("Acme Corp")
);
}
#[test]
fn test_parse_trashed_title_case() {
let dir = TempDir::new().unwrap();
let content = "---\nTrashed: true\nTrashed at: \"2025-02-01\"\n---\n# Gone\n";
let entry = parse_test_entry(&dir, "gone.md", content);
assert!(entry.trashed);
assert!(entry.trashed_at.is_some());
}
#[test]
fn test_parse_trashed_lowercase_alias() {
let dir = TempDir::new().unwrap();
let content = "---\ntrashed: true\ntrashed_at: \"2025-02-01\"\n---\n# Gone\n";
let entry = parse_test_entry(&dir, "gone.md", content);
assert!(
entry.trashed,
"lowercase 'trashed' must be parsed via alias"
);
assert!(
entry.trashed_at.is_some(),
"lowercase 'trashed_at' must be parsed via alias"
);
}
#[test]
fn test_parse_archived_lowercase_alias() {
let dir = TempDir::new().unwrap();
let content = "---\narchived: true\n---\n# Old Quarter\n";
let entry = parse_test_entry(&dir, "old-quarter.md", content);
assert!(
entry.archived,
"lowercase 'archived' must be parsed via alias (frontend writes lowercase)"
);
}
#[test]
fn test_parse_archived_titlecase() {
let dir = TempDir::new().unwrap();
let content = "---\nArchived: true\n---\n# Old Quarter\n";
let entry = parse_test_entry(&dir, "old-quarter-2.md", content);
assert!(entry.archived, "titlecase 'Archived' must also be parsed");
}
#[test]
fn test_trashed_false_when_absent() {
let dir = TempDir::new().unwrap();
let content = "---\nIs A: Note\n---\n# Active\n";
let entry = parse_test_entry(&dir, "active.md", content);
assert!(!entry.trashed);
assert!(entry.trashed_at.is_none());
}
// --- archived/trashed string-value tests ---
#[test]
fn test_parse_archived_yes_titlecase() {
let dir = TempDir::new().unwrap();
let content = "---\nArchived: Yes\n---\n# Old\n";
let entry = parse_test_entry(&dir, "old.md", content);
assert!(entry.archived, "'Archived: Yes' must be parsed as true");
}
#[test]
fn test_parse_archived_yes_lowercase() {
let dir = TempDir::new().unwrap();
let content = "---\narchived: yes\n---\n# Old\n";
let entry = parse_test_entry(&dir, "old2.md", content);
assert!(entry.archived, "'archived: yes' must be parsed as true");
}
#[test]
fn test_parse_archived_yes_uppercase() {
let dir = TempDir::new().unwrap();
let content = "---\nArchived: YES\n---\n# Old\n";
let entry = parse_test_entry(&dir, "old3.md", content);
assert!(entry.archived, "'Archived: YES' must be parsed as true");
}
#[test]
fn test_parse_archived_no() {
let dir = TempDir::new().unwrap();
let content = "---\nArchived: No\n---\n# Active\n";
let entry = parse_test_entry(&dir, "active2.md", content);
assert!(!entry.archived, "'Archived: No' must be parsed as false");
}
#[test]
fn test_parse_archived_false_string() {
let dir = TempDir::new().unwrap();
let content = "---\nArchived: \"false\"\n---\n# Active\n";
let entry = parse_test_entry(&dir, "active3.md", content);
assert!(
!entry.archived,
"'Archived: \"false\"' must be parsed as false"
);
}
#[test]
fn test_parse_archived_zero() {
let dir = TempDir::new().unwrap();
let content = "---\nArchived: 0\n---\n# Active\n";
let entry = parse_test_entry(&dir, "active4.md", content);
assert!(!entry.archived, "'Archived: 0' must be parsed as false");
}
#[test]
fn test_parse_archived_absent() {
let dir = TempDir::new().unwrap();
let content = "---\nIs A: Note\n---\n# Active\n";
let entry = parse_test_entry(&dir, "active5.md", content);
assert!(!entry.archived, "absent archived must default to false");
}
#[test]
fn test_parse_trashed_yes_titlecase() {
let dir = TempDir::new().unwrap();
let content = "---\nTrashed: Yes\n---\n# Gone\n";
let entry = parse_test_entry(&dir, "gone2.md", content);
assert!(entry.trashed, "'Trashed: Yes' must be parsed as true");
}
#[test]
fn test_parse_trashed_yes_lowercase() {
let dir = TempDir::new().unwrap();
let content = "---\ntrashed: yes\n---\n# Gone\n";
let entry = parse_test_entry(&dir, "gone3.md", content);
assert!(entry.trashed, "'trashed: yes' must be parsed as true");
}
#[test]
fn test_parse_trashed_no() {
let dir = TempDir::new().unwrap();
let content = "---\nTrashed: No\n---\n# Active\n";
let entry = parse_test_entry(&dir, "active6.md", content);
assert!(!entry.trashed, "'Trashed: No' must be parsed as false");
}
// --- visible field tests ---
#[test]
fn test_parse_visible_false_from_type_entry() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nvisible: false\n---\n# Journal\n";
let entry = parse_test_entry(&dir, "type/journal.md", content);
assert_eq!(entry.visible, Some(false));
}
#[test]
fn test_parse_visible_true_from_type_entry() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nvisible: true\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert_eq!(entry.visible, Some(true));
}
#[test]
fn test_parse_visible_missing_defaults_to_none() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert_eq!(entry.visible, None);
}
#[test]
fn test_visible_not_in_relationships() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nvisible: false\n---\n# Journal\n";
let entry = parse_test_entry(&dir, "type/journal.md", content);
assert!(entry.relationships.get("visible").is_none());
}
#[test]
fn test_visible_not_in_properties() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nvisible: false\n---\n# Journal\n";
let entry = parse_test_entry(&dir, "type/journal.md", content);
assert!(entry.properties.get("visible").is_none());
}
// --- round-trip: canonical `type:` field and `Is A:` alias ---
#[test]
fn test_roundtrip_type_key_parses_correctly() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Quarter\n---\n# Q1 2026\n";
let entry = parse_test_entry(&dir, "quarter/q1.md", content);
assert_eq!(entry.is_a, Some("Quarter".to_string()));
}
#[test]
fn test_roundtrip_is_a_alias_still_works() {
let dir = TempDir::new().unwrap();
let content = "---\nIs A: Quarter\n---\n# Q1 2026\n";
let entry = parse_test_entry(&dir, "quarter/q1.md", content);
assert_eq!(entry.is_a, Some("Quarter".to_string()));
}
#[test]
fn test_roundtrip_is_a_snake_case_alias_still_works() {
let dir = TempDir::new().unwrap();
let content = "---\nis_a: Quarter\n---\n# Q1 2026\n";
let entry = parse_test_entry(&dir, "quarter/q1.md", content);
assert_eq!(entry.is_a, Some("Quarter".to_string()));
}
// Frontmatter update/delete tests are in frontmatter.rs
// save_image tests are in vault/image.rs
// purge_trash tests are in vault/trash.rs
// rename_note tests are in vault/rename.rs
}