When a .yml file is opened in the raw editor, the breadcrumb bar now shows the YAML `name` field (e.g., "Active Projects") instead of the filename. Falls back to filename if no name field is present. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1528 lines
52 KiB
Rust
1528 lines
52 KiB
Rust
use super::*;
|
||
use std::collections::HashMap;
|
||
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), None).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn test_reload_entry_returns_fresh_data() {
|
||
let dir = TempDir::new().unwrap();
|
||
create_test_file(
|
||
dir.path(),
|
||
"my-note.md",
|
||
"---\ntitle: My Note\nStatus: Active\n---\n# My Note\n\nOriginal.",
|
||
);
|
||
let entry = reload_entry(&dir.path().join("my-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(),
|
||
"my-note.md",
|
||
"---\ntitle: My Note\nStatus: Done\n---\n# My Note\n\nUpdated.",
|
||
);
|
||
let fresh = reload_entry(&dir.path().join("my-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 = "---\ntitle: Laputa Project\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);
|
||
assert_eq!(entry.status, Some("Active".to_string()));
|
||
assert_eq!(
|
||
entry.properties.get("Owner").and_then(|v| v.as_str()),
|
||
Some("Luca")
|
||
);
|
||
assert_eq!(
|
||
entry.properties.get("Cadence").and_then(|v| v.as_str()),
|
||
Some("Weekly")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_empty_frontmatter() {
|
||
let dir = TempDir::new().unwrap();
|
||
let entry = parse_test_entry(
|
||
&dir,
|
||
"just-a-title.md",
|
||
"---\n---\n# Just a Title\n\nNo frontmatter fields.",
|
||
);
|
||
// No title in frontmatter → derived from filename slug (H1 is body content)
|
||
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(), "a-note-without-frontmatter.md", content);
|
||
|
||
let entry = parse_md_file(&dir.path().join("a-note-without-frontmatter.md"), None).unwrap();
|
||
// No title in frontmatter → derived from filename
|
||
assert_eq!(entry.title, "A Note Without Frontmatter");
|
||
}
|
||
|
||
#[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"), None).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 included as text",
|
||
);
|
||
|
||
let entries = scan_vault(dir.path(), &HashMap::new()).unwrap();
|
||
assert_eq!(entries.len(), 4);
|
||
|
||
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"));
|
||
assert!(filenames.contains(&"not-markdown.txt"));
|
||
|
||
let txt_entry = entries
|
||
.iter()
|
||
.find(|e| e.filename == "not-markdown.txt")
|
||
.unwrap();
|
||
assert_eq!(txt_entry.file_kind, "text");
|
||
assert_eq!(txt_entry.title, "not-markdown.txt");
|
||
}
|
||
|
||
#[test]
|
||
fn test_scan_vault_includes_subdirectory_notes() {
|
||
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(), &HashMap::new()).unwrap();
|
||
assert_eq!(
|
||
entries.len(),
|
||
3,
|
||
"all .md files including subdirs should be scanned"
|
||
);
|
||
let filenames: Vec<&str> = entries.iter().map(|e| e.filename.as_str()).collect();
|
||
assert!(filenames.contains(&"root.md"));
|
||
assert!(filenames.contains(&"nested.md"));
|
||
assert!(filenames.contains(&"old-project.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(), "attachments/notes.md", "# Attachment note\n");
|
||
create_test_file(dir.path(), "assets/image.md", "# Asset\n");
|
||
|
||
let entries = scan_vault(dir.path(), &HashMap::new()).unwrap();
|
||
assert_eq!(entries.len(), 3);
|
||
}
|
||
|
||
#[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(), &HashMap::new()).unwrap();
|
||
assert_eq!(entries.len(), 1);
|
||
assert_eq!(entries[0].filename, "root.md");
|
||
}
|
||
|
||
#[test]
|
||
fn test_scan_vault_nonexistent_path() {
|
||
let result = scan_vault(
|
||
Path::new("/nonexistent/path/that/does/not/exist"),
|
||
&HashMap::new(),
|
||
);
|
||
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"), None);
|
||
// 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"), None).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"), None).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"), None).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"), None).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!["[[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"), None).unwrap();
|
||
|
||
// Owner with wikilink should appear in relationships
|
||
assert!(entry.relationships.get("Owner").is_some());
|
||
// Wikilinks don't go to properties
|
||
assert!(entry.properties.get("Owner").is_none());
|
||
|
||
// 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"), None).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<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);
|
||
// Owner with wikilink should be in relationships
|
||
assert!(rels.get("Owner").is_some());
|
||
}
|
||
|
||
#[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"), None).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<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_some());
|
||
assert!(rels.get("Created at").is_some());
|
||
assert!(rels.get("Created time").is_some());
|
||
}
|
||
|
||
#[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" + "Type" + "Cadence" + "Created at" + "Created time"
|
||
assert_eq!(len, 5);
|
||
assert_eq!(rels.get("Type").unwrap(), &vec!["[[project]]".to_string()]);
|
||
assert!(rels.get("Cadence").is_some());
|
||
assert!(rels.get("Created at").is_some());
|
||
assert!(rels.get("Created time").is_some());
|
||
}
|
||
|
||
#[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"), None).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()
|
||
]
|
||
);
|
||
}
|
||
|
||
// --- large relationship array (regression: No Code note with 32 Notes) ---
|
||
|
||
#[test]
|
||
fn test_parse_large_notes_relationship_array() {
|
||
let dir = TempDir::new().unwrap();
|
||
let content = r#"---
|
||
type: Topic
|
||
Referred by Data:
|
||
- "[[michele-sampieri|Michele Sampieri]]"
|
||
- "[[varun-anand|Varun Anand]]"
|
||
Belongs to:
|
||
- "[[engineering|Engineering]]"
|
||
aliases:
|
||
- No Code
|
||
Notes:
|
||
- "[[8020-we-help-companies-move-faster-without-code|8020 | We help companies move faster without code.]]"
|
||
- "[[airdev-build-hub|Airdev Build Hub]]"
|
||
- "[[airdev-leader-in-bubble-and-no-code-development|AirDev | Leader in Bubble and No-Code Development]]"
|
||
- "[[budibase-internal-tools-made-easy|Budibase - Internal tools made easy]]"
|
||
- "[[bullet-launch-bubble-boilerplate|Bullet Launch Bubble Boilerplate]]"
|
||
- "[[canvas-base-template-for-bubble|Canvas • Base Template for Bubble]]"
|
||
- "[[chameleon-microsurveys|Chameleon | Microsurveys]]"
|
||
- "[[felt-the-best-way-to-make-maps-on-the-internet|Felt – The best way to make maps on the internet]]"
|
||
- "[[flutterflow-build-native-apps-visually|FlutterFlow | Build Native Apps Visually]]"
|
||
- "[[framer-ai-generate-and-publish-your-site-with-ai-in-seconds|Framer AI — Generate and publish your site with AI in seconds.]]"
|
||
- "[[jumpstart-pro-the-best-ruby-on-rails-saas-template|Jumpstart Pro | The best Ruby on Rails SaaS Template]]"
|
||
- "[[mailparser-email-parser-software-workflow-automation|MailParser • Email Parser Software & Workflow Automation]]"
|
||
- "[[make-work-the-way-you-imagine|Make | Work the way you imagine]]"
|
||
- "[[michele-sampieri|Michele Sampieri]]"
|
||
- "[[n8nio-a-powerful-workflow-automation-tool|n8n.io - a powerful workflow automation tool]]"
|
||
- "[[n8nio-ai-workflow-automation-tool|n8n.io - AI workflow automation tool]]"
|
||
- "[[nocodey-find-best-nocoder|Nocodey • Find Best Nocoder]]"
|
||
- "[[outseta-software-for-subscription-start-ups|Outseta | Software for subscription start-ups]]"
|
||
- "[[payments-tax-subscriptions-for-software-companies-lemon-squeezy|Payments, tax & subscriptions for software companies • Lemon Squeezy]]"
|
||
- "[[retool-portals-custom-client-portal-software|Retool Portals • Custom Client Portal Software]]"
|
||
- "[[rise-of-the-no-code-economy-report-formstack|Rise of the No-Code Economy Report | Formstack]]"
|
||
- "[[scene-the-smart-way-to-build-websites|Scene • The smart way to build websites]]"
|
||
- "[[scrapingbee-the-best-web-scraping-api|ScrapingBee • the best web scraping API]]"
|
||
- "[[softr-build-a-website-web-app-or-portal-on-airtable-without-code|Softr | Build a website, web app or portal on Airtable without code]]"
|
||
- "[[superblocks-build-modern-internal-apps-in-days-not-months|Superblocks • Build modern internal apps in days, not months]]"
|
||
- "[[superwall-quickly-deploy-paywalls|Superwall • Quickly deploy paywalls]]"
|
||
- "[[tails-tailwind-css-page-creator|Tails | Tailwind CSS Page Creator]]"
|
||
- "[[the-open-source-firebase-alternative-supabase|The Open Source Firebase Alternative | Supabase]]"
|
||
- "[[varun-anand|Varun Anand]]"
|
||
- "[[xano-the-fastest-no-code-backend-development-platform|Xano - The Fastest No Code Backend Development Platform]]"
|
||
- "[[directus-open-data-platform-for-headless-content-management|{'Directus': 'Open Data Platform for Headless Content Management'}]]"
|
||
- "[[framer-design-beautiful-websites-in-minutes|{'Framer': 'Design beautiful websites in minutes'}]]"
|
||
title: No Code
|
||
---
|
||
# No Code
|
||
"#;
|
||
create_test_file(dir.path(), "no-code.md", content);
|
||
let entry = parse_md_file(&dir.path().join("no-code.md"), None).unwrap();
|
||
|
||
let notes = entry
|
||
.relationships
|
||
.get("Notes")
|
||
.expect("Notes relationship should exist");
|
||
assert_eq!(notes.len(), 32, "All 32 Notes entries should be parsed");
|
||
|
||
let referred = entry
|
||
.relationships
|
||
.get("Referred by Data")
|
||
.expect("Referred by Data should exist");
|
||
assert_eq!(referred.len(), 2);
|
||
|
||
let belongs = entry
|
||
.relationships
|
||
.get("Belongs to")
|
||
.expect("Belongs to should exist");
|
||
assert_eq!(belongs.len(), 1);
|
||
}
|
||
|
||
// --- 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"), None).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"), None).unwrap();
|
||
assert_eq!(entry.is_a, None, "type should not be inferred from folder");
|
||
}
|
||
|
||
// --- created_at sourcing from filesystem ---
|
||
|
||
#[test]
|
||
fn test_created_at_from_filesystem() {
|
||
let dir = TempDir::new().unwrap();
|
||
let content = "---\nIs A: Note\n---\n# Test\n";
|
||
create_test_file(dir.path(), "test.md", content);
|
||
|
||
let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap();
|
||
// created_at should be set from filesystem metadata (not None)
|
||
assert!(
|
||
entry.created_at.is_some(),
|
||
"created_at should come from filesystem"
|
||
);
|
||
}
|
||
|
||
// --- 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<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);
|
||
// Priority + Owner + Cadence should survive — all others are structural
|
||
assert_eq!(entry.properties.len(), 3);
|
||
assert_eq!(
|
||
entry.properties.get("Priority").and_then(|v| v.as_str()),
|
||
Some("High")
|
||
);
|
||
assert_eq!(
|
||
entry.properties.get("Owner").and_then(|v| v.as_str()),
|
||
Some("Luca")
|
||
);
|
||
assert_eq!(
|
||
entry.properties.get("Cadence").and_then(|v| v.as_str()),
|
||
Some("Weekly")
|
||
);
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
|
||
// --- new canonical underscore-prefixed keys ---
|
||
|
||
#[test]
|
||
fn test_parse_underscore_trashed_canonical() {
|
||
let dir = TempDir::new().unwrap();
|
||
let content = "---\n_trashed: true\n_trashed_at: \"2026-03-15\"\n---\n# Gone\n";
|
||
let entry = parse_test_entry(&dir, "gone-new.md", content);
|
||
assert!(entry.trashed, "'_trashed: true' must be parsed as trashed");
|
||
assert!(
|
||
entry.trashed_at.is_some(),
|
||
"'_trashed_at' must be parsed as trashed_at"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_underscore_archived_canonical() {
|
||
let dir = TempDir::new().unwrap();
|
||
let content = "---\n_archived: true\n---\n# Old\n";
|
||
let entry = parse_test_entry(&dir, "old-new.md", content);
|
||
assert!(
|
||
entry.archived,
|
||
"'_archived: true' must be parsed as archived"
|
||
);
|
||
}
|
||
|
||
// --- favorite field tests ---
|
||
|
||
#[test]
|
||
fn test_parse_favorite_true() {
|
||
let dir = TempDir::new().unwrap();
|
||
let content = "---\n_favorite: true\n_favorite_index: 3\n---\n# Fav\n";
|
||
let entry = parse_test_entry(&dir, "fav.md", content);
|
||
assert!(
|
||
entry.favorite,
|
||
"'_favorite: true' must be parsed as favorite"
|
||
);
|
||
assert_eq!(entry.favorite_index, Some(3));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_favorite_absent_defaults_false() {
|
||
let dir = TempDir::new().unwrap();
|
||
let content = "---\ntype: Note\n---\n# Not Fav\n";
|
||
let entry = parse_test_entry(&dir, "not-fav.md", content);
|
||
assert!(!entry.favorite, "absent _favorite must default to false");
|
||
assert_eq!(entry.favorite_index, None);
|
||
}
|
||
|
||
// --- 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);
|
||
}
|
||
|
||
// --- Regression: trashed/archived must survive unquoted date in "Trashed at" ---
|
||
|
||
#[test]
|
||
fn test_trashed_true_with_unquoted_date_in_trashed_at() {
|
||
let dir = TempDir::new().unwrap();
|
||
// Reproduces the engineering-management.md scenario: Trashed at has an
|
||
// unquoted YAML date (2026-03-11) which gray_matter may parse as a non-string.
|
||
// The entire Frontmatter deserialization must NOT fail because of this.
|
||
let content = "---\ntype: Topic\nTrashed: true\n\"Trashed at\": 2026-03-11\n---\n# Engineering Management\n";
|
||
let entry = parse_test_entry(&dir, "engineering-management.md", content);
|
||
assert!(
|
||
entry.trashed,
|
||
"Trashed must be true even when 'Trashed at' contains an unquoted date"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_archived_true_with_extra_non_string_fields() {
|
||
let dir = TempDir::new().unwrap();
|
||
// If any StringOrList field gets a non-string value, archived must still parse.
|
||
let content = "---\nArchived: true\norder: 5\n---\n# Archived Note\n";
|
||
let entry = parse_test_entry(&dir, "archived-extra.md", content);
|
||
assert!(
|
||
entry.archived,
|
||
"Archived must be true even with other fields present"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_trashed_with_reviewed_false_field() {
|
||
let dir = TempDir::new().unwrap();
|
||
let content = "---\ntype: Topic\nReviewed: False\nTrashed: true\n---\n# Test\n";
|
||
let entry = parse_test_entry(&dir, "reviewed-test.md", content);
|
||
assert!(
|
||
entry.trashed,
|
||
"Trashed must be true even when frontmatter contains Reviewed: False"
|
||
);
|
||
assert_eq!(entry.is_a, Some("Topic".to_string()));
|
||
}
|
||
|
||
/// Regression: wikilinks containing curly braces + nested quotes in YAML arrays
|
||
/// cause gray_matter to produce Hash values instead of strings for array elements.
|
||
/// This must NOT make parse_frontmatter fall back to default (losing trashed/archived).
|
||
#[test]
|
||
fn test_trashed_survives_malformed_wikilinks_in_yaml() {
|
||
let dir = TempDir::new().unwrap();
|
||
// This YAML has curly braces inside a double-quoted string, producing nested
|
||
// Hash values in some YAML parsers. The Frontmatter serde must not fail.
|
||
let content = "---\ntype: Topic\nNotes:\n - \"[[foo|bar]]\"\n - \"[[slug|{'Title': 'Subtitle'}]]\"\nTrashed: true\n---\n# Test\n";
|
||
let entry = parse_test_entry(&dir, "malformed-links.md", content);
|
||
assert!(
|
||
entry.trashed,
|
||
"Trashed must be true even with curly-brace wikilinks in frontmatter arrays"
|
||
);
|
||
}
|
||
|
||
/// Regression: files with malformed YAML (e.g. Notion exports with unescaped quotes
|
||
/// in wikilinks) cause gray_matter to return Null instead of a Hash. The fallback
|
||
/// parser must still extract Trashed, type, and other simple key:value fields.
|
||
#[test]
|
||
fn test_parse_real_engineering_management_file() {
|
||
let path = std::path::Path::new("/Users/luca/Laputa/engineering-management.md");
|
||
if !path.exists() {
|
||
return; // Skip when the Laputa vault is not available
|
||
}
|
||
let entry = parse_md_file(path, None).unwrap();
|
||
assert!(
|
||
entry.trashed,
|
||
"engineering-management.md must be trashed (has Trashed: true in frontmatter)"
|
||
);
|
||
assert_eq!(entry.is_a, Some("Topic".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn test_fallback_parser_extracts_trashed_from_malformed_yaml() {
|
||
let dir = TempDir::new().unwrap();
|
||
// Simulate malformed YAML that gray_matter can't parse: unescaped double
|
||
// quotes inside a double-quoted YAML string cause the YAML parser to fail.
|
||
// The fallback line-by-line parser must still extract simple key:value pairs.
|
||
//
|
||
// Write the file manually with literal unescaped quotes (can't use Rust string
|
||
// escaping for this since the YAML itself is the malformed part).
|
||
let fm = [
|
||
"---",
|
||
"type: Topic",
|
||
"Status: Draft",
|
||
"Belongs to:",
|
||
" - \"[[engineering|Engineering]]\"",
|
||
"aliases:",
|
||
" - Engineering Management",
|
||
"Notes:",
|
||
// This line has unescaped " inside a "-quoted YAML string — malformed YAML
|
||
" - \"[[slug|{\"Title\": 'Subtitle'}]]\"",
|
||
"Trashed: true",
|
||
"\"Trashed at\": 2026-03-11",
|
||
"---",
|
||
"",
|
||
"# Engineering Management",
|
||
];
|
||
let content = fm.join("\n");
|
||
create_test_file(dir.path(), "eng-mgmt.md", &content);
|
||
let entry = parse_md_file(&dir.path().join("eng-mgmt.md"), None).unwrap();
|
||
assert!(
|
||
entry.trashed,
|
||
"Trashed must be true even when YAML is malformed (fallback parser)"
|
||
);
|
||
assert_eq!(
|
||
entry.is_a,
|
||
Some("Topic".to_string()),
|
||
"isA must be extracted by fallback parser"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_fallback_parser_extracts_archived_from_malformed_yaml() {
|
||
let dir = TempDir::new().unwrap();
|
||
let fm = [
|
||
"---",
|
||
"type: Essay",
|
||
"Notes:",
|
||
" - \"[[slug|{\"Broken\": 'quotes'}]]\"",
|
||
"Archived: true",
|
||
"---",
|
||
"",
|
||
"# Archived Essay",
|
||
];
|
||
let content = fm.join("\n");
|
||
create_test_file(dir.path(), "archived-essay.md", &content);
|
||
let entry = parse_md_file(&dir.path().join("archived-essay.md"), None).unwrap();
|
||
assert!(
|
||
entry.archived,
|
||
"Archived must be true even when YAML is malformed"
|
||
);
|
||
assert_eq!(entry.is_a, Some("Essay".to_string()));
|
||
}
|
||
|
||
#[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.properties.get("Owner").and_then(|v| v.as_str()),
|
||
Some("Luca")
|
||
);
|
||
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.properties.get("Cadence").and_then(|v| v.as_str()),
|
||
Some("Weekly")
|
||
);
|
||
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_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.properties.get("Owner").and_then(|v| v.as_str()),
|
||
Some("Luca")
|
||
);
|
||
assert_eq!(
|
||
entry.properties.get("Cadence").and_then(|v| v.as_str()),
|
||
Some("Daily")
|
||
);
|
||
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.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.status, Some("Active".to_string()));
|
||
}
|
||
|
||
// ── Folder tree tests ──────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn test_scan_vault_folders_returns_tree() {
|
||
let dir = TempDir::new().unwrap();
|
||
fs::create_dir_all(dir.path().join("projects/laputa")).unwrap();
|
||
fs::create_dir_all(dir.path().join("areas")).unwrap();
|
||
|
||
let folders = scan_vault_folders(dir.path()).unwrap();
|
||
let names: Vec<&str> = folders.iter().map(|f| f.name.as_str()).collect();
|
||
assert!(names.contains(&"projects"));
|
||
assert!(names.contains(&"areas"));
|
||
|
||
let projects = folders.iter().find(|f| f.name == "projects").unwrap();
|
||
assert_eq!(projects.children.len(), 1);
|
||
assert_eq!(projects.children[0].name, "laputa");
|
||
assert_eq!(projects.children[0].path, "projects/laputa");
|
||
}
|
||
|
||
#[test]
|
||
fn test_scan_vault_folders_excludes_hidden() {
|
||
let dir = TempDir::new().unwrap();
|
||
fs::create_dir_all(dir.path().join(".git")).unwrap();
|
||
fs::create_dir_all(dir.path().join(".laputa")).unwrap();
|
||
fs::create_dir_all(dir.path().join("visible")).unwrap();
|
||
|
||
let folders = scan_vault_folders(dir.path()).unwrap();
|
||
assert_eq!(folders.len(), 1);
|
||
assert_eq!(folders[0].name, "visible");
|
||
}
|
||
|
||
#[test]
|
||
fn test_scan_vault_folders_flat_vault() {
|
||
let dir = TempDir::new().unwrap();
|
||
create_test_file(dir.path(), "note.md", "# Note\n");
|
||
|
||
let folders = scan_vault_folders(dir.path()).unwrap();
|
||
assert!(folders.is_empty(), "flat vault has no visible folders");
|
||
}
|
||
|
||
// --- list_properties_display tests ---
|
||
|
||
#[test]
|
||
fn test_parse_list_properties_display() {
|
||
let dir = TempDir::new().unwrap();
|
||
let content =
|
||
"---\ntype: Type\n_list_properties_display:\n - rating\n - genre\n---\n# Movies\n";
|
||
let entry = parse_test_entry(&dir, "movies.md", content);
|
||
assert_eq!(entry.list_properties_display, vec!["rating", "genre"]);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_list_properties_display_absent_defaults_empty() {
|
||
let dir = TempDir::new().unwrap();
|
||
let content = "---\ntype: Type\n---\n# Books\n";
|
||
let entry = parse_test_entry(&dir, "books.md", content);
|
||
assert!(entry.list_properties_display.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn test_list_properties_display_not_in_properties_or_relationships() {
|
||
let dir = TempDir::new().unwrap();
|
||
let content = "---\ntype: Type\n_list_properties_display:\n - rating\n---\n# Movies\n";
|
||
let entry = parse_test_entry(&dir, "movies.md", content);
|
||
assert!(
|
||
!entry.properties.contains_key("_list_properties_display"),
|
||
"_list_properties_display must not leak into properties map"
|
||
);
|
||
assert!(
|
||
!entry.relationships.contains_key("_list_properties_display"),
|
||
"_list_properties_display must not leak into relationships map"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_yml_file_uses_name_field_as_title() {
|
||
let dir = TempDir::new().unwrap();
|
||
let yml_content = "name: Active Projects\nicon: rocket\ncolor: blue\n";
|
||
let yml_path = dir.path().join("active-projects.yml");
|
||
std::fs::write(&yml_path, yml_content).unwrap();
|
||
let entry = super::parse_non_md_file(&yml_path, None).unwrap();
|
||
assert_eq!(entry.title, "Active Projects");
|
||
assert_eq!(entry.filename, "active-projects.yml");
|
||
}
|
||
|
||
#[test]
|
||
fn test_yml_file_without_name_falls_back_to_filename() {
|
||
let dir = TempDir::new().unwrap();
|
||
let yml_content = "key: value\n";
|
||
let yml_path = dir.path().join("config.yml");
|
||
std::fs::write(&yml_path, yml_content).unwrap();
|
||
let entry = super::parse_non_md_file(&yml_path, None).unwrap();
|
||
assert_eq!(entry.title, "config.yml");
|
||
}
|
||
|
||
#[test]
|
||
fn test_non_yml_file_uses_filename_as_title() {
|
||
let dir = TempDir::new().unwrap();
|
||
let txt_path = dir.path().join("notes.txt");
|
||
std::fs::write(&txt_path, "some content").unwrap();
|
||
let entry = super::parse_non_md_file(&txt_path, None).unwrap();
|
||
assert_eq!(entry.title, "notes.txt");
|
||
}
|
||
|
||
// 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
|