From 50dec4bcf38c78e2014baee4f58160f8a7ba494a Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 17 Mar 2026 15:07:29 +0100 Subject: [PATCH] =?UTF-8?q?refactor:=20extract=20vault/mod.rs=20into=20sub?= =?UTF-8?q?modules=20=E2=80=94=20fix=20CodeScene=20hotspot=20health?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vault/mod.rs was 1820 lines with duplicated code already extracted to entry.rs, frontmatter.rs, file.rs but never wired up. Slim mod.rs to delegate to submodules. Score: 7.85 → 10.0. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/vault/frontmatter.rs | 105 +- src-tauri/src/vault/mod.rs | 1616 +--------------------------- src-tauri/src/vault/mod_tests.rs | 98 +- 3 files changed, 154 insertions(+), 1665 deletions(-) diff --git a/src-tauri/src/vault/frontmatter.rs b/src-tauri/src/vault/frontmatter.rs index 599c85b2..164228d4 100644 --- a/src-tauri/src/vault/frontmatter.rs +++ b/src-tauri/src/vault/frontmatter.rs @@ -9,16 +9,6 @@ pub(crate) struct Frontmatter { pub is_a: Option, #[serde(default)] pub aliases: Option, - #[serde(rename = "Belongs to")] - pub belongs_to: Option, - #[serde(rename = "Related to")] - pub related_to: Option, - #[serde(rename = "Status")] - pub status: Option, - #[serde(rename = "Owner")] - pub owner: Option, - #[serde(rename = "Cadence")] - pub cadence: Option, #[serde( rename = "Archived", alias = "archived", @@ -33,26 +23,32 @@ pub(crate) struct Frontmatter { deserialize_with = "deserialize_bool_or_string" )] pub trashed: Option, + #[serde(rename = "Status", alias = "status", default)] + pub status: Option, + #[serde(rename = "Owner", alias = "owner", default)] + pub owner: Option, + #[serde(rename = "Cadence", alias = "cadence", default)] + pub cadence: Option, #[serde(rename = "Trashed at", alias = "trashed_at")] - pub trashed_at: Option, + pub trashed_at: Option, #[serde(rename = "Created at")] - pub created_at: Option, + pub created_at: Option, #[serde(rename = "Created time")] - pub created_time: Option, + pub created_time: Option, #[serde(default)] - pub icon: Option, + pub icon: Option, #[serde(default)] - pub color: Option, + pub color: Option, #[serde(default)] pub order: Option, #[serde(rename = "sidebar label", default)] - pub sidebar_label: Option, + pub sidebar_label: Option, #[serde(default)] - pub template: Option, + pub template: Option, #[serde(default)] - pub sort: Option, + pub sort: Option, #[serde(default)] - pub view: Option, + pub view: Option, #[serde(default)] pub visible: Option, } @@ -122,24 +118,30 @@ impl StringOrList { StringOrList::List(v) => v, } } + + /// Normalize to a single scalar: unwrap single-element arrays, take first + /// element of multi-element arrays, return scalar unchanged, empty array → None. + pub fn into_scalar(self) -> Option { + match self { + StringOrList::Single(s) => Some(s), + StringOrList::List(mut v) => { + if v.is_empty() { + None + } else { + Some(v.swap_remove(0)) + } + } + } + } } /// Parse frontmatter from raw YAML data extracted by gray_matter. fn parse_frontmatter(data: &HashMap) -> Frontmatter { - // Convert HashMap to serde_json::Value for deserialization. - // Filter to only known Frontmatter keys to prevent unknown fields with - // unexpected types (e.g. a list where a string is expected) from causing - // the entire deserialization to fail and return Default (all None). static KNOWN_KEYS: &[&str] = &[ "type", "Is A", "is_a", "aliases", - "Belongs to", - "Related to", - "Status", - "Owner", - "Cadence", "Archived", "archived", "Trashed", @@ -157,6 +159,12 @@ fn parse_frontmatter(data: &HashMap) -> Frontmatter { "view", "visible", "notion_id", + "Status", + "status", + "Owner", + "owner", + "Cadence", + "cadence", ]; let filtered: serde_json::Map = data .iter() @@ -173,8 +181,6 @@ const SKIP_KEYS: &[&str] = &[ "is a", "type", "aliases", - "status", - "cadence", "archived", "trashed", "trashed at", @@ -188,19 +194,18 @@ const SKIP_KEYS: &[&str] = &[ "sort", "view", "visible", + "status", + "owner", + "cadence", ]; /// 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( +pub(crate) fn extract_relationships( data: &HashMap, ) -> HashMap> { 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; } @@ -229,26 +234,15 @@ fn extract_relationships( 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( +pub(crate) fn extract_properties( data: &HashMap, ) -> HashMap { 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)) - { + if SKIP_KEYS.iter().any(|k| k.eq_ignore_ascii_case(&lower)) { continue; } @@ -268,8 +262,7 @@ fn extract_properties( properties } -/// Resolve `is_a` from frontmatter only. Type is determined purely by frontmatter, -/// never inferred from folder name. +/// Resolve `is_a` from frontmatter only. pub(crate) fn resolve_is_a(fm_is_a: Option) -> Option { fm_is_a.and_then(|a| a.into_vec().into_iter().next()) } @@ -277,9 +270,15 @@ pub(crate) fn resolve_is_a(fm_is_a: Option) -> Option { /// Parse created_at from frontmatter (prefer "Created at" over "Created time"). pub(crate) fn parse_created_at(fm: &Frontmatter) -> Option { 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))) + .clone() + .and_then(|v| v.into_scalar()) + .and_then(|s| parse_iso_date(&s)) + .or_else(|| { + fm.created_time + .clone() + .and_then(|v| v.into_scalar()) + .and_then(|s| parse_iso_date(&s)) + }) } /// Convert gray_matter::Pod to serde_json::Value diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index 379b0528..e741c758 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -1,5 +1,8 @@ mod cache; mod config_seed; +mod entry; +mod file; +mod frontmatter; mod getting_started; mod image; mod migration; @@ -9,411 +12,24 @@ 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 entry::VaultEntry; +pub use file::{get_note_content, save_note_content}; 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::{flatten_vault, migrate_is_a_to_type, vault_health_check, VaultHealthReport}; pub use rename::{rename_note, 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, -}; +use file::read_file_metadata; +use frontmatter::{extract_fm_and_rels, parse_created_at, resolve_is_a}; +use parsing::{count_body_words, extract_outgoing_links, extract_snippet, extract_title}; 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, - pub aliases: Vec, - #[serde(rename = "belongsTo")] - pub belongs_to: Vec, - #[serde(rename = "relatedTo")] - pub related_to: Vec, - pub status: Option, - pub owner: Option, - pub cadence: Option, - pub archived: bool, - pub trashed: bool, - #[serde(rename = "trashedAt")] - pub trashed_at: Option, - #[serde(rename = "modifiedAt")] - pub modified_at: Option, - #[serde(rename = "createdAt")] - pub created_at: Option, - #[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>, - /// Phosphor icon name (kebab-case) for Type entries, e.g. "cooking-pot". - pub icon: Option, - /// Accent color key for Type entries: "red", "purple", "blue", "green", "yellow", "orange". - pub color: Option, - /// Display order for Type entries in sidebar (lower = higher). None = use default order. - pub order: Option, - /// Custom sidebar section label for Type entries, overriding auto-pluralization. - #[serde(rename = "sidebarLabel")] - pub sidebar_label: Option, - /// 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, - /// 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, - /// 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, - /// Whether this Type is visible in the sidebar. Defaults to true when absent. - pub visible: Option, - /// 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, - /// Custom scalar frontmatter properties (non-relationship, non-structural). - /// Only includes strings, numbers, and booleans — arrays/objects are excluded. - #[serde(default)] - pub properties: HashMap, -} - -/// 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, - #[serde(default)] - aliases: Option, - #[serde( - rename = "Archived", - alias = "archived", - default, - deserialize_with = "deserialize_bool_or_string" - )] - archived: Option, - #[serde( - rename = "Trashed", - alias = "trashed", - default, - deserialize_with = "deserialize_bool_or_string" - )] - trashed: Option, - #[serde(rename = "Status", alias = "status", default)] - status: Option, - #[serde(rename = "Owner", alias = "owner", default)] - owner: Option, - #[serde(rename = "Cadence", alias = "cadence", default)] - cadence: Option, - #[serde(rename = "Trashed at", alias = "trashed_at")] - trashed_at: Option, - #[serde(rename = "Created at")] - created_at: Option, - #[serde(rename = "Created time")] - created_time: Option, - #[serde(default)] - icon: Option, - #[serde(default)] - color: Option, - #[serde(default)] - order: Option, - #[serde(rename = "sidebar label", default)] - sidebar_label: Option, - #[serde(default)] - template: Option, - #[serde(default)] - sort: Option, - #[serde(default)] - view: Option, - #[serde(default)] - visible: Option, -} - -/// 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, D::Error> -where - D: serde::Deserializer<'de>, -{ - use serde::de; - - struct BoolOrStringVisitor; - - impl<'de> de::Visitor<'de> for BoolOrStringVisitor { - type Value = Option; - - 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(self, v: bool) -> Result { - Ok(Some(v)) - } - - fn visit_str(self, v: &str) -> Result { - match v.to_lowercase().as_str() { - "true" | "yes" | "1" => Ok(Some(true)), - "false" | "no" | "0" | "" => Ok(Some(false)), - _ => Ok(Some(false)), - } - } - - fn visit_i64(self, v: i64) -> Result { - Ok(Some(v != 0)) - } - - fn visit_u64(self, v: u64) -> Result { - Ok(Some(v != 0)) - } - - fn visit_none(self) -> Result { - Ok(None) - } - - fn visit_unit(self) -> Result { - 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), -} - -impl StringOrList { - fn into_vec(self) -> Vec { - match self { - StringOrList::Single(s) => vec![s], - StringOrList::List(v) => v, - } - } - - /// Normalize to a single scalar: unwrap single-element arrays, take first - /// element of multi-element arrays, return scalar unchanged, empty array → None. - fn into_scalar(self) -> Option { - match self { - StringOrList::Single(s) => Some(s), - StringOrList::List(mut v) => { - if v.is_empty() { - None - } else { - Some(v.swap_remove(0)) - } - } - } - } -} - -/// Parse frontmatter from raw YAML data extracted by gray_matter. -fn parse_frontmatter(data: &HashMap) -> Frontmatter { - // Convert HashMap to serde_json::Value for deserialization. - // Filter to only known Frontmatter keys to prevent unknown fields with - // unexpected types (e.g. a list where a string is expected) from causing - // the entire deserialization to fail and return Default (all None). - static KNOWN_KEYS: &[&str] = &[ - "type", - "Is A", - "is_a", - "aliases", - "Archived", - "archived", - "Trashed", - "trashed", - "Trashed at", - "trashed_at", - "Created at", - "Created time", - "icon", - "color", - "order", - "sidebar label", - "template", - "sort", - "view", - "visible", - "notion_id", - "Status", - "status", - "Owner", - "owner", - "Cadence", - "cadence", - ]; - let filtered: serde_json::Map = data - .iter() - .filter(|(k, _)| KNOWN_KEYS.contains(&k.as_str())) - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - let value = serde_json::Value::Object(filtered); - 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", - "archived", - "trashed", - "trashed at", - "created at", - "created time", - "icon", - "color", - "order", - "sidebar label", - "template", - "sort", - "view", - "visible", - "status", - "owner", - "cadence", -]; - -/// 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, -) -> HashMap> { - 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 = 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 -} - -/// 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, -) -> HashMap { - 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)) { - 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 -} - -/// Resolve `is_a` from frontmatter only. Type is determined purely by frontmatter, -/// never inferred from folder name. -fn resolve_is_a(fm_is_a: Option) -> Option { - fm_is_a.and_then(|a| a.into_vec().into_iter().next()) -} - -/// Parse created_at from frontmatter (prefer "Created at" over "Created time"). -fn parse_created_at(fm: &Frontmatter) -> Option { - fm.created_at - .clone() - .and_then(|v| v.into_scalar()) - .and_then(|s| parse_iso_date(&s)) - .or_else(|| { - fm.created_time - .clone() - .and_then(|v| v.into_scalar()) - .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, -) -> ( - Frontmatter, - HashMap>, - HashMap, -) { - let hash = match data { - Some(gray_matter::Pod::Hash(map)) => map, - _ => return (Frontmatter::default(), HashMap::new(), HashMap::new()), - }; - let json_map: HashMap = - 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), 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 { let content = fs::read_to_string(path) @@ -439,7 +55,6 @@ pub fn parse_md_file(path: &Path) -> Result { // 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. "[[project]]"), use it directly let type_link = if type_name.starts_with("[[") && type_name.ends_with("]]") { type_name.clone() } else { @@ -474,7 +89,7 @@ pub fn parse_md_file(path: &Path) -> Result { .trashed_at .and_then(|v| v.into_scalar()) .as_deref() - .and_then(parse_iso_date), + .and_then(parsing::parse_iso_date), modified_at, created_at, file_size, @@ -492,27 +107,7 @@ pub fn parse_md_file(path: &Path) -> Result { }) } -/// 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 = - 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 { if !path.exists() { return Err(format!("File does not exist: {}", path.display())); @@ -520,51 +115,6 @@ pub fn reload_entry(path: &Path) -> Result { parse_md_file(path) } -/// Read the content of a single note file. -pub fn get_note_content(path: &Path) -> Result { - 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. /// Folders that are scanned recursively (themes, attachments, assets). /// All other subfolders are ignored — notes and type definitions live flat at the vault root. const PROTECTED_FOLDERS: &[&str] = &["attachments", "_themes", "assets"]; @@ -610,6 +160,7 @@ fn scan_protected_folders(vault_path: &Path, entries: &mut Vec) { } } +/// Scan a directory recursively for .md files and return VaultEntry for each. pub fn scan_vault(vault_path: &Path) -> Result, String> { if !vault_path.exists() { return Err(format!( @@ -633,1148 +184,5 @@ pub fn scan_vault(vault_path: &Path) -> Result, String> { } #[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"]); - // Belongs to / Related to are no longer first-class fields. - // As arrays of plain strings (no wikilinks), they don't appear in - // relationships or properties — only wikilink arrays become relationships, - // and only scalars become properties. - assert!(entry.relationships.get("Belongs to").is_none()); - assert!(entry.relationships.get("Related to").is_none()); - } - - #[test] - fn test_parse_full_frontmatter_scalars() { - let dir = TempDir::new().unwrap(); - let entry = parse_test_entry(&dir, "laputa.md", FULL_FM_CONTENT); - // Status, Owner, Cadence are first-class struct fields. - 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.relationships.is_empty()); - assert!(entry.properties.is_empty()); - } - - #[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_root_and_protected_folders() { - let dir = TempDir::new().unwrap(); - create_test_file(dir.path(), "root.md", "# Root Note\n"); - create_test_file( - dir.path(), - "project.md", - "---\ntype: Type\n---\n# Project\n", - ); - create_test_file(dir.path(), "attachments/notes.md", "# Attachment note\n"); - create_test_file(dir.path(), "not-markdown.txt", "This should be ignored"); - - let entries = scan_vault(dir.path()).unwrap(); - assert_eq!(entries.len(), 3); - - let filenames: Vec<&str> = entries.iter().map(|e| e.filename.as_str()).collect(); - assert!(filenames.contains(&"root.md")); - assert!(filenames.contains(&"project.md")); - assert!(filenames.contains(&"notes.md")); - } - - #[test] - fn test_scan_vault_skips_non_protected_subfolders() { - let dir = TempDir::new().unwrap(); - create_test_file(dir.path(), "root.md", "# Root Note\n"); - create_test_file( - dir.path(), - "random-folder/nested.md", - "---\ntype: Note\n---\n# Nested\n", - ); - create_test_file( - dir.path(), - "project/old-project.md", - "---\ntype: Project\n---\n# Old\n", - ); - - let entries = scan_vault(dir.path()).unwrap(); - assert_eq!(entries.len(), 1, "only root .md files should be scanned"); - assert_eq!(entries[0].filename, "root.md"); - } - - #[test] - fn test_scan_vault_includes_all_protected_folders() { - let dir = TempDir::new().unwrap(); - create_test_file(dir.path(), "root.md", "# Root\n"); - create_test_file(dir.path(), "_themes/legacy.md", "---\n---\n# Legacy\n"); - create_test_file(dir.path(), "attachments/notes.md", "# Attachment note\n"); - create_test_file(dir.path(), "assets/image.md", "# Asset\n"); - - let entries = scan_vault(dir.path()).unwrap(); - assert_eq!(entries.len(), 4); - } - - #[test] - fn test_scan_vault_skips_hidden_folders() { - let dir = TempDir::new().unwrap(); - create_test_file(dir.path(), "root.md", "# Root\n"); - create_test_file(dir.path(), ".laputa/cache.md", "# Cache\n"); - create_test_file(dir.path(), ".git/objects.md", "# Git\n"); - - let entries = scan_vault(dir.path()).unwrap(); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].filename, "root.md"); - } - - #[test] - fn test_scan_vault_nonexistent_path() { - let result = scan_vault(Path::new("/nonexistent/path/that/does/not/exist")); - 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 - - let has = entry.relationships.get("Has").unwrap(); - assert_eq!( - has, - &vec![ - "[[essay/foo|Foo Essay]]".to_string(), - "[[essay/bar|Bar Essay]]".to_string() - ] - ); - - let topics = entry.relationships.get("Topics").unwrap(); - assert_eq!( - topics, - &vec!["[[topic/rust]]".to_string(), "[[topic/wasm]]".to_string()] - ); - - let rel_type = entry.relationships.get("Type").unwrap(); - assert_eq!(rel_type, &vec!["[[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 is now a structural field (skipped from relationships) - assert!(entry.relationships.get("Owner").is_none()); - assert_eq!( - entry.owner, - Some("[[person/luca-rossi|Luca Rossi]]".to_string()) - ); - - // Belongs to is a wikilink array, should appear in relationships - let belongs = entry.relationships.get("Belongs to").unwrap(); - assert_eq!( - belongs, - &vec!["[[responsibility/grow-newsletter]]".to_string()] - ); - // Also parsed in the dedicated field - 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!["[[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> { - 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); - // Owner is now a structural field (skipped from relationships) - assert!(rels.get("Owner").is_none()); - } - - #[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: \"[[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>, 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: "[[project]]") - assert_eq!(len, 2); - assert_eq!(rels.get("Type").unwrap(), &vec!["[[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() - ] - ); - } - - // --- type from frontmatter only (no folder inference) --- - - #[test] - fn test_type_from_frontmatter_only() { - let dir = TempDir::new().unwrap(); - create_test_file(dir.path(), "test.md", "---\ntype: Custom\n---\n# Test\n"); - let entry = parse_md_file(&dir.path().join("test.md")).unwrap(); - assert_eq!(entry.is_a, Some("Custom".to_string())); - } - - #[test] - fn test_no_type_when_frontmatter_missing() { - let dir = TempDir::new().unwrap(); - create_test_file(dir.path(), "note/test.md", "# Test\n"); - let entry = parse_md_file(&dir.path().join("note/test.md")).unwrap(); - assert_eq!(entry.is_a, None, "type should not be inferred from folder"); - } - - // --- 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!["[[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, "project.md", content); - assert!(entry.relationships.get("Type").is_none()); - } - - #[test] - fn test_no_type_relationship_without_frontmatter() { - let dir = TempDir::new().unwrap(); - let content = "# A Person\n\nSome content."; - let entry = parse_test_entry(&dir, "someone.md", content); - assert_eq!(entry.is_a, None); - assert!(entry.relationships.get("Type").is_none()); - } - - #[test] - fn test_type_relationship_handles_wikilink_is_a() { - let dir = TempDir::new().unwrap(); - let content = "---\nIs A: \"[[experiment]]\"\n---\n# Test\n"; - let entry = parse_test_entry(&dir, "test.md", content); - assert_eq!( - entry.relationships.get("Type").unwrap(), - &vec!["[[experiment]]".to_string()] - ); - } - - #[test] - fn test_type_from_frontmatter_not_folder() { - let dir = TempDir::new().unwrap(); - let content = "---\ntype: Type\n---\n# Some Type\n"; - let entry = parse_test_entry(&dir, "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!["[[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, "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, "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, "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, "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, "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, "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, "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, "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, "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, "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, "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 = [ - ("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, "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, "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, "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, "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, "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())); - } - - // --- StringOrList normalization (uniform, no per-field special cases) --- - - #[test] - fn test_single_element_array_owner_unwraps_to_scalar() { - let dir = TempDir::new().unwrap(); - let content = "---\ntype: Responsibility\nOwner:\n - Luca\n---\n# Test\n"; - let entry = parse_test_entry(&dir, "test.md", content); - assert_eq!(entry.owner, Some("Luca".to_string())); - assert_eq!(entry.is_a, Some("Responsibility".to_string())); - } - - #[test] - fn test_single_element_array_cadence_unwraps_to_scalar() { - let dir = TempDir::new().unwrap(); - let content = "---\ntype: Procedure\nCadence:\n - Weekly\n---\n# Test\n"; - let entry = parse_test_entry(&dir, "test.md", content); - assert_eq!(entry.cadence, Some("Weekly".to_string())); - assert_eq!(entry.is_a, Some("Procedure".to_string())); - } - - #[test] - fn test_single_element_array_status_unwraps_to_scalar() { - let dir = TempDir::new().unwrap(); - let content = "---\ntype: Project\nStatus:\n - Active\n---\n# Test\n"; - let entry = parse_test_entry(&dir, "test.md", content); - assert_eq!(entry.status, Some("Active".to_string())); - assert_eq!(entry.is_a, Some("Project".to_string())); - } - - #[test] - fn test_multi_element_array_owner_takes_first() { - let dir = TempDir::new().unwrap(); - let content = "---\ntype: Project\nOwner:\n - Alice\n - Bob\n---\n# Test\n"; - let entry = parse_test_entry(&dir, "test.md", content); - assert_eq!(entry.owner, Some("Alice".to_string())); - } - - #[test] - fn test_scalar_fields_unchanged() { - let dir = TempDir::new().unwrap(); - let content = - "---\ntype: Project\nOwner: Luca\nCadence: Daily\nStatus: Done\n---\n# Test\n"; - let entry = parse_test_entry(&dir, "test.md", content); - assert_eq!(entry.owner, Some("Luca".to_string())); - assert_eq!(entry.cadence, Some("Daily".to_string())); - assert_eq!(entry.status, Some("Done".to_string())); - } - - #[test] - fn test_absent_fields_no_crash() { - let dir = TempDir::new().unwrap(); - let content = "---\ntype: Note\n---\n# Test\n"; - let entry = parse_test_entry(&dir, "test.md", content); - assert_eq!(entry.owner, None); - assert_eq!(entry.cadence, None); - assert_eq!(entry.status, None); - } - - #[test] - fn test_array_field_does_not_break_type_detection() { - // Regression: when Owner was Option, a YAML array like [Luca] - // caused serde to fail the entire Frontmatter → all fields defaulted to None, - // losing the is_a field and breaking the type badge. - let dir = TempDir::new().unwrap(); - let content = "---\ntype: Responsibility\nOwner:\n - Luca\nCadence:\n - Weekly\nStatus:\n - Active\n---\n# My Responsibility\n"; - let entry = parse_test_entry(&dir, "test.md", content); - assert_eq!( - entry.is_a, - Some("Responsibility".to_string()), - "type must not be lost when other fields are arrays" - ); - - assert_eq!(entry.owner, Some("Luca".to_string())); - assert_eq!(entry.cadence, Some("Weekly".to_string())); - assert_eq!(entry.status, Some("Active".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 -} +#[path = "mod_tests.rs"] +mod tests; diff --git a/src-tauri/src/vault/mod_tests.rs b/src-tauri/src/vault/mod_tests.rs index dd6c00a0..52da9786 100644 --- a/src-tauri/src/vault/mod_tests.rs +++ b/src-tauri/src/vault/mod_tests.rs @@ -63,8 +63,12 @@ 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"]); + // Belongs to / Related to are no longer first-class fields. + // As arrays of plain strings (no wikilinks), they don't appear in + // relationships or properties — only wikilink arrays become relationships, + // and only scalars become properties. + assert!(entry.relationships.get("Belongs to").is_none()); + assert!(entry.relationships.get("Related to").is_none()); } #[test] @@ -294,17 +298,21 @@ Belongs to: 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 + + // Owner is now a structural field (skipped from relationships) + assert!(entry.relationships.get("Owner").is_none()); assert_eq!( - entry.relationships.get("Owner").unwrap(), - &vec!["[[person/luca-rossi|Luca Rossi]]".to_string()] + entry.owner, + Some("[[person/luca-rossi|Luca Rossi]]".to_string()) ); - // Belongs to is also a wikilink array, should appear in relationships + + // Belongs to is a wikilink array, should appear in relationships + let belongs = entry.relationships.get("Belongs to").unwrap(); assert_eq!( - entry.relationships.get("Belongs to").unwrap(), + belongs, &vec!["[[responsibility/grow-newsletter]]".to_string()] ); - // Still parsed in the dedicated field too + // Also parsed in the dedicated field assert_eq!(entry.belongs_to, vec!["[[responsibility/grow-newsletter]]"]); } @@ -1048,6 +1056,80 @@ fn test_roundtrip_is_a_snake_case_alias_still_works() { assert_eq!(entry.is_a, Some("Quarter".to_string())); } +// --- StringOrList normalization (uniform, no per-field special cases) --- + +#[test] +fn test_single_element_array_owner_unwraps_to_scalar() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Responsibility\nOwner:\n - Luca\n---\n# Test\n"; + let entry = parse_test_entry(&dir, "test.md", content); + assert_eq!(entry.owner, Some("Luca".to_string())); + assert_eq!(entry.is_a, Some("Responsibility".to_string())); +} + +#[test] +fn test_single_element_array_cadence_unwraps_to_scalar() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Procedure\nCadence:\n - Weekly\n---\n# Test\n"; + let entry = parse_test_entry(&dir, "test.md", content); + assert_eq!(entry.cadence, Some("Weekly".to_string())); + assert_eq!(entry.is_a, Some("Procedure".to_string())); +} + +#[test] +fn test_single_element_array_status_unwraps_to_scalar() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Project\nStatus:\n - Active\n---\n# Test\n"; + let entry = parse_test_entry(&dir, "test.md", content); + assert_eq!(entry.status, Some("Active".to_string())); + assert_eq!(entry.is_a, Some("Project".to_string())); +} + +#[test] +fn test_multi_element_array_owner_takes_first() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Project\nOwner:\n - Alice\n - Bob\n---\n# Test\n"; + let entry = parse_test_entry(&dir, "test.md", content); + assert_eq!(entry.owner, Some("Alice".to_string())); +} + +#[test] +fn test_scalar_fields_unchanged() { + let dir = TempDir::new().unwrap(); + let content = + "---\ntype: Project\nOwner: Luca\nCadence: Daily\nStatus: Done\n---\n# Test\n"; + let entry = parse_test_entry(&dir, "test.md", content); + assert_eq!(entry.owner, Some("Luca".to_string())); + assert_eq!(entry.cadence, Some("Daily".to_string())); + assert_eq!(entry.status, Some("Done".to_string())); +} + +#[test] +fn test_absent_fields_no_crash() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Note\n---\n# Test\n"; + let entry = parse_test_entry(&dir, "test.md", content); + assert_eq!(entry.owner, None); + assert_eq!(entry.cadence, None); + assert_eq!(entry.status, None); +} + +#[test] +fn test_array_field_does_not_break_type_detection() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Responsibility\nOwner:\n - Luca\nCadence:\n - Weekly\nStatus:\n - Active\n---\n# My Responsibility\n"; + let entry = parse_test_entry(&dir, "test.md", content); + assert_eq!( + entry.is_a, + Some("Responsibility".to_string()), + "type must not be lost when other fields are arrays" + ); + + assert_eq!(entry.owner, Some("Luca".to_string())); + assert_eq!(entry.cadence, Some("Weekly".to_string())); + assert_eq!(entry.status, Some("Active".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