From 6a203b27f92d7fe519a7d440b6dcf5bcd9150c09 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 20 Feb 2026 21:44:46 +0100 Subject: [PATCH] feat(vault): add Type relationship and type folder inference - Add "type" folder to infer_type_from_folder() mapping - When a VaultEntry has isA set (and it's not "Type"), automatically add a "Type" relationship (e.g., "[[type/project]]") to the relationships map, making the type property a navigable link - Handle both plain string isA ("Project") and wikilink isA ("[[type/project]]") correctly - Add 5 new tests for the Type relationship feature Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/vault.rs | 90 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs index 0470c519..ee37b3f8 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -257,6 +257,7 @@ fn infer_type_from_folder(folder: &str) -> String { "event" => "Event", "topic" => "Topic", "experiment" => "Experiment", + "type" => "Type", "note" => "Note", "quarter" => "Quarter", "measure" => "Measure", @@ -319,7 +320,7 @@ pub fn parse_md_file(path: &Path) -> Result { let matter = Matter::::new(); let parsed = matter.parse(&content); - let (frontmatter, relationships) = extract_fm_and_rels(parsed.data); + let (frontmatter, mut relationships) = extract_fm_and_rels(parsed.data); let title = extract_title(&parsed.content, &filename); let snippet = extract_snippet(&content); @@ -327,6 +328,20 @@ pub fn parse_md_file(path: &Path) -> Result { 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, @@ -897,7 +912,7 @@ Status: Active 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(), 2); + 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()] @@ -906,6 +921,10 @@ Status: Active 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] @@ -952,8 +971,12 @@ Custom Field: just a plain string create_test_file(dir.path(), "plain-note.md", content); let entry = parse_md_file(&dir.path().join("plain-note.md")).unwrap(); - // Tags and Custom Field don't contain wikilinks, so relationships should be empty - assert!(entry.relationships.is_empty()); + // 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"; @@ -1056,7 +1079,12 @@ Context: "[[area/research]]" 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()]); - assert_eq!(len, 1); + // "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] @@ -1390,5 +1418,57 @@ References: 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())); + } + // Frontmatter update/delete tests are in frontmatter.rs }