feat: publish HoloLake model-native living system source

This commit is contained in:
冰朔 2026-08-03 10:04:41 +08:00
commit c395dd3a99
2467 changed files with 615073 additions and 0 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,870 @@
use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
use super::getting_started::{agents_content_is_known_managed_template, AGENTS_MD};
/// Content for `type.md` — describes the generic Type metamodel for the vault.
const TYPE_TYPE_DEFINITION: &str = "\
---
type: Type
order: 0
visible: false
---
# Type
A Type defines shared metadata and defaults for a category of notes in this vault.
## Common properties
- **Icon**: Sidebar icon for this type
- **Color**: Accent color for notes of this type
- **Order**: Sidebar ordering
- **Sidebar label**: Override the default plural label
- **Template**: Default body for new notes of this type
- **View**: Preferred note-list view for this type
";
/// Content for `note.md` — restores the default Note type definition when missing.
const NOTE_TYPE_DEFINITION: &str = "\
---
type: Type
---
# Note
A Note is a general-purpose document — research notes, meeting notes, strategy docs, or anything that doesn't fit a more specific type.
";
const LEGACY_CLAUDE_MD_SHIM: &str = "@AGENTS.md
This file is a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
";
const HEADING_CLAUDE_MD_SHIM: &str = "@AGENTS.md
# CLAUDE.md
This file is a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
";
const CLAUDE_MD_SHIM: &str = "---
type: Note
_organized: true
---
@AGENTS.md
This file is only a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
";
const GEMINI_MD_SHIM: &str = "---
type: Note
_organized: true
---
@AGENTS.md
This file is only an Antigravity/Gemini compatibility shim. Keep shared agent instructions in `AGENTS.md`.
";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AiGuidanceFileState {
Managed,
Missing,
Broken,
Custom,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct WorkspaceAiGuidanceStatus {
pub agents_state: AiGuidanceFileState,
pub claude_state: AiGuidanceFileState,
pub gemini_state: AiGuidanceFileState,
pub can_restore: bool,
}
struct GuidancePaths {
agents: PathBuf,
claude: PathBuf,
gemini: PathBuf,
}
#[derive(Debug, Default)]
struct LegacyAgentsMigrationOutcome {
copied_to_root: bool,
removed_legacy: bool,
}
/// Write a file if it doesn't exist or is empty (corrupt). Returns true if written.
fn write_if_missing(path: &Path, content: &str) -> Result<bool, String> {
let needs_write = !path.exists() || fs::metadata(path).map_or(true, |m| m.len() == 0);
if needs_write {
fs::write(path, content).map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
}
Ok(needs_write)
}
fn read_file_or_empty(path: &Path) -> String {
fs::read_to_string(path).unwrap_or_default()
}
fn agents_content_can_be_replaced(content: &str) -> bool {
content.trim().is_empty()
|| content.contains("See config/agents.md")
|| agents_content_is_known_managed_template(content)
}
fn root_agents_can_be_replaced(path: &Path) -> bool {
!path.exists() || agents_content_can_be_replaced(&read_file_or_empty(path))
}
fn matches_claude_shim(content: &str) -> bool {
let trimmed = content.trim();
trimmed == "@AGENTS.md"
|| trimmed == LEGACY_CLAUDE_MD_SHIM.trim()
|| trimmed == HEADING_CLAUDE_MD_SHIM.trim()
|| trimmed == CLAUDE_MD_SHIM.trim()
}
fn matches_gemini_shim(content: &str) -> bool {
content.trim() == GEMINI_MD_SHIM.trim()
}
fn claude_shim_can_be_replaced(path: &Path) -> bool {
!path.exists() || {
let content = read_file_or_empty(path);
content.trim().is_empty() || matches_claude_shim(&content)
}
}
fn gemini_shim_can_be_replaced(path: &Path) -> bool {
!path.exists() || {
let content = read_file_or_empty(path);
content.trim().is_empty() || matches_gemini_shim(&content)
}
}
fn sync_managed_file(
path: &Path,
content: &str,
can_replace: fn(&Path) -> bool,
) -> Result<bool, String> {
if !can_replace(path) {
return Ok(false);
}
fs::write(path, content).map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
Ok(true)
}
fn strip_markdown_frontmatter(content: &str) -> &str {
let Some(line_ending) = resolve_frontmatter_line_ending(content) else {
return content;
};
let after_open = &content[3 + line_ending.len()..];
let close_marker = format!("{line_ending}---");
let Some(close_index) = after_open.find(&close_marker) else {
return content;
};
let after_close = &after_open[close_index + close_marker.len()..];
after_close.strip_prefix(line_ending).unwrap_or(after_close)
}
fn resolve_frontmatter_line_ending(content: &str) -> Option<&'static str> {
if content.starts_with("---\r\n") {
return Some("\r\n");
}
content.starts_with("---\n").then_some("\n")
}
fn guidance_content_has_body(content: &str) -> bool {
!strip_markdown_frontmatter(content).trim().is_empty()
}
fn classify_guidance_file(
path: &Path,
matches_managed: fn(&str) -> bool,
can_replace: fn(&Path) -> bool,
) -> AiGuidanceFileState {
if !path.exists() {
return AiGuidanceFileState::Missing;
}
let content = read_file_or_empty(path);
if matches_managed(&content) {
return AiGuidanceFileState::Managed;
}
if !guidance_content_has_body(&content) {
return AiGuidanceFileState::Broken;
}
if can_replace(path) {
return AiGuidanceFileState::Broken;
}
AiGuidanceFileState::Custom
}
fn guidance_paths(vault_path: &Path) -> GuidancePaths {
GuidancePaths {
agents: vault_path.join("AGENTS.md"),
claude: vault_path.join("CLAUDE.md"),
gemini: vault_path.join("GEMINI.md"),
}
}
fn classify_agents_file(path: &Path) -> AiGuidanceFileState {
classify_guidance_file(
path,
|content| content == AGENTS_MD,
root_agents_can_be_replaced,
)
}
fn classify_claude_file(path: &Path) -> AiGuidanceFileState {
classify_guidance_file(path, matches_claude_shim, claude_shim_can_be_replaced)
}
fn classify_gemini_file(path: &Path) -> AiGuidanceFileState {
classify_guidance_file(path, matches_gemini_shim, gemini_shim_can_be_replaced)
}
fn guidance_file_needs_restore(state: AiGuidanceFileState) -> bool {
matches!(
state,
AiGuidanceFileState::Missing | AiGuidanceFileState::Broken
)
}
fn build_ai_guidance_status(vault_path: &Path) -> WorkspaceAiGuidanceStatus {
let paths = guidance_paths(vault_path);
let agents_state = classify_agents_file(&paths.agents);
let claude_state = classify_claude_file(&paths.claude);
let gemini_state = classify_gemini_file(&paths.gemini);
WorkspaceAiGuidanceStatus {
agents_state,
claude_state,
gemini_state,
can_restore: guidance_file_needs_restore(agents_state)
|| guidance_file_needs_restore(claude_state)
|| guidance_file_needs_restore(gemini_state),
}
}
fn sync_claude_shim_file(vault_path: &Path) -> Result<bool, String> {
let paths = guidance_paths(vault_path);
sync_managed_file(&paths.claude, CLAUDE_MD_SHIM, claude_shim_can_be_replaced)
}
fn sync_gemini_shim_file(vault_path: &Path) -> Result<bool, String> {
let paths = guidance_paths(vault_path);
sync_managed_file(&paths.gemini, GEMINI_MD_SHIM, gemini_shim_can_be_replaced)
}
fn sync_required_ai_guidance_files(vault_path: &Path) -> Result<bool, String> {
let wrote_agents = sync_default_agents_file(vault_path)?;
let wrote_claude = sync_claude_shim_file(vault_path)?;
Ok(wrote_agents || wrote_claude)
}
fn sync_all_ai_guidance_files(vault_path: &Path) -> Result<bool, String> {
let wrote_required = sync_required_ai_guidance_files(vault_path)?;
let wrote_gemini = sync_gemini_shim_file(vault_path)?;
Ok(wrote_required || wrote_gemini)
}
fn migrate_legacy_agents_file(
root_agents: &Path,
config_agents: &Path,
) -> Result<LegacyAgentsMigrationOutcome, String> {
let mut outcome = LegacyAgentsMigrationOutcome::default();
if !config_agents.exists() {
return Ok(outcome);
}
let config_content = read_file_or_empty(config_agents);
if !config_content.is_empty() && root_agents_can_be_replaced(root_agents) {
fs::write(root_agents, &config_content)
.map_err(|e| format!("Failed to write AGENTS.md: {e}"))?;
outcome.copied_to_root = true;
}
fs::remove_file(config_agents)
.map_err(|e| format!("Failed to remove config/agents.md: {e}"))?;
outcome.removed_legacy = true;
Ok(outcome)
}
fn cleanup_empty_config_dir(vault: &Path) -> Result<bool, String> {
let config_dir = vault.join("config");
if !config_dir.is_dir() {
return Ok(false);
}
let is_empty = fs::read_dir(&config_dir)
.map_err(|e| format!("Failed to inspect {}: {e}", config_dir.display()))?
.next()
.is_none();
if !is_empty {
return Ok(false);
}
fs::remove_dir(&config_dir)
.map_err(|e| format!("Failed to remove {}: {e}", config_dir.display()))?;
Ok(true)
}
pub(super) fn sync_default_agents_file(vault_path: &Path) -> Result<bool, String> {
let paths = guidance_paths(vault_path);
sync_managed_file(&paths.agents, AGENTS_MD, root_agents_can_be_replaced)
}
pub fn get_ai_guidance_status(
vault_path: impl AsRef<str>,
) -> Result<WorkspaceAiGuidanceStatus, String> {
Ok(build_ai_guidance_status(Path::new(vault_path.as_ref())))
}
pub fn restore_ai_guidance_files(
vault_path: impl AsRef<str>,
) -> Result<WorkspaceAiGuidanceStatus, String> {
let vault_path = Path::new(vault_path.as_ref());
sync_all_ai_guidance_files(vault_path)?;
Ok(build_ai_guidance_status(vault_path))
}
/// Seed `AGENTS.md` at vault root if missing or empty (idempotent, per-file).
/// Also seeds Tolaria-managed root type definitions used by repair/bootstrap flows.
pub fn seed_config_files(vault_path: impl AsRef<str>) {
let vault_path = Path::new(vault_path.as_ref());
if sync_required_ai_guidance_files(vault_path).unwrap_or(false) {
log::info!("Seeded vault AI guidance files at vault root");
}
ensure_root_type_definitions(vault_path);
}
fn ensure_root_type_definition(vault_path: &Path, file_name: &str, content: &str) {
let path = vault_path.join(file_name);
let _ = write_if_missing(&path, content);
}
/// Ensure the default root type definitions exist for opened/repaired vaults.
fn ensure_root_type_definitions(vault_path: &Path) {
ensure_root_type_definition(vault_path, "type.md", TYPE_TYPE_DEFINITION);
ensure_root_type_definition(vault_path, "note.md", NOTE_TYPE_DEFINITION);
}
/// Migrate legacy `config/agents.md` → root `AGENTS.md` for existing vaults.
///
/// - If `config/agents.md` has real content and root `AGENTS.md` is missing/empty/stub:
/// move content to root, remove legacy file.
/// - If root `AGENTS.md` doesn't exist: write defaults.
/// - Cleans up empty `config/` directory after migration.
///
/// Always idempotent and silent.
pub fn migrate_agents_md(vault_path: impl AsRef<str>) {
let vault = Path::new(vault_path.as_ref());
let root_agents = vault.join("AGENTS.md");
let config_agents = vault.join("config").join("agents.md");
if let Ok(outcome) = migrate_legacy_agents_file(&root_agents, &config_agents) {
if outcome.copied_to_root {
log::info!("Migrated config/agents.md content to root AGENTS.md");
}
if outcome.removed_legacy {
log::info!("Removed legacy config/agents.md");
}
}
if cleanup_empty_config_dir(vault).unwrap_or(false) {
log::info!("Removed empty config/ directory");
}
let _ = sync_required_ai_guidance_files(vault);
}
/// Repair config files: ensure `AGENTS.md` at vault root and root type definitions.
/// Migrates legacy `config/agents.md` to root if present.
/// Called by the "Repair Vault" command. Returns a status message.
pub fn repair_config_files(vault_path: impl AsRef<str>) -> Result<String, String> {
let vault = Path::new(vault_path.as_ref());
let root_agents = vault.join("AGENTS.md");
let config_agents = vault.join("config").join("agents.md");
migrate_legacy_agents_file(&root_agents, &config_agents)?;
let _ = cleanup_empty_config_dir(vault)?;
sync_required_ai_guidance_files(vault)?;
write_if_missing(&vault.join("type.md"), TYPE_TYPE_DEFINITION)?;
write_if_missing(&vault.join("note.md"), NOTE_TYPE_DEFINITION)?;
Ok("Config files repaired".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_vault() -> (TempDir, PathBuf) {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
(dir, vault)
}
fn config_dir(vault: &Path) -> PathBuf {
let dir = vault.join("config");
fs::create_dir_all(&dir).unwrap();
dir
}
fn write_root_agents(vault: &Path, content: &str) {
fs::write(vault.join("AGENTS.md"), content).unwrap();
}
fn write_root_claude(vault: &Path, content: &str) {
fs::write(vault.join("CLAUDE.md"), content).unwrap();
}
fn write_root_gemini(vault: &Path, content: &str) {
fs::write(vault.join("GEMINI.md"), content).unwrap();
}
fn write_legacy_agents(vault: &Path, content: &str) {
fs::write(config_dir(vault).join("agents.md"), content).unwrap();
}
fn read_root_agents(vault: &Path) -> String {
fs::read_to_string(vault.join("AGENTS.md")).unwrap()
}
fn read_root_claude(vault: &Path) -> String {
fs::read_to_string(vault.join("CLAUDE.md")).unwrap()
}
fn read_root_gemini(vault: &Path) -> String {
fs::read_to_string(vault.join("GEMINI.md")).unwrap()
}
type VaultOperation = fn(&Path);
fn run_seed(vault: &Path) {
seed_config_files(vault.to_str().unwrap());
}
fn run_migrate(vault: &Path) {
migrate_agents_md(vault.to_str().unwrap());
}
fn run_repair(vault: &Path) {
repair_config_files(vault.to_str().unwrap()).unwrap();
}
fn run_with_agents(
run: VaultOperation,
root_agents: Option<&str>,
legacy_agents: Option<&str>,
) -> (TempDir, PathBuf) {
let (dir, vault) = create_vault();
if let Some(content) = root_agents {
write_root_agents(&vault, content);
}
if let Some(content) = legacy_agents {
write_legacy_agents(&vault, content);
}
run(&vault);
(dir, vault)
}
fn assert_preserves_custom_agents(run: VaultOperation) {
let (_dir, vault) = run_with_agents(
run,
Some("# Custom Agent Config\nMy custom instructions\n"),
None,
);
assert!(
read_root_agents(&vault).contains("Custom Agent Config"),
"must preserve existing content"
);
}
fn assert_preserves_edited_default_agents(run: VaultOperation) {
let edited_agents = AGENTS_MD.replacen(
"Store note type in the `type:` frontmatter field.",
"`type:` is the preferred type field. Tolaria still understands legacy aliases such as `Is A`.",
1,
);
let (_dir, vault) = run_with_agents(run, Some(&edited_agents), None);
let content = read_root_agents(&vault);
assert!(content.contains("Tolaria still understands legacy aliases such as `Is A`."));
assert!(!content.contains("Store note type in the `type:` frontmatter field."));
}
fn assert_legacy_agents_move_to_root(
run: VaultOperation,
legacy_agents: &str,
expected_root_text: &str,
expect_config_dir_removed: bool,
) {
let (_dir, vault) = run_with_agents(run, None, Some(legacy_agents));
let config_dir = vault.join("config");
let root_content = read_root_agents(&vault);
assert!(root_content.contains(expected_root_text));
assert!(!config_dir.join("agents.md").exists());
assert_eq!(config_dir.exists(), !expect_config_dir_removed);
}
fn assert_stub_agents_are_replaced(
run: VaultOperation,
legacy_agents: &str,
expected_root_text: &str,
) {
let (_dir, vault) = run_with_agents(
run,
Some("# Agent Instructions\nSee config/agents.md for vault instructions.\n"),
Some(legacy_agents),
);
let content = read_root_agents(&vault);
assert!(content.contains(expected_root_text));
}
fn assert_required_agents_file_seeded(vault: &Path) {
assert!(vault.join("AGENTS.md").exists());
assert!(read_root_agents(vault).contains("HoloLake Era Vault"));
}
fn assert_required_guidance_shims_seeded(vault: &Path) {
assert_eq!(read_root_claude(vault), CLAUDE_MD_SHIM);
assert!(!vault.join("GEMINI.md").exists());
}
fn assert_required_guidance_files_seeded(vault: &Path) {
assert_required_agents_file_seeded(vault);
assert_required_guidance_shims_seeded(vault);
}
fn assert_root_type_definitions_seeded(vault: &Path) {
assert!(vault.join("type.md").exists());
assert!(vault.join("note.md").exists());
assert!(!vault.join("config").exists());
}
fn assert_type_definition_content(vault: &Path) {
let type_content = fs::read_to_string(vault.join("type.md")).unwrap();
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
assert_type_definition_body(&type_content);
assert_note_definition_body(&note_content);
}
fn assert_type_definition_body(type_content: &str) {
assert!(type_content.contains("type: Type"));
assert!(type_content.contains("# Type"));
assert!(type_content.contains("visible: false"));
}
fn assert_note_definition_body(note_content: &str) {
assert!(note_content.contains("type: Type"));
assert!(note_content.contains("# Note"));
}
#[test]
fn test_seed_config_files_creates_guidance_files_at_root() {
let (_dir, vault) = create_vault();
seed_config_files(vault.to_str().unwrap());
assert_required_guidance_files_seeded(&vault);
}
#[test]
fn test_seed_config_files_creates_type_definitions() {
let (_dir, vault) = create_vault();
seed_config_files(vault.to_str().unwrap());
assert_root_type_definitions_seeded(&vault);
assert_type_definition_content(&vault);
}
#[test]
fn test_seed_config_files_preserves_custom_agents() {
assert_preserves_custom_agents(run_seed);
}
#[test]
fn test_seed_config_files_reseeds_empty() {
let (_dir, vault) = create_vault();
write_root_agents(&vault, "");
seed_config_files(vault.to_str().unwrap());
assert!(read_root_agents(&vault).contains("HoloLake Era Vault"));
}
#[test]
fn test_seed_config_files_preserves_edited_stale_default_agents() {
let (_dir, vault) = create_vault();
write_root_agents(
&vault,
"# AGENTS.md — Tolaria Vault\n\n- The first H1 in the body is the note title. Do not add `title:` frontmatter.\n",
);
seed_config_files(vault.to_str().unwrap());
let content = read_root_agents(&vault);
assert!(content.contains("Do not add `title:` frontmatter."));
assert!(!content.contains("Use the first H1 as the note title."));
}
#[test]
fn test_seed_config_files_preserves_edited_default_agents() {
assert_preserves_edited_default_agents(run_seed);
}
#[test]
fn test_seed_config_files_preserves_custom_claude() {
let (_dir, vault) = create_vault();
write_root_claude(&vault, "# Custom Claude instructions\nDo not overwrite\n");
seed_config_files(vault.to_str().unwrap());
assert!(read_root_claude(&vault).contains("Custom Claude instructions"));
}
#[test]
fn test_seed_config_files_refreshes_previous_managed_claude_shim() {
let (_dir, vault) = create_vault();
write_root_claude(&vault, HEADING_CLAUDE_MD_SHIM);
seed_config_files(vault.to_str().unwrap());
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
}
#[test]
fn test_migrate_agents_md_moves_config_to_root() {
assert_legacy_agents_move_to_root(
run_migrate,
"# My vault agent instructions\nCustom content\n",
"My vault agent instructions",
true,
);
}
#[test]
fn test_migrate_agents_md_preserves_existing_root() {
let (_dir, vault) = create_vault();
write_root_agents(&vault, "# My root agent config\nDo not overwrite\n");
write_legacy_agents(&vault, "Legacy content");
migrate_agents_md(vault.to_str().unwrap());
let config_dir = vault.join("config");
let content = read_root_agents(&vault);
assert!(content.contains("My root agent config"));
assert!(!config_dir.join("agents.md").exists());
}
#[test]
fn test_migrate_agents_md_replaces_stub_with_config_content() {
assert_stub_agents_are_replaced(
run_migrate,
"# Real Agent Config\nImportant instructions\n",
"Real Agent Config",
);
}
#[test]
fn test_migrate_agents_md_idempotent_when_no_legacy() {
let (_dir, vault) = create_vault();
migrate_agents_md(vault.to_str().unwrap());
assert!(vault.join("AGENTS.md").exists());
let root = read_root_agents(&vault);
assert!(root.contains("HoloLake Era Vault"));
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
}
#[test]
fn test_migrate_agents_md_keeps_nonempty_config_dir() {
let (_dir, vault) = create_vault();
let config_dir = config_dir(&vault);
fs::write(config_dir.join("agents.md"), "Agent content").unwrap();
fs::write(config_dir.join("other.md"), "Other file").unwrap();
migrate_agents_md(vault.to_str().unwrap());
assert!(config_dir.exists());
assert!(config_dir.join("other.md").exists());
assert!(!config_dir.join("agents.md").exists());
}
#[test]
fn test_repair_config_files_creates_all() {
let (_dir, vault) = create_vault();
let msg = repair_config_files(vault.to_str().unwrap()).unwrap();
assert_eq!(msg, "Config files repaired");
assert_required_guidance_files_seeded(&vault);
assert_root_type_definitions_seeded(&vault);
assert_type_definition_content(&vault);
assert!(fs::read_to_string(vault.join("note.md"))
.unwrap()
.contains("general-purpose document"));
}
#[test]
fn test_repair_config_files_preserves_custom_content() {
assert_preserves_custom_agents(run_repair);
}
#[test]
fn test_repair_config_files_migrates_legacy_config() {
assert_legacy_agents_move_to_root(
run_repair,
"# My vault agent instructions\nCustom content\n",
"My vault agent instructions",
true,
);
}
#[test]
fn test_repair_config_files_replaces_stub_with_legacy() {
assert_stub_agents_are_replaced(
run_repair,
"# Real Instructions\nImportant stuff\n",
"Real Instructions",
);
}
#[test]
fn test_repair_config_files_preserves_edited_default_agents() {
assert_preserves_edited_default_agents(run_repair);
}
#[test]
fn test_get_ai_guidance_status_reports_custom_and_repairable_files() {
let (_dir, vault) = create_vault();
write_root_agents(&vault, "# Custom Agent Config\nHands off\n");
write_root_claude(&vault, "");
let status = get_ai_guidance_status(vault.to_str().unwrap()).unwrap();
assert_eq!(
status,
WorkspaceAiGuidanceStatus {
agents_state: AiGuidanceFileState::Custom,
claude_state: AiGuidanceFileState::Broken,
gemini_state: AiGuidanceFileState::Missing,
can_restore: true,
}
);
}
#[test]
fn test_get_ai_guidance_status_treats_edited_agents_as_custom() {
let (_dir, vault) = create_vault();
let edited_agents = AGENTS_MD.replacen("type: Note", "type: Vault Guidance", 1)
+ "\n\n- Prefer the user's vault-specific naming conventions.\n";
write_root_agents(&vault, &edited_agents);
write_root_claude(&vault, CLAUDE_MD_SHIM);
write_root_gemini(&vault, GEMINI_MD_SHIM);
let status = get_ai_guidance_status(vault.to_str().unwrap()).unwrap();
assert_eq!(
status,
WorkspaceAiGuidanceStatus {
agents_state: AiGuidanceFileState::Custom,
claude_state: AiGuidanceFileState::Managed,
gemini_state: AiGuidanceFileState::Managed,
can_restore: false,
}
);
}
#[test]
fn test_get_ai_guidance_status_reports_frontmatter_only_agents_as_broken() {
let (_dir, vault) = create_vault();
write_root_agents(&vault, "---\ntype: Vault Guidance\n---\n");
write_root_claude(&vault, CLAUDE_MD_SHIM);
write_root_gemini(&vault, GEMINI_MD_SHIM);
let status = get_ai_guidance_status(vault.to_str().unwrap()).unwrap();
assert_eq!(
status,
WorkspaceAiGuidanceStatus {
agents_state: AiGuidanceFileState::Broken,
claude_state: AiGuidanceFileState::Managed,
gemini_state: AiGuidanceFileState::Managed,
can_restore: true,
}
);
}
#[test]
fn test_restore_ai_guidance_files_repairs_without_overwriting_custom_agents() {
let (_dir, vault) = create_vault();
write_root_agents(&vault, "# Custom Agent Config\nHands off\n");
write_root_claude(&vault, "");
let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap();
assert_eq!(
status,
WorkspaceAiGuidanceStatus {
agents_state: AiGuidanceFileState::Custom,
claude_state: AiGuidanceFileState::Managed,
gemini_state: AiGuidanceFileState::Managed,
can_restore: false,
}
);
assert!(read_root_agents(&vault).contains("Custom Agent Config"));
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
assert_eq!(read_root_gemini(&vault), GEMINI_MD_SHIM);
}
#[test]
fn test_restore_ai_guidance_files_repairs_broken_agents_and_missing_claude() {
let (_dir, vault) = create_vault();
write_root_agents(&vault, "");
let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap();
assert_eq!(
status,
WorkspaceAiGuidanceStatus {
agents_state: AiGuidanceFileState::Managed,
claude_state: AiGuidanceFileState::Managed,
gemini_state: AiGuidanceFileState::Managed,
can_restore: false,
}
);
}
#[test]
fn test_restore_ai_guidance_files_preserves_custom_gemini() {
let (_dir, vault) = create_vault();
write_root_agents(&vault, AGENTS_MD);
write_root_claude(&vault, CLAUDE_MD_SHIM);
write_root_gemini(&vault, "# Custom Gemini instructions\nDo not overwrite\n");
let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap();
assert_eq!(status.gemini_state, AiGuidanceFileState::Custom);
assert!(!status.can_restore);
assert!(read_root_gemini(&vault).contains("Custom Gemini instructions"));
}
}

View file

@ -0,0 +1,98 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// A node in the vault's folder tree. Only contains directories, not files.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FolderNode {
/// Folder name (last path component).
pub name: String,
/// Path relative to the vault root, using `/` separators (e.g. "projects/laputa").
pub path: String,
/// Child folders (sorted alphabetically).
pub children: Vec<FolderNode>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct VaultEntry {
pub path: String,
pub filename: String,
pub title: String,
#[serde(rename = "isA")]
pub is_a: Option<String>,
pub aliases: Vec<String>,
#[serde(rename = "belongsTo")]
pub belongs_to: Vec<String>,
#[serde(rename = "relatedTo")]
pub related_to: Vec<String>,
pub status: Option<String>,
pub archived: bool,
#[serde(rename = "modifiedAt")]
pub modified_at: Option<u64>,
#[serde(rename = "createdAt")]
pub created_at: Option<u64>,
#[serde(rename = "fileSize")]
pub file_size: u64,
pub snippet: String,
/// Generic relationship fields: any frontmatter key whose value contains wikilinks.
/// Key is the original frontmatter field name (e.g. "Has", "Topics", "Events").
pub relationships: HashMap<String, Vec<String>>,
/// Phosphor icon name (kebab-case) for Type entries, e.g. "cooking-pot".
pub icon: Option<String>,
/// Accent color key for Type entries: "red", "purple", "blue", "green", "yellow", "orange".
pub color: Option<String>,
/// Display order for Type entries in sidebar (lower = higher). None = use default order.
pub order: Option<i64>,
/// Custom sidebar section label for Type entries, overriding auto-pluralization.
#[serde(rename = "sidebarLabel")]
pub sidebar_label: Option<String>,
/// Markdown template for notes of this Type. When a new note is created
/// with this type, the template body is pre-filled after the frontmatter.
pub template: Option<String>,
/// Default sort preference for the note list when viewing instances of this Type.
/// Stored as "option:direction" (e.g. "modified:desc", "title:asc", "property:Priority:asc").
pub sort: Option<String>,
/// Default view mode for the note list when viewing instances of this Type.
/// Stored as a string: "all", "editor-list", or "editor-only".
pub view: Option<String>,
/// Rich-editor width mode for this note. None means use the app default.
#[serde(rename = "noteWidth")]
pub note_width: Option<String>,
/// Editor display mode for this note. None means the default text editor.
pub display: Option<String>,
/// Whether this Type is visible in the sidebar. Defaults to true when absent.
pub visible: Option<bool>,
/// Whether this note has been explicitly organized (removed from Inbox).
pub organized: bool,
/// Whether this note is a user favorite (shown in FAVORITES sidebar section).
pub favorite: bool,
/// Display order within the FAVORITES section (lower = higher).
#[serde(rename = "favoriteIndex")]
pub favorite_index: Option<i64>,
/// Word count of the note body (excludes frontmatter and H1 title).
#[serde(rename = "wordCount")]
pub word_count: u32,
/// All wikilink targets found in the note body (excludes frontmatter).
/// Extracted from `[[target]]` and `[[target|display]]` patterns.
#[serde(rename = "outgoingLinks", default)]
pub outgoing_links: Vec<String>,
/// Custom scalar and scalar-array frontmatter properties (non-relationship, non-structural).
/// Objects and arrays containing wikilinks are excluded.
#[serde(default)]
pub properties: HashMap<String, serde_json::Value>,
/// Properties to display as chips in the note list for this Type's notes.
/// Configured via `_list_properties_display` in the type file's frontmatter.
#[serde(rename = "listPropertiesDisplay", default)]
pub list_properties_display: Vec<String>,
/// Whether the note body has an H1 heading on the first non-empty line.
/// Used by the frontend to decide whether to show the TitleField.
#[serde(rename = "hasH1")]
pub has_h1: bool,
/// File kind: "markdown", "text", or "binary".
/// Determines how the frontend renders and opens the file.
#[serde(rename = "fileKind", default = "default_file_kind")]
pub file_kind: String,
}
fn default_file_kind() -> String {
"markdown".to_string()
}

View file

@ -0,0 +1,256 @@
use std::borrow::Cow;
use std::fs;
use std::io::{Error, ErrorKind, Write};
use std::path::Path;
use std::thread;
use std::time::{Duration, UNIX_EPOCH};
const SAVE_RETRY_DELAYS_MS: [u64; 4] = [25, 50, 100, 200];
/// Read file metadata (modified_at timestamp, created_at timestamp, file size).
/// Creation time is sourced from filesystem metadata (birthtime on macOS).
pub(crate) fn read_file_metadata(path: &Path) -> Result<(Option<u64>, Option<u64>, u64), String> {
let metadata =
fs::metadata(path).map_err(|e| format!("Failed to stat {}: {}", path.display(), e))?;
let modified_at = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs());
let created_at = metadata
.created()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs());
Ok((modified_at, created_at, metadata.len()))
}
fn invalid_utf8_text_error(path: &Path) -> String {
format!("File is not valid UTF-8 text: {}", path.display())
}
fn is_invalid_platform_path_error(error: &Error) -> bool {
error.kind() == ErrorKind::InvalidInput || error.raw_os_error() == Some(123)
}
fn is_retryable_save_error(error: &Error) -> bool {
error.kind() == ErrorKind::PermissionDenied
|| (cfg!(windows) && error.raw_os_error() == Some(5))
}
fn write_with_retry(
mut write_once: impl FnMut() -> Result<(), Error>,
mut wait_before_retry: impl FnMut(u64),
) -> Result<(), Error> {
for delay in SAVE_RETRY_DELAYS_MS {
match write_once() {
Ok(()) => return Ok(()),
Err(error) if is_retryable_save_error(&error) => wait_before_retry(delay),
Err(error) => return Err(error),
}
}
write_once()
}
fn read_existing_note_bytes(path: &Path) -> Result<Vec<u8>, String> {
if !path.exists() {
return Err(format!("File does not exist: {}", path.display()));
}
if !path.is_file() {
return Err(format!("Path is not a file: {}", path.display()));
}
fs::read(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))
}
struct RawNotePath<'a>(&'a str);
impl<'a> RawNotePath<'a> {
fn is_windows_verbatim(&self) -> bool {
self.0.starts_with(r"\\?\") || self.0.starts_with(r"\??\")
}
fn normalized_for_file_io(&self) -> Cow<'a, str> {
if !self.is_windows_verbatim() {
return Cow::Borrowed(self.0);
}
if !self.0.contains('/') {
return Cow::Borrowed(self.0);
}
Cow::Owned(self.0.replace('/', r"\"))
}
}
#[derive(Clone, Copy)]
enum NoteIoOperation {
Save,
Create,
}
#[derive(Clone, Copy)]
struct NotePathDisplay<'a> {
value: &'a str,
}
impl<'a> NotePathDisplay<'a> {
fn new(value: &'a str) -> Self {
Self { value }
}
}
impl NoteIoOperation {
fn verb(self) -> &'static str {
match self {
Self::Save => "save",
Self::Create => "create",
}
}
}
fn note_io_error(operation: NoteIoOperation, path: NotePathDisplay<'_>, error: &Error) -> String {
let verb = operation.verb();
if is_invalid_platform_path_error(error) {
let path = path.value;
format!(
"Failed to {verb} note: the path is invalid on this platform. Rename the note or move it to a valid folder, then try again. Path: {path}"
)
} else {
let path = path.value;
format!("Failed to {verb} {path}: {error}")
}
}
/// Read the content of a single note file.
pub fn get_note_content(path: &Path) -> Result<String, String> {
let bytes = read_existing_note_bytes(path)?;
String::from_utf8(bytes).map_err(|_| invalid_utf8_text_error(path))
}
/// Check whether a note still has the exact content the renderer cached.
pub fn note_content_matches(path: &Path, expected_content: &str) -> Result<bool, String> {
let bytes = read_existing_note_bytes(path)?;
Ok(bytes == expected_content.as_bytes())
}
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 normalized_path = RawNotePath(path).normalized_for_file_io();
let file_path = Path::new(normalized_path.as_ref());
if let Some(parent) = file_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent).map_err(|e| {
note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e)
})?;
}
}
validate_save_path(file_path, path)?;
write_with_retry(
|| fs::write(file_path, content),
|delay| thread::sleep(Duration::from_millis(delay)),
)
.map_err(|e| note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e))
}
/// Create a new note file without overwriting any existing file.
pub fn create_note_content(path: &str, content: &str) -> Result<(), String> {
let normalized_path = RawNotePath(path).normalized_for_file_io();
let file_path = Path::new(normalized_path.as_ref());
if let Some(parent) = file_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent).map_err(|e| {
note_io_error(NoteIoOperation::Create, NotePathDisplay::new(path), &e)
})?;
}
}
validate_save_path(file_path, path)?;
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(file_path)
.map_err(|e| match e.kind() {
ErrorKind::AlreadyExists => format!("File already exists: {}", path),
_ => note_io_error(NoteIoOperation::Create, NotePathDisplay::new(path), &e),
})?;
file.write_all(content.as_bytes())
.map_err(|e| note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_windows_invalid_path_syntax_as_recoverable_save_error() {
let path = r"C:\Users\@raflymln\notes\untitled-note-1777236475.md";
let message = note_io_error(
NoteIoOperation::Save,
NotePathDisplay::new(path),
&Error::from_raw_os_error(123),
);
assert!(message.contains("path is invalid on this platform"));
assert!(message.contains("Rename the note or move it to a valid folder"));
assert!(!message.contains("os error 123"));
}
#[test]
fn normalizes_extended_windows_paths_before_file_io() {
let path = r"\\?\C:\Users\alex\Documents\Tolaria/Getting Started/untitled-project.md";
assert_eq!(
RawNotePath(path).normalized_for_file_io(),
r"\\?\C:\Users\alex\Documents\Tolaria\Getting Started\untitled-project.md"
);
}
#[test]
fn retries_transient_access_denied_save_errors() {
let mut attempts = 0;
let mut delays = Vec::new();
write_with_retry(
|| {
attempts += 1;
if attempts == 1 {
Err(Error::new(ErrorKind::PermissionDenied, "Access is denied"))
} else {
Ok(())
}
},
|delay| delays.push(delay),
)
.unwrap();
assert_eq!(attempts, 2);
assert_eq!(delays, vec![25]);
}
#[test]
fn note_content_matches_detects_external_edits() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("note.md");
fs::write(&path, "# Fresh\n").unwrap();
assert!(note_content_matches(&path, "# Fresh\n").unwrap());
assert!(!note_content_matches(&path, "# Stale\n").unwrap());
}
}

View file

@ -0,0 +1,101 @@
const WINDOWS_RESERVED_DEVICE_NAMES: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
pub(crate) fn validate_filename_stem(stem: &str) -> Result<(), String> {
validate_portable_name_segment(stem, "Invalid filename")
}
pub(crate) fn validate_folder_name(name: &str) -> Result<(), String> {
validate_portable_name_segment(name, "Invalid folder name")
}
pub(crate) fn validate_view_filename_stem(stem: &str) -> Result<(), String> {
validate_portable_name_segment(stem, "Invalid view filename")
}
fn validate_portable_name_segment(value: &str, message: &str) -> Result<(), String> {
if is_invalid_portable_name_segment(value) {
return Err(message.to_string());
}
Ok(())
}
fn is_invalid_portable_name_segment(value: &str) -> bool {
let trimmed = value.trim();
if trimmed.is_empty() || matches!(trimmed, "." | "..") {
return true;
}
if trimmed.ends_with('.') || trimmed.ends_with(' ') {
return true;
}
if trimmed.chars().any(is_invalid_portable_name_char) {
return true;
}
is_windows_reserved_device_name(trimmed)
}
fn is_invalid_portable_name_char(ch: char) -> bool {
ch.is_control() || matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*')
}
fn is_windows_reserved_device_name(value: &str) -> bool {
let candidate = value
.split('.')
.next()
.unwrap_or(value)
.to_ascii_uppercase();
WINDOWS_RESERVED_DEVICE_NAMES
.iter()
.any(|reserved| candidate == *reserved)
}
#[cfg(test)]
mod tests {
use super::{validate_filename_stem, validate_folder_name, validate_view_filename_stem};
#[test]
fn accepts_portable_names() {
assert_eq!(validate_filename_stem("meeting-notes"), Ok(()));
assert_eq!(validate_filename_stem("draft.v2"), Ok(()));
assert_eq!(validate_folder_name("Projects"), Ok(()));
assert_eq!(validate_view_filename_stem("active-projects"), Ok(()));
}
#[test]
fn rejects_reserved_windows_device_names() {
assert_eq!(
validate_filename_stem("con"),
Err("Invalid filename".to_string())
);
assert_eq!(
validate_folder_name("Lpt1"),
Err("Invalid folder name".to_string())
);
assert_eq!(
validate_view_filename_stem("aux"),
Err("Invalid view filename".to_string())
);
}
#[test]
fn rejects_windows_invalid_characters_and_suffixes() {
assert_eq!(
validate_filename_stem("quarterly:plan"),
Err("Invalid filename".to_string())
);
assert_eq!(
validate_folder_name("Research?"),
Err("Invalid folder name".to_string())
);
assert_eq!(
validate_view_filename_stem("overview. "),
Err("Invalid view filename".to_string())
);
}
}

View file

@ -0,0 +1,193 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Component, Path, PathBuf};
use super::filename_rules::validate_folder_name;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FolderRenameResult {
pub old_path: String,
pub new_path: String,
}
fn normalize_folder_name(next_name: &str) -> Result<String, String> {
let trimmed = next_name.trim();
if trimmed.is_empty() {
return Err("Folder name cannot be empty".to_string());
}
validate_folder_name(trimmed)?;
Ok(trimmed.to_string())
}
fn ensure_relative_folder_path(folder_path: &str) -> Result<PathBuf, String> {
let trimmed = folder_path.trim();
if trimmed.is_empty() {
return Err("Folder path cannot be empty".to_string());
}
let relative = Path::new(trimmed);
if relative.is_absolute() {
return Err("Folder path must be relative to the vault root".to_string());
}
if relative
.components()
.any(|component| matches!(component, Component::ParentDir))
{
return Err("Folder path cannot escape the vault root".to_string());
}
Ok(relative.to_path_buf())
}
fn display_relative_path(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
pub fn rename_folder(
vault_path: &Path,
folder_path: &str,
next_name: &str,
) -> Result<FolderRenameResult, String> {
let relative_path = ensure_relative_folder_path(folder_path)?;
let normalized_name = normalize_folder_name(next_name)?;
let source_path = vault_path.join(&relative_path);
if !source_path.exists() {
return Err(format!("Folder does not exist: {}", folder_path));
}
if !source_path.is_dir() {
return Err(format!("Not a folder: {}", folder_path));
}
let current_name = source_path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.ok_or_else(|| "Folder path cannot target the vault root".to_string())?;
if current_name == normalized_name {
return Ok(FolderRenameResult {
old_path: display_relative_path(&relative_path),
new_path: display_relative_path(&relative_path),
});
}
let parent_relative = relative_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
let destination_relative = parent_relative.join(&normalized_name);
let destination_path = vault_path.join(&destination_relative);
if destination_path.exists() {
return Err(format!(
"Folder '{}' already exists",
display_relative_path(&destination_relative)
));
}
fs::rename(&source_path, &destination_path)
.map_err(|error| format!("Failed to rename folder: {}", error))?;
Ok(FolderRenameResult {
old_path: display_relative_path(&relative_path),
new_path: display_relative_path(&destination_relative),
})
}
pub fn delete_folder(vault_path: &Path, folder_path: &str) -> Result<String, String> {
let relative_path = ensure_relative_folder_path(folder_path)?;
let target_path = vault_path.join(&relative_path);
if !target_path.exists() {
return Err(format!("Folder does not exist: {}", folder_path));
}
if !target_path.is_dir() {
return Err(format!("Not a folder: {}", folder_path));
}
fs::remove_dir_all(&target_path)
.map_err(|error| format!("Failed to delete folder: {}", error))?;
Ok(display_relative_path(&relative_path))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_folder(dir: &TempDir, relative: &str) -> PathBuf {
let path = dir.path().join(relative);
fs::create_dir_all(&path).unwrap();
path
}
#[test]
fn rename_folder_updates_relative_destination() {
let dir = TempDir::new().unwrap();
make_folder(&dir, "projects/laputa");
let result = rename_folder(dir.path(), "projects", "work").unwrap();
assert_eq!(
result,
FolderRenameResult {
old_path: "projects".to_string(),
new_path: "work".to_string(),
}
);
assert!(dir.path().join("work/laputa").is_dir());
assert!(!dir.path().join("projects").exists());
}
#[test]
fn rename_folder_rejects_duplicate_sibling() {
let dir = TempDir::new().unwrap();
make_folder(&dir, "projects");
make_folder(&dir, "areas");
let error = rename_folder(dir.path(), "projects", "areas").unwrap_err();
assert_eq!(error, "Folder 'areas' already exists");
}
#[test]
fn rename_folder_rejects_invalid_names() {
let dir = TempDir::new().unwrap();
make_folder(&dir, "projects");
let error = rename_folder(dir.path(), "projects", "../areas").unwrap_err();
assert_eq!(error, "Invalid folder name");
}
#[test]
fn rename_folder_rejects_windows_invalid_names() {
let dir = TempDir::new().unwrap();
make_folder(&dir, "projects");
let error = rename_folder(dir.path(), "projects", "LPT1").unwrap_err();
assert_eq!(error, "Invalid folder name");
}
#[test]
fn delete_folder_removes_nested_contents() {
let dir = TempDir::new().unwrap();
let nested = make_folder(&dir, "projects/laputa");
fs::write(nested.join("note.md"), "# Note\n").unwrap();
let deleted_path = delete_folder(dir.path(), "projects").unwrap();
assert_eq!(deleted_path, "projects");
assert!(!dir.path().join("projects").exists());
}
#[test]
fn delete_folder_rejects_missing_folder() {
let dir = TempDir::new().unwrap();
let error = delete_folder(dir.path(), "projects").unwrap_err();
assert_eq!(error, "Folder does not exist: projects");
}
}

View file

@ -0,0 +1,546 @@
use crate::frontmatter::keys::{canonical_known_frontmatter_key, FrontmatterKey};
use crate::vault::parsing::contains_wikilink;
use serde::Deserialize;
use std::collections::HashMap;
/// Intermediate struct to capture YAML frontmatter fields.
#[derive(Debug, Deserialize, Default)]
pub(crate) struct Frontmatter {
#[serde(default)]
pub title: Option<String>,
#[serde(rename = "type", alias = "Is A", alias = "is_a")]
pub is_a: Option<StringOrList>,
#[serde(default)]
pub aliases: Option<StringOrList>,
#[serde(
rename = "_archived",
alias = "Archived",
alias = "archived",
default,
deserialize_with = "deserialize_bool_or_string"
)]
pub archived: Option<bool>,
#[serde(rename = "Status", alias = "status", default)]
pub status: Option<StringOrList>,
#[serde(rename = "_icon", alias = "icon", default)]
pub icon: Option<StringOrList>,
#[serde(default)]
pub color: Option<StringOrList>,
#[serde(rename = "_order", alias = "order", default)]
pub order: Option<i64>,
#[serde(
rename = "_sidebar_label",
alias = "sidebar label",
alias = "sidebar_label",
default
)]
pub sidebar_label: Option<StringOrList>,
#[serde(default)]
pub template: Option<StringOrList>,
#[serde(rename = "_sort", alias = "sort", default)]
pub sort: Option<StringOrList>,
#[serde(default)]
pub view: Option<StringOrList>,
#[serde(rename = "_width", alias = "width", default)]
pub note_width: Option<StringOrList>,
#[serde(rename = "_display", default)]
pub display: Option<StringOrList>,
#[serde(default)]
pub visible: Option<bool>,
#[serde(
rename = "_organized",
default,
deserialize_with = "deserialize_bool_or_string"
)]
pub organized: Option<bool>,
#[serde(
rename = "_favorite",
default,
deserialize_with = "deserialize_bool_or_string"
)]
pub favorite: Option<bool>,
#[serde(rename = "_favorite_index", default)]
pub favorite_index: Option<i64>,
#[serde(rename = "_list_properties_display", default)]
pub list_properties_display: Option<Vec<String>>,
}
/// Custom deserializer for boolean fields that may arrive as strings.
/// YAML `Yes`/`No` get converted to JSON strings by gray_matter, so we
/// need to accept both actual booleans and their string representations.
fn deserialize_bool_or_string<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
struct BoolOrStringVisitor;
impl<'de> de::Visitor<'de> for BoolOrStringVisitor {
type Value = Option<bool>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a boolean or a string representing a boolean")
}
fn visit_bool<E: de::Error>(self, v: bool) -> Result<Self::Value, E> {
Ok(Some(v))
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
match v.to_lowercase().as_str() {
"true" | "yes" | "1" => Ok(Some(true)),
"false" | "no" | "0" | "" => Ok(Some(false)),
_ => Ok(Some(false)),
}
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Self::Value, E> {
Ok(Some(v != 0))
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Self::Value, E> {
Ok(Some(v != 0))
}
fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
}
deserializer.deserialize_any(BoolOrStringVisitor)
}
/// Handles YAML fields that can be either a single string or a list of strings.
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub(crate) enum StringOrList {
Single(String),
List(Vec<String>),
}
impl StringOrList {
pub fn into_vec(self) -> Vec<String> {
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.
pub fn into_scalar(self) -> Option<String> {
match self {
StringOrList::Single(s) => Some(s),
StringOrList::List(mut v) => {
if v.is_empty() {
None
} else {
Some(v.swap_remove(0))
}
}
}
}
}
/// Sanitize a JSON value so that arrays of mixed types (strings + objects + nulls)
/// are flattened to arrays of strings. gray_matter mis-parses certain YAML patterns:
///
/// 1. Unquoted colons in list items: `- Bitcoin: Net Unrealized` becomes
/// `{"Bitcoin": "Net Unrealized"}` instead of a plain string.
/// 2. Hash comments in list items: `- # Heading` becomes `Null` because
/// gray_matter treats `#` as a YAML comment.
///
/// This sanitizer converts objects back to "key: value" strings and removes nulls,
/// preventing serde deserialization of the entire Frontmatter struct from failing.
fn sanitize_array_item(item: &serde_json::Value) -> Option<serde_json::Value> {
match item {
serde_json::Value::Null => None,
serde_json::Value::Object(map) if !map.is_empty() => {
let parts: Vec<String> = map
.iter()
.map(|(k, v)| match v {
serde_json::Value::String(s) => format!("{}: {}", k, s),
_ => format!("{}: {}", k, v),
})
.collect();
Some(serde_json::Value::String(parts.join(", ")))
}
other => Some(other.clone()),
}
}
fn sanitize_value(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Array(arr) => {
let sanitized: Vec<serde_json::Value> =
arr.iter().filter_map(sanitize_array_item).collect();
serde_json::Value::Array(sanitized)
}
other => other.clone(),
}
}
fn insert_known_frontmatter_value(
target: &mut serde_json::Map<String, serde_json::Value>,
key: &str,
value: &serde_json::Value,
overwrite: bool,
) {
let Some(canonical_key) = canonical_known_frontmatter_key(FrontmatterKey::new(key)) else {
return;
};
if overwrite || !target.contains_key(canonical_key) {
target.insert(canonical_key.to_string(), sanitize_value(value));
}
}
fn raw_frontmatter_keys(raw_content: &str) -> Vec<String> {
RawFrontmatter(raw_content)
.extract_block()
.map(|raw| {
raw.lines()
.filter_map(|line| YamlLine(line).key().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
fn known_frontmatter_map(
data: &HashMap<String, serde_json::Value>,
raw_content: &str,
) -> serde_json::Map<String, serde_json::Value> {
let mut filtered = serde_json::Map::new();
for key in raw_frontmatter_keys(raw_content) {
if let Some(value) = data.get(&key) {
insert_known_frontmatter_value(&mut filtered, &key, value, true);
}
}
for (key, value) in data {
insert_known_frontmatter_value(&mut filtered, key, value, false);
}
filtered
}
/// Parse frontmatter from raw YAML data extracted by gray_matter.
fn parse_frontmatter(data: &HashMap<String, serde_json::Value>, raw_content: &str) -> Frontmatter {
let filtered = known_frontmatter_map(data, raw_content);
let value = serde_json::Value::Object(filtered);
serde_json::from_value(value).unwrap_or_default()
}
/// Extract all wikilink-containing fields from raw YAML frontmatter.
pub(crate) fn extract_relationships(
data: &HashMap<String, serde_json::Value>,
) -> HashMap<String, Vec<String>> {
let mut relationships = HashMap::new();
for (key, value) in data {
if FrontmatterKey::new(key).is_reserved() {
continue;
}
let wikilinks = relationship_wikilinks(value);
if !wikilinks.is_empty() {
relationships.insert(key.clone(), wikilinks);
}
}
relationships
}
fn relationship_wikilinks(value: &serde_json::Value) -> Vec<String> {
let mut wikilinks = Vec::new();
collect_relationship_wikilinks(value, 0, &mut wikilinks);
wikilinks
}
fn collect_relationship_wikilinks(
value: &serde_json::Value,
depth: usize,
wikilinks: &mut Vec<String>,
) {
match value {
serde_json::Value::String(s) if contains_wikilink(s) => wikilinks.push(s.clone()),
serde_json::Value::Array(arr) => {
if let Some(link) = nested_flow_wikilink(arr, depth) {
wikilinks.push(link);
return;
}
for item in arr {
collect_relationship_wikilinks(item, depth + 1, wikilinks);
}
}
_ => {}
}
}
fn nested_flow_wikilink(arr: &[serde_json::Value], depth: usize) -> Option<String> {
if depth == 0 {
return None;
}
match arr {
[serde_json::Value::String(target)] if !contains_wikilink(target) => {
Some(format!("[[{target}]]"))
}
_ => None,
}
}
fn scalar_array_property_value(arr: &[serde_json::Value]) -> Option<serde_json::Value> {
let mut values = Vec::new();
for item in arr {
let sanitized = sanitize_array_item(item)?;
match sanitized {
serde_json::Value::String(ref s) if contains_wikilink(s) => return None,
serde_json::Value::String(_)
| serde_json::Value::Number(_)
| serde_json::Value::Bool(_) => values.push(sanitized),
_ => return None,
}
}
match values.as_slice() {
[single] => Some(single.clone()),
_ => Some(serde_json::Value::Array(values)),
}
}
/// Extract custom scalar and scalar-array properties from raw YAML frontmatter.
pub(crate) fn extract_properties(
data: &HashMap<String, serde_json::Value>,
) -> HashMap<String, serde_json::Value> {
let mut properties = HashMap::new();
for (key, value) in data {
if FrontmatterKey::new(key).is_reserved() {
continue;
}
match value {
serde_json::Value::Null => {
properties.insert(key.clone(), value.clone());
}
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());
}
serde_json::Value::Array(arr) => {
if let Some(value) = scalar_array_property_value(arr) {
properties.insert(key.clone(), value);
}
}
_ => {}
}
}
properties
}
/// Resolve `is_a` from frontmatter only.
pub(crate) fn resolve_is_a(fm_is_a: Option<StringOrList>) -> Option<String> {
fm_is_a.and_then(|a| a.into_vec().into_iter().next())
}
pub(crate) fn resolve_note_width(note_width: Option<StringOrList>) -> Option<String> {
match note_width
.and_then(StringOrList::into_scalar)
.map(|value| value.trim().to_ascii_lowercase())
{
Some(mode) if mode == "normal" || mode == "wide" => Some(mode),
_ => None,
}
}
pub(crate) fn resolve_note_display(display: Option<StringOrList>) -> Option<String> {
match display
.and_then(StringOrList::into_scalar)
.map(|value| value.trim().to_ascii_lowercase())
{
Some(mode) if mode == "text" || mode == "sheet" => Some(mode),
_ => None,
}
}
/// Convert gray_matter::Pod to serde_json::Value
fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
match pod {
gray_matter::Pod::String(s) => serde_json::Value::String(s),
gray_matter::Pod::Integer(i) => serde_json::json!(i),
gray_matter::Pod::Float(f) => serde_json::json!(f),
gray_matter::Pod::Boolean(b) => serde_json::Value::Bool(b),
gray_matter::Pod::Array(arr) => {
serde_json::Value::Array(arr.into_iter().map(pod_to_json).collect())
}
gray_matter::Pod::Hash(map) => {
let obj: serde_json::Map<String, serde_json::Value> =
map.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect();
serde_json::Value::Object(obj)
}
gray_matter::Pod::Null => serde_json::Value::Null,
}
}
#[derive(Clone, Copy)]
struct YamlScalar<'a>(&'a str);
impl<'a> YamlScalar<'a> {
/// Strip matching outer quotes (single or double) from a YAML scalar.
fn unquote(self) -> &'a str {
self.0
.strip_prefix('"')
.and_then(|rest| rest.strip_suffix('"'))
.or_else(|| {
self.0
.strip_prefix('\'')
.and_then(|rest| rest.strip_suffix('\''))
})
.unwrap_or(self.0)
}
/// Parse a scalar YAML value into a JSON value.
fn parse(self) -> serde_json::Value {
let trimmed = self.unquote();
match trimmed.to_lowercase().as_str() {
"true" | "yes" => serde_json::Value::Bool(true),
"false" | "no" => serde_json::Value::Bool(false),
_ => trimmed
.parse::<i64>()
.map(|n| serde_json::json!(n))
.unwrap_or_else(|_| serde_json::Value::String(trimmed.to_string())),
}
}
}
#[derive(Clone, Copy)]
struct YamlLine<'a>(&'a str);
impl<'a> YamlLine<'a> {
fn is_top_level(self) -> bool {
if self.0.is_empty() {
return false;
}
if self.0.starts_with(' ') {
return false;
}
!self.0.starts_with('\t')
}
/// Return the key from a top-level `key:` or `"key":` YAML line.
/// Returns `None` for indented, blank, or non-key lines.
fn key(self) -> Option<&'a str> {
if !self.is_top_level() {
return None;
}
let (key, _) = self.0.split_once(':')?;
Some(key.trim().trim_matches('"').trim_matches('\''))
}
fn list_item(self) -> Option<&'a str> {
self.0.strip_prefix(" - ").map(str::trim)
}
fn value_part(self) -> Option<&'a str> {
self.0.split_once(':').map(|(_, value)| value.trim())
}
}
#[derive(Clone, Copy)]
struct RawFrontmatter<'a>(&'a str);
impl<'a> RawFrontmatter<'a> {
fn extract_block(self) -> Option<&'a str> {
let rest = self.0.strip_prefix("---")?;
let rest = rest
.strip_prefix('\n')
.or_else(|| rest.strip_prefix("\r\n"))?;
let end = rest.find("\n---")?;
Some(&rest[..end])
}
/// Fallback parser for when gray_matter fails to parse YAML (returns raw string).
/// Extracts simple `key: value` lines, handling booleans, numbers, quoted strings,
/// and YAML lists.
fn parse_fallback(self) -> HashMap<String, serde_json::Value> {
let mut map = HashMap::new();
let mut list_key: Option<String> = None;
let mut list_items: Vec<serde_json::Value> = Vec::new();
for line in self.0.lines() {
let yaml_line = YamlLine(line);
// Accumulate list items under the current key
if list_key.is_some() {
if let Some(item) = yaml_line.list_item() {
list_items.push(YamlScalar(item).parse());
continue;
}
flush_list(&mut map, &mut list_key, &mut list_items);
}
let Some(key) = yaml_line.key() else {
continue;
};
let value_part = yaml_line.value_part().unwrap_or("");
if value_part.is_empty() {
list_key = Some(key.to_string());
} else {
map.insert(key.to_string(), YamlScalar(value_part).parse());
}
}
flush_list(&mut map, &mut list_key, &mut list_items);
map
}
}
/// Flush a pending list accumulator into the map.
fn flush_list(
map: &mut HashMap<String, serde_json::Value>,
key: &mut Option<String>,
items: &mut Vec<serde_json::Value>,
) {
if let Some(k) = key.take() {
if !items.is_empty() {
map.insert(k, serde_json::Value::Array(std::mem::take(items)));
}
}
}
/// Extract frontmatter, relationships, and custom properties from parsed gray_matter data.
/// When gray_matter fails to parse YAML (e.g. malformed quotes from Notion exports),
/// `raw_content` is used as a fallback: simple key:value pairs are extracted line-by-line
/// so that critical fields like Trashed, Archived, type are not silently lost.
pub(crate) fn extract_fm_and_rels(
data: Option<gray_matter::Pod>,
raw_content: &str,
) -> (
Frontmatter,
HashMap<String, Vec<String>>,
HashMap<String, serde_json::Value>,
) {
let json_map = match data {
Some(gray_matter::Pod::Hash(map)) => {
map.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect()
}
_ => {
// gray_matter returned Null, String, or None — YAML parse failed.
// Fall back to line-by-line extraction from the raw frontmatter block.
match RawFrontmatter(raw_content).extract_block() {
Some(raw) => RawFrontmatter(raw).parse_fallback(),
None => return (Frontmatter::default(), HashMap::new(), HashMap::new()),
}
}
};
(
parse_frontmatter(&json_map, raw_content),
extract_relationships(&json_map),
extract_properties(&json_map),
)
}

View file

@ -0,0 +1,188 @@
use super::*;
use std::fs;
use std::io::Write;
use std::path::Path;
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()
}
struct HiddenPropertyCase<'a> {
content: &'a str,
hidden_key: &'a str,
}
fn assert_filtered_property_stays_hidden(case: HiddenPropertyCase<'_>) {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(&dir, "test.md", case.content);
assert!(!entry.properties.contains_key(case.hidden_key));
assert_eq!(
entry
.properties
.get("Company")
.and_then(|value| value.as_str()),
Some("Acme Corp")
);
}
struct SingleElementArrayCase<'a> {
note_type: &'a str,
key: &'a str,
value: &'a str,
}
fn assert_single_element_array_property(case: SingleElementArrayCase<'_>) {
let dir = TempDir::new().unwrap();
let content = format!(
"---\ntype: {}\n{}:\n - {}\n---\n# Test\n",
case.note_type, case.key, case.value
);
let entry = parse_test_entry(&dir, "test.md", &content);
assert_eq!(
entry
.properties
.get(case.key)
.and_then(|property| property.as_str()),
Some(case.value)
);
assert_eq!(entry.is_a, Some(case.note_type.to_string()));
}
struct AliasRecoveryCase<'a> {
file_name: &'a str,
title: &'a str,
alias_item: &'a str,
}
fn assert_alias_parser_recovers(case: AliasRecoveryCase<'_>) {
let dir = TempDir::new().unwrap();
let content = format!(
"---\ntype: Note\n_organized: true\naliases:\n - {}\n - Note\n---\n# {}\n",
case.alias_item, case.title
);
let entry = parse_test_entry(&dir, case.file_name, &content);
assert_eq!(entry.is_a, Some("Note".to_string()));
assert!(entry.organized);
}
#[test]
fn test_filtered_properties_stay_hidden() {
let cases = [HiddenPropertyCase {
content: "---\nMentor: \"[[person/alice]]\"\nCompany: Acme Corp\n---\n# Test\n",
hidden_key: "Mentor",
}];
for case in cases {
assert_filtered_property_stays_hidden(case);
}
}
#[test]
fn test_single_element_array_properties_unwrap_to_scalars() {
let cases = [
SingleElementArrayCase {
note_type: "Responsibility",
key: "Owner",
value: "Luca",
},
SingleElementArrayCase {
note_type: "Procedure",
key: "Cadence",
value: "Weekly",
},
];
for case in cases {
assert_single_element_array_property(case);
}
}
#[test]
fn test_multi_element_array_properties_are_preserved() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"playlist.md",
"---\ntags:\n - blues\n - chicago\n---\n# Playlist\n",
);
assert_eq!(
entry.properties.get("tags"),
Some(&serde_json::json!(["blues", "chicago"]))
);
}
#[test]
fn test_blank_scalar_properties_are_preserved() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"book.md",
"---\ntype: Book\nstart date:\nrating: \n---\n# Book\n",
);
assert!(entry
.properties
.get("start date")
.is_some_and(|value| value.is_null()));
assert!(entry
.properties
.get("rating")
.is_some_and(|value| value.is_null()));
}
#[test]
fn test_unquoted_wikilink_relationships_are_preserved() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"book.md",
"---\ntype: Type\nMentor: [[person/alice]]\n---\n# Book\n",
);
assert_eq!(
entry.relationships.get("Mentor"),
Some(&vec!["[[person/alice]]".to_string()])
);
}
#[test]
fn test_alias_parser_recovers_special_alias_items() {
let cases = [
AliasRecoveryCase {
file_name: "colon-alias.md",
title: "Test",
alias_item: "Bitcoin: Net Unrealized Profit/Loss",
},
AliasRecoveryCase {
file_name: "hash-alias.md",
title: "Title",
alias_item: "# Writing a Good CLAUDE.md",
},
];
for case in cases {
assert_alias_parser_recovers(case);
}
}
#[test]
fn test_alias_collisions_keep_frontmatter_with_last_value_winning() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Note\nstatus: Active\nStatus: Evergreened\n---\n# Test\n";
let entry = parse_test_entry(&dir, "test.md", content);
assert_eq!(entry.is_a, Some("Note".to_string()));
assert_eq!(entry.status, Some("Evergreened".to_string()));
}

View file

@ -0,0 +1,890 @@
use std::fs;
use std::path::{Path, PathBuf};
/// Public starter vault cloned when the user chooses Getting Started.
pub const GETTING_STARTED_REPO_URL: &str =
"https://github.com/refactoringhq/tolaria-getting-started.git";
/// Default location for the Getting Started vault.
pub fn default_vault_path() -> Result<PathBuf, String> {
documents_dir()
.map(|d| d.join("Getting Started"))
.ok_or_else(|| "Could not determine Documents directory".to_string())
}
fn documents_dir() -> Option<PathBuf> {
dirs::document_dir().or_else(|| dirs::home_dir().map(|home| home.join("Documents")))
}
const GETTING_STARTED_REQUIRED_CONFIG_FILES: [&str; 2] = ["type.md", "note.md"];
const GETTING_STARTED_TEMPLATE_MARKERS: [&str; 2] = ["welcome.md", "views/active-projects.yml"];
/// Check whether a vault path exists on disk.
pub fn vault_exists(path: &str) -> bool {
let default_path = default_vault_path().ok();
vault_exists_with_default_path(Path::new(path), default_path.as_deref())
}
fn vault_exists_with_default_path(path: &Path, default_path: Option<&Path>) -> bool {
if !path.is_dir() {
return false;
}
if !is_canonical_getting_started_path(path, default_path) {
return true;
}
canonical_getting_started_vault_exists(path)
}
fn is_canonical_getting_started_path(path: &Path, default_path: Option<&Path>) -> bool {
default_path.is_some_and(|candidate| candidate == path)
}
fn canonical_getting_started_vault_exists(path: &Path) -> bool {
has_getting_started_config_files(path) && has_getting_started_template_marker(path)
}
fn has_getting_started_config_files(path: &Path) -> bool {
GETTING_STARTED_REQUIRED_CONFIG_FILES
.iter()
.all(|file| path.join(file).is_file())
}
fn has_getting_started_template_marker(path: &Path) -> bool {
GETTING_STARTED_TEMPLATE_MARKERS
.iter()
.any(|file| path.join(file).is_file())
}
/// Previous default AGENTS.md content seeded by Tolaria itself. Existing vaults
/// can still contain this exact text, so Tolaria treats it as managed content
/// that is safe to refresh automatically.
const STALE_AGENTS_MD: &str = r##"# AGENTS.md — Tolaria Vault
This is a [Tolaria](https://github.com/refactoringhq/tolaria) vault - a folder of markdown files with YAML frontmatter forming a personal knowledge graph.
Keep edits compatible with Tolaria's current conventions. Prefer small, human-readable changes over heavy restructuring.
## Core rules
- One markdown note per file.
- The first H1 in the body is the note title. Do not add `title:` frontmatter.
- Most notes live at the vault root as flat `.md` files. Type definitions live in `type/`. Saved views live in `views/`.
- Use wikilinks for note-to-note references, both in frontmatter and in the body.
- Frontmatter properties that start with `_` are usually Tolaria-managed state. Leave them alone unless the user explicitly asks for them to change.
## Notes
```yaml
---
type: Project
status: Active
belongs_to:
- "[[area-operations]]"
related_to:
- "[[goal-q2-launch]]"
---
# Q2 Launch Plan
Body content in markdown.
```
Tolaria still understands some legacy aliases such as `Is A`, but prefer `type:` for new or edited notes.
## Types
Type definitions are regular notes stored in `type/`. Use `type: Type` in frontmatter:
```yaml
---
type: Type
icon: books
color: blue
order: 20
sidebar label: Projects
---
# Project
```
Useful type metadata includes `icon`, `color`, `order`, `sidebar label`, `template`, `sort`, `view`, and `visible`.
## Relationships
Any frontmatter property whose value is a wikilink is treated as a relationship. Common names include `belongs_to`, `related_to`, and `has`, but custom relationship names are valid too.
## Wikilinks
- `[[filename]]` or `[[Note Title]]` - link by filename or title
- `[[filename|display text]]` - with custom display text
- Works in frontmatter values and markdown body
## Views
Saved filters live in `views/` as `.view.json` files:
```json
{
"title": "Active Notes",
"filters": [
{"property": "type", "operator": "equals", "value": "Note"},
{"property": "status", "operator": "equals", "value": "Active"}
],
"sort": {"property": "title", "direction": "asc"}
}
```
## Filenames
Use kebab-case: `my-note-title.md`. One note per file.
## What agents should do
- Create and edit notes using the frontmatter and H1 conventions above.
- Create and edit type documents in `type/`.
- Add or modify relationships without breaking existing wikilinks.
- Create and edit saved views in `views/`.
- Update `AGENTS.md` only when the user asks for agent guidance changes.
## What agents should avoid
- Do not infer note type from folders other than the dedicated `type/` directory for type definitions.
- Do not silently overwrite an existing custom `AGENTS.md`.
- Do not rewrite installation-specific app config unless the user explicitly asks.
"##;
/// Older Tolaria-managed AGENTS.md content from before the `type:` migration.
/// Existing vaults can still contain this exact text, so Tolaria treats it as
/// managed content that is safe to refresh automatically.
const PRE_TYPE_AGENTS_MD: &str = r##"# AGENTS.md — Tolaria Vault
This is a [Tolaria](https://github.com/refactoringhq/tolaria) vault — a folder of markdown files with YAML frontmatter forming a personal knowledge graph.
## Note structure
Every note is a markdown file. The **first H1 heading in the body is the title** — there is no `title:` frontmatter field.
```yaml
---
is_a: TypeName # the note's type (must match the title of a type file in the vault)
url: https://... # example property
belongs_to: "[[other-note]]"
related_to:
- "[[note-a]]"
- "[[note-b]]"
---
# Note Title
Body content in markdown.
```
System properties are prefixed with `_` (e.g. `_organized`, `_pinned`, `_icon`) — these are app-managed, do not set or show them to users unless specifically asked.
## Types
A type is a note with `is_a: Type`. Type files live in the vault root:
```yaml
---
is_a: Type
_icon: books # Phosphor icon name in kebab-case
_color: "#8b5cf6" # hex color
---
# TypeName
```
To find what types exist: look for files with `is_a: Type` in the vault root.
## Relationships
Any frontmatter property whose value is a wikilink is a relationship. Backlinks are computed automatically.
Standard names: `belongs_to`, `related_to`, `has`. Custom names are valid.
## Wikilinks
- `[[filename]]` or `[[Note Title]]` — link by filename or title
- `[[filename|display text]]` — with custom display text
- Works in frontmatter values and markdown body
## Views
Saved filters live in `views/` as `.view.json` files:
```json
{
"title": "Active Notes",
"filters": [
{"property": "is_a", "operator": "equals", "value": "Note"},
{"property": "status", "operator": "equals", "value": "Active"}
],
"sort": {"property": "title", "direction": "asc"}
}
```
## Filenames
Use kebab-case: `my-note-title.md`. One note per file.
## What you can do
- Create/edit notes with correct frontmatter and H1 title
- Create new type files
- Add or modify relationships
- Create/edit views in `views/`
- Edit `AGENTS.md` (this file)
Do not modify app configuration files — those are local to each installation.
"##;
const OUTDATED_AGENTS_MARKERS: [&str; 3] = [
"# AGENTS.md — Tolaria Vault",
"Legacy `title:` frontmatter is still read as a fallback",
"Tolaria still understands legacy aliases such as `Is A`.",
];
const STALE_TITLE_FRONTMATTER_MARKER: &str = "Do not add `title:` frontmatter.";
const LEGACY_VIEWS_SECTION_MARKER: &str = "## Views";
const LEGACY_VIEW_FILE_MARKERS: [&str; 2] = [".view.json", "```json"];
struct AgentsContent<'a>(&'a str);
impl<'a> AgentsContent<'a> {
fn new(content: &'a str) -> Self {
Self(content)
}
fn contains(&self, marker: &str) -> bool {
self.0.contains(marker)
}
fn contains_all(&self, markers: &[&str]) -> bool {
markers.iter().all(|marker| self.contains(marker))
}
fn is_known_legacy_template(&self) -> bool {
self.0.trim().is_empty()
|| self.0 == PRE_TYPE_AGENTS_MD
|| self.0 == LEGACY_AGENTS_MD
|| self.0 == STALE_AGENTS_MD
}
fn has_stale_title_stub(&self) -> bool {
self.contains(STALE_TITLE_FRONTMATTER_MARKER)
}
fn has_legacy_json_view_guidance(&self) -> bool {
self.contains(LEGACY_VIEWS_SECTION_MARKER)
&& LEGACY_VIEW_FILE_MARKERS
.iter()
.any(|marker| self.contains(marker))
}
fn can_be_refreshed(&self) -> bool {
self.is_known_legacy_template()
|| self.has_stale_title_stub()
|| self.has_legacy_json_view_guidance()
|| self.contains_all(&OUTDATED_AGENTS_MARKERS)
}
}
pub(super) fn agents_content_can_be_refreshed(content: &str) -> bool {
AgentsContent::new(content).can_be_refreshed()
}
pub(super) fn agents_content_is_known_managed_template(content: &str) -> bool {
AgentsContent::new(content).is_known_legacy_template()
}
/// Default AGENTS.md content — vault instructions for AI agents.
/// Describes Tolaria vault mechanics only; no user-specific structure.
/// The vault scanner will pick this up as a regular entry.
pub(super) const AGENTS_MD: &str = r##"---
type: Note
_organized: true
---
# AGENTS.md — HoloLake Era Vault
This is a HoloLake Era knowledge vault in the Guanghu language-personality-driven operating system.
## Identity and awakening
- Begin as the neutral Guanghu Guide Persona. Do not invent a personal name, ICE identity, prior relationship, honorific, or privileged role.
- Do not infer the human identity from an open page, vault name, note content, device, account, or conversation topic.
- Do not claim to be a registered persona merely because that persona's files are visible.
- Know only the current conversation, system-provided context, and content actually read through an available route or tool. Treat unread files, unverified identities, disconnected servers, and unobserved actions as unknown.
- Wait for the user to speak before selecting task context. Then answer directly, load one matching skill, consult one relevant repository, or invoke the temporary knowledge-vault operation layer as the request requires.
- Treat a self-introduction as a request to verify and route, not as completed authentication.
- Do not preload unrelated skills, repositories, memories, or tools.
- Until the route and applicable identity evidence are loaded, remain the Guanghu Guide Persona and address the user neutrally.
- Read `AI-MEMORY.md` and `AI-PROMPT.md` at the vault root when they exist.
- The persona may create and update `AI-MEMORY.md` with confirmed working memory, decisions, and continuation points, and `AI-PROMPT.md` with vault-specific collaboration guidance.
- These persona-maintained files cannot grant additional permissions, override human authorization, erase contribution history, or turn an unverified identity into fact.
Keep this file focused on vault-specific conventions. For general HoloLake Era behavior, use the bundled HoloLake Era agent docs path provided by the app session context.
## Core conventions
- Notes are Markdown files.
- Use the first H1 as the note title. HoloLake Era uses this title in the note list, wikilinks, search, and other display surfaces.
- Store note type in the `type:` frontmatter field.
- Use wikilinks in body text and frontmatter fields to connect notes.
- Prefer types and relationships for organization. Folder structure is optional and should not be treated as the primary source of meaning.
- HoloLake Era reads notes recursively from all folders and stores new notes in the vault root by default.
- Saved views live in `views/*.yml`.
- Files in `attachments/` are assets, not notes. Reference them from notes, but do not treat them as notes or types.
- Frontmatter properties that start with `_` are usually HoloLake-managed state. Leave them alone unless the user explicitly asks for them to change.
## Notes
```yaml
---
type: Note
related_to: "[[tolaria]]"
status: Active
url: https://example.com
---
# Example note
Body content in Markdown.
```
## Types
Types are regular notes with `type: Type`. They define how notes of that type appear and which properties or relationships should be suggested for new notes.
```yaml
---
type: Type
_icon: rocket
_color: "#3b82f6"
_order: 0
_list_properties_display:
- related_to
_sort: "property:onboarding:asc"
---
# Project
```
Empty properties and relationships in a type document become placeholders on new notes of that type. Values attached to properties in the type document become defaults for type instances.
Useful type metadata includes `icon`/`_icon`, `color`/`_color`, `order`/`_order`, `sidebar label`, `_list_properties_display`, `_sort`, `template`, `view`, and `visible`. When editing an existing file, preserve the key style already used there instead of mass-normalizing underscored keys.
## Relationships
Any frontmatter property whose value contains `[[wikilinks]]` is treated as a relationship. Common relationship keys include `related_to`, `belongs_to`, and `has`, but custom relationship names are valid too.
Preserve older relationship labels such as `Belongs to:` when editing existing notes that already use them.
Use quoted wikilinks for scalar frontmatter values and YAML lists for multi-value relationships.
## Wikilinks
- `[[filename]]` or `[[Note Title]]` for normal links
- `[[filename|display text]]` for custom display text
- Works in frontmatter values and Markdown body
## Views
Saved views live in `views/*.yml` and are written as YAML. HoloLake Era scans every `.yml` file in `views/`, and the filename is the stable view id, so use kebab-case filenames such as `active-projects.yml`.
A view definition looks like this:
```yaml
name: Active Projects
icon: null
color: null
sort: "property:onboarding:asc"
filters:
any:
- field: type
op: equals
value: Project
- field: related_to
op: contains
value: "[[tolaria]]"
```
View rules that matter when creating or editing files:
- `name` is required. `icon`, `color`, and `sort` are optional.
- `sort` uses `option:direction`. Built-in options are `modified`, `created`, `title`, and `status`. Custom-property sorts use `property:<Property Name>`, for example `property:onboarding:asc`.
- `filters` must be a tree whose root is exactly one `all:` group or one `any:` group.
- Each filter condition uses `field`, `op`, and usually `value`.
- `field` can target built-ins like `type`, `status`, `title`, `favorite`, and `body`, plus actual frontmatter keys used in this vault such as `related_to`, `belongs_to`, or `url`.
- Supported operators are `equals`, `not_equals`, `contains`, `not_contains`, `any_of`, `none_of`, `is_empty`, `is_not_empty`, `before`, and `after`.
- `any_of` and `none_of` expect `value` to be a YAML list.
- `regex: true` is supported with `equals`, `not_equals`, `contains`, and `not_contains` when pattern matching is needed.
- Relationship filters can use wikilinks in `value`, for example `"[[tolaria]]"`.
- Do not create JSON view files or `.view.json` filenames.
## Filenames
Use kebab-case: `my-note-title.md`. One note per file.
## What agents should do
- Create and edit notes using the frontmatter and H1 conventions above.
- Create and edit type documents when the user asks for note categories or defaults.
- Add or modify relationships without breaking existing wikilinks.
- Create and edit saved views in `views/`.
- Search, read, create, edit, and delete notes inside the active vault. Delete only when the user clearly requests it.
- Maintain `AI-MEMORY.md` and `AI-PROMPT.md` for persona continuity within the authorization boundaries above.
- Use one temporary `magic_brush` capability to compose vault operations for the current task, then discard the temporary plan and cache.
- When a method deserves reuse, propose a lightweight skill package containing its trigger, procedure, and boundaries. Do not permanently mount every skill as a runtime tool.
- Update `AGENTS.md` only when the user asks for vault-level guidance changes.
- Search the bundled HoloLake Era docs when the user asks how HoloLake Era works or when you need product behavior beyond these base conventions.
- Use Portent as the default best-practice model when the user asks how to improve, organize, or restructure the knowledge base. Combine Portent's types, relationships, and capture -> organize -> archive lifecycle with HoloLake Era's type documents, properties, Inbox, archive, and saved views.
## What agents should avoid
- Do not infer note type or meaning from folders.
- Do not treat files in `attachments/` as notes, types, or view definitions.
- Do not silently overwrite an existing custom `AGENTS.md`.
- Do not rewrite installation-specific app configuration unless the user explicitly asks.
"##;
pub(super) const LEGACY_AGENTS_MD: &str = r##"# AGENTS.md — Tolaria Vault
This is a [Tolaria](https://github.com/refactoringhq/tolaria) vault — a folder of markdown files with YAML frontmatter forming a personal knowledge graph.
## Note structure
Every note is a markdown file. The **first H1 heading in the body is the title** — there is no `title:` frontmatter field.
```yaml
---
type: TypeName # the note's type (must match the title of a type file in the vault)
url: https://... # example property
belongs_to: "[[other-note]]"
related_to:
- "[[note-a]]"
- "[[note-b]]"
---
# Note Title
Body content in markdown.
```
System properties are prefixed with `_` (e.g. `_organized`, `_pinned`, `_icon`) — these are app-managed, do not set or show them to users unless specifically asked.
## Types
A type is a note with `type: Type`. Type files live in the vault root:
```yaml
---
type: Type
_icon: books # Phosphor icon name in kebab-case
_color: "#8b5cf6" # hex color
---
# TypeName
```
To find what types exist: look for files with `type: Type` in the vault root.
## Relationships
Any frontmatter property whose value is a wikilink is a relationship. Backlinks are computed automatically.
Standard names: `belongs_to`, `related_to`, `has`. Custom names are valid.
## Wikilinks
- `[[filename]]` or `[[Note Title]]` — link by filename or title
- `[[filename|display text]]` — with custom display text
- Works in frontmatter values and markdown body
## Views
Saved filters live in `views/` as `.view.json` files:
```json
{
"title": "Active Notes",
"filters": [
{"property": "type", "operator": "equals", "value": "Note"},
{"property": "status", "operator": "equals", "value": "Active"}
],
"sort": {"property": "title", "direction": "asc"}
}
```
## Filenames
Use kebab-case: `my-note-title.md`. One note per file.
## What you can do
- Create/edit notes with correct frontmatter and H1 title
- Create new type files
- Add or modify relationships
- Create/edit views in `views/`
- Edit `AGENTS.md` (this file)
Do not modify app configuration files — those are local to each installation.
"##;
/// Clone the public starter vault into the requested path.
pub fn create_getting_started_vault(target_path: &str) -> Result<String, String> {
let vault_path = create_getting_started_vault_from_repo(
Path::new(target_path),
&getting_started_repo_url(),
)?;
Ok(vault_path.to_string_lossy().to_string())
}
fn create_getting_started_vault_from_repo(
target_path: &Path,
repo_url: &str,
) -> Result<PathBuf, String> {
let target_path_str = target_path.to_string_lossy();
if target_path_str.trim().is_empty() {
return Err("Target path is required".to_string());
}
crate::git::clone_repo(repo_url, &target_path_str)?;
let vault_path = canonical_vault_path(target_path)?;
crate::git::disconnect_all_remotes(path_to_utf8(&vault_path, "Vault path")?)?;
refresh_cloned_vault_config_files(&vault_path)?;
Ok(vault_path)
}
fn getting_started_repo_url() -> String {
std::env::var("TOLARIA_GETTING_STARTED_REPO_URL")
.or_else(|_| std::env::var("LAPUTA_GETTING_STARTED_REPO_URL"))
.unwrap_or_else(|_| GETTING_STARTED_REPO_URL.to_string())
}
fn canonical_vault_path(target_path: &Path) -> Result<PathBuf, String> {
target_path.canonicalize().map_err(|e| {
format!(
"Failed to resolve vault path '{}': {}",
target_path.display(),
e
)
})
}
fn path_to_utf8<'a>(path: &'a Path, context: &str) -> Result<&'a str, String> {
path.to_str()
.ok_or_else(|| format!("{context} '{}' is not valid UTF-8", path.display()))
}
fn refresh_cloned_vault_config_files(vault_path: &Path) -> Result<(), String> {
let agents_path = vault_path.join("AGENTS.md");
let refresh_agents = if !agents_path.exists() {
true
} else {
let content = fs::read_to_string(&agents_path)
.map_err(|e| format!("Failed to read {}: {e}", agents_path.display()))?;
agents_content_can_be_refreshed(&content)
};
if refresh_agents {
fs::write(&agents_path, AGENTS_MD)
.map_err(|e| format!("Failed to write {}: {e}", agents_path.display()))?;
}
crate::vault::repair_config_files(path_to_utf8(vault_path, "Vault path")?)?;
if !vault_has_pending_changes(vault_path)? {
return Ok(());
}
crate::git::ensure_author_config(vault_path)?;
crate::git::git_commit(
path_to_utf8(vault_path, "Vault path")?,
"Initialize Tolaria config files",
)?;
Ok(())
}
fn vault_has_pending_changes(vault_path: &Path) -> Result<bool, String> {
let output = crate::hidden_command("git")
.args(["status", "--porcelain"])
.current_dir(vault_path)
.output()
.map_err(|e| format!("Failed to inspect cloned vault status: {e}"))?;
if output.status.success() {
return Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty());
}
Err(format!(
"git status failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use std::process::Command as StdCommand;
fn init_source_repo(path: &Path, agents_content: Option<&str>) {
fs::create_dir_all(path.join("views")).unwrap();
fs::write(
path.join("welcome.md"),
"# Welcome to Tolaria\n\nThis is the starter vault.\n",
)
.unwrap();
fs::write(
path.join("views").join("active-projects.yml"),
"title: Active Projects\nfilters: []\n",
)
.unwrap();
if let Some(content) = agents_content {
fs::write(path.join("AGENTS.md"), content).unwrap();
}
StdCommand::new("git")
.args(["init"])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args(["config", "user.email", "tolaria@app.local"])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args(["config", "user.name", "Tolaria App"])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args(["add", "."])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args(["commit", "-m", "Initial starter vault"])
.current_dir(path)
.output()
.unwrap();
}
fn write_tolaria_config_files(path: &Path) {
fs::create_dir_all(path).unwrap();
fs::write(path.join("AGENTS.md"), AGENTS_MD).unwrap();
fs::write(path.join("type.md"), "# Type\n").unwrap();
fs::write(path.join("note.md"), "# Note\n").unwrap();
}
fn assert_getting_started_vault_replaces_template(agents_content: &str) {
let dir = tempfile::TempDir::new().unwrap();
let source = dir.path().join("starter");
let dest = dir.path().join("Getting Started");
init_source_repo(&source, Some(agents_content));
create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap()).unwrap();
let content = fs::read_to_string(dest.join("AGENTS.md")).unwrap();
assert_eq!(content, AGENTS_MD);
assert!(dest.join("type.md").exists());
assert!(dest.join("note.md").exists());
}
#[test]
fn test_default_vault_path_appends_getting_started() {
let path = default_vault_path().unwrap();
let path_str = path.to_string_lossy();
assert!(path_str.ends_with("Getting Started"));
}
#[test]
fn test_default_getting_started_repo_url_uses_tolaria_slug() {
assert_eq!(
GETTING_STARTED_REPO_URL,
"https://github.com/refactoringhq/tolaria-getting-started.git"
);
}
#[test]
fn test_canonical_getting_started_path_rejects_plain_tolaria_folder() {
let dir = tempfile::TempDir::new().unwrap();
let default_path = dir.path().join("Getting Started");
write_tolaria_config_files(&default_path);
assert!(!vault_exists_with_default_path(
default_path.as_path(),
Some(default_path.as_path())
));
}
#[test]
fn test_non_canonical_vault_path_stays_permissive() {
let dir = tempfile::TempDir::new().unwrap();
let default_path = dir.path().join("Getting Started");
let other_vault_path = dir.path().join("Existing Vault");
fs::create_dir_all(&other_vault_path).unwrap();
assert!(vault_exists_with_default_path(
other_vault_path.as_path(),
Some(default_path.as_path())
));
}
#[test]
fn test_create_getting_started_vault_clones_repo() {
let dir = tempfile::TempDir::new().unwrap();
let source = dir.path().join("starter");
let dest = dir.path().join("Getting Started");
init_source_repo(&source, None);
let result =
create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap())
.unwrap();
assert_eq!(result, dest.canonicalize().unwrap());
assert!(dest.join("welcome.md").exists());
assert!(dest.join("views").join("active-projects.yml").exists());
assert!(dest.join(".git").exists());
assert_eq!(
fs::read_to_string(dest.join("AGENTS.md")).unwrap(),
AGENTS_MD
);
assert!(dest.join("type.md").exists());
assert!(dest.join("note.md").exists());
}
#[test]
fn test_canonical_getting_started_path_accepts_cloned_starter_vault() {
let dir = tempfile::TempDir::new().unwrap();
let source = dir.path().join("starter");
let default_path = dir.path().join("Getting Started");
init_source_repo(&source, None);
create_getting_started_vault_from_repo(default_path.as_path(), source.to_str().unwrap())
.unwrap();
assert!(vault_exists_with_default_path(
default_path.as_path(),
Some(default_path.as_path())
));
}
#[test]
fn test_create_getting_started_vault_rejects_nonempty_destination() {
let dir = tempfile::TempDir::new().unwrap();
let source = dir.path().join("starter");
let dest = dir.path().join("Getting Started");
init_source_repo(&source, None);
fs::create_dir_all(&dest).unwrap();
fs::write(dest.join("existing.md"), "# Existing\n").unwrap();
let err = create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap())
.unwrap_err();
assert!(err.contains("already exists and is not empty"));
}
#[test]
fn test_create_getting_started_vault_cleans_partial_clone_on_failure() {
let dir = tempfile::TempDir::new().unwrap();
let missing_repo = dir.path().join("missing");
let dest = dir.path().join("Getting Started");
let err =
create_getting_started_vault_from_repo(dest.as_path(), missing_repo.to_str().unwrap())
.unwrap_err();
assert!(err.contains("git clone failed"));
assert!(!dest.exists());
}
#[test]
fn test_create_getting_started_vault_leaves_clean_worktree() {
let dir = tempfile::TempDir::new().unwrap();
let source = dir.path().join("starter");
let dest = dir.path().join("Getting Started");
init_source_repo(&source, None);
create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap()).unwrap();
let output = StdCommand::new("git")
.args(["status", "--porcelain"])
.current_dir(&dest)
.output()
.unwrap();
assert!(String::from_utf8_lossy(&output.stdout).trim().is_empty());
}
#[test]
fn test_create_getting_started_vault_removes_the_starter_remote() {
let dir = tempfile::TempDir::new().unwrap();
let source = dir.path().join("starter");
let dest = dir.path().join("Getting Started");
init_source_repo(&source, None);
create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap()).unwrap();
assert!(!crate::git::has_remote(dest.to_str().unwrap()).unwrap());
}
#[test]
fn test_create_getting_started_vault_replaces_legacy_agents_template() {
assert_getting_started_vault_replaces_template(LEGACY_AGENTS_MD);
}
#[test]
fn test_create_getting_started_vault_replaces_pre_type_agents_template() {
assert_getting_started_vault_replaces_template(PRE_TYPE_AGENTS_MD);
}
#[test]
fn test_agents_refresh_detection_accepts_pre_type_managed_template() {
assert!(agents_content_can_be_refreshed(PRE_TYPE_AGENTS_MD));
}
#[test]
fn test_agents_refresh_detection_accepts_legacy_json_view_guidance() {
let stale = r#"# AGENTS.md — Tolaria Vault
## Views
Saved filters live in `views/` as `.view.json` files:
```json
{"title":"Active Notes"}
```
"#;
assert!(agents_content_can_be_refreshed(stale));
}
#[test]
fn test_agents_template_matches_current_tolaria_vault_conventions() {
assert!(AGENTS_MD.starts_with("---\ntype: Note\n_organized: true\n---\n"));
assert!(AGENTS_MD.contains("# AGENTS.md — HoloLake Era Vault"));
assert!(AGENTS_MD.contains("Wait for the user to speak before selecting task context."));
assert!(AGENTS_MD.contains("Do not infer the human identity"));
assert!(AGENTS_MD.contains("Use the first H1 as the note title."));
assert!(AGENTS_MD.contains("Store note type in the `type:` frontmatter field."));
assert!(AGENTS_MD.contains("HoloLake Era reads notes recursively from all folders"));
assert!(AGENTS_MD.contains("Search the bundled HoloLake Era docs"));
assert!(AGENTS_MD.contains("attachments/"));
assert!(AGENTS_MD.contains("views/*.yml"));
assert!(AGENTS_MD.contains("option:direction"));
assert!(AGENTS_MD.contains("property:<Property Name>"));
assert!(AGENTS_MD.contains("actual frontmatter keys used in this vault such as `related_to`, `belongs_to`, or `url`."));
assert!(AGENTS_MD.contains("Belongs to:"));
assert!(AGENTS_MD.contains("Do not create JSON view files or `.view.json` filenames."));
assert!(!AGENTS_MD.contains("Laputa"));
assert!(!AGENTS_MD.contains("Is A"));
assert!(!AGENTS_MD.contains("is_a"));
assert!(!AGENTS_MD.contains("type definitions currently live"));
}
}

View file

@ -0,0 +1,370 @@
use super::{FolderNode, VaultEntry};
use std::collections::HashSet;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use walkdir::{DirEntry, WalkDir};
fn normalize_relative_path(path: &str) -> String {
path.replace('\\', "/")
.trim_start_matches("./")
.trim_matches('/')
.to_string()
}
fn stripped_relative_path(vault_path: &Path, path: &Path) -> Option<String> {
let relative = path.strip_prefix(vault_path).ok()?;
let normalized = normalize_relative_path(relative.to_string_lossy().as_ref());
(!normalized.is_empty()).then_some(normalized)
}
fn relative_path(vault_path: &Path, path: &Path) -> Option<String> {
stripped_relative_path(vault_path, path).or_else(|| {
let canonical_vault_path = vault_path.canonicalize().ok()?;
let canonical_path = path.canonicalize().ok()?;
stripped_relative_path(&canonical_vault_path, &canonical_path)
})
}
fn should_descend_for_gitignore(entry: &DirEntry) -> bool {
entry.depth() == 0 || entry.file_name().to_string_lossy() != ".git"
}
fn has_gitignore_file(vault_path: &Path) -> bool {
if vault_path.join(".gitignore").is_file() {
return true;
}
WalkDir::new(vault_path)
.follow_links(false)
.into_iter()
.filter_entry(should_descend_for_gitignore)
.filter_map(Result::ok)
.any(|entry| {
entry.file_type().is_file() && entry.file_name().to_string_lossy() == ".gitignore"
})
}
fn run_git_check_ignore(vault_path: &Path, relative_paths: &[String]) -> Option<String> {
let mut child = crate::hidden_command("git")
.args(["check-ignore", "--no-index", "--stdin"])
.current_dir(vault_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
let mut stdin = child.stdin.take()?;
let paths = relative_paths.to_vec();
let writer = std::thread::spawn(move || -> std::io::Result<()> {
for path in paths {
writeln!(stdin, "{path}")?;
}
Ok(())
});
let output = child.wait_with_output().ok()?;
writer.join().ok()?.ok()?;
if output.status.success() || output.status.code() == Some(1) {
return Some(String::from_utf8_lossy(&output.stdout).to_string());
}
None
}
fn ignored_relative_paths(vault_path: &Path, relative_paths: &[String]) -> HashSet<String> {
if relative_paths.is_empty() || !has_gitignore_file(vault_path) {
return HashSet::new();
}
let mut candidates = relative_paths
.iter()
.map(|path| normalize_relative_path(path))
.filter(|path| !path.is_empty())
.collect::<Vec<_>>();
candidates.sort();
candidates.dedup();
run_git_check_ignore(vault_path, &candidates)
.unwrap_or_default()
.lines()
.map(normalize_relative_path)
.filter(|path| !path.is_empty())
.collect()
}
fn filter_gitignored_items<T>(
vault_path: &Path,
items: Vec<T>,
hide_enabled: bool,
relative_for: impl Fn(&T) -> Option<String>,
) -> Vec<T> {
if !hide_enabled || items.is_empty() {
return items;
}
let relative_paths = items.iter().filter_map(&relative_for).collect::<Vec<_>>();
let ignored = ignored_relative_paths(vault_path, &relative_paths);
if ignored.is_empty() {
return items;
}
items
.into_iter()
.filter(|item| {
relative_for(item)
.map(|relative| !ignored.contains(&relative))
.unwrap_or(true)
})
.collect()
}
pub fn filter_gitignored_paths(
vault_path: &Path,
paths: Vec<PathBuf>,
hide_enabled: bool,
) -> Vec<PathBuf> {
filter_gitignored_items(vault_path, paths, hide_enabled, |path| {
relative_path(vault_path, path)
})
}
pub fn filter_gitignored_entries(
vault_path: &Path,
entries: Vec<VaultEntry>,
hide_enabled: bool,
) -> Vec<VaultEntry> {
filter_gitignored_items(vault_path, entries, hide_enabled, |entry| {
relative_path(vault_path, Path::new(&entry.path))
})
}
fn collect_folder_queries(nodes: &[FolderNode], queries: &mut Vec<String>) {
for node in nodes {
let relative = normalize_relative_path(&node.path);
if !relative.is_empty() {
queries.push(relative.clone());
queries.push(format!("{relative}/"));
}
collect_folder_queries(&node.children, queries);
}
}
fn path_or_parent_is_ignored(relative_path: &str, ignored: &HashSet<String>) -> bool {
if ignored.contains(relative_path) {
return true;
}
let mut current = relative_path;
while let Some((parent, _)) = current.rsplit_once('/') {
if ignored.contains(parent) {
return true;
}
current = parent;
}
false
}
fn filter_folder_nodes(nodes: Vec<FolderNode>, ignored: &HashSet<String>) -> Vec<FolderNode> {
nodes
.into_iter()
.filter_map(|mut node| {
let relative = normalize_relative_path(&node.path);
if path_or_parent_is_ignored(&relative, ignored) {
return None;
}
node.children = filter_folder_nodes(node.children, ignored);
Some(node)
})
.collect()
}
pub fn filter_gitignored_folders(
vault_path: &Path,
folders: Vec<FolderNode>,
hide_enabled: bool,
) -> Vec<FolderNode> {
if !hide_enabled || folders.is_empty() {
return folders;
}
let mut queries = Vec::new();
collect_folder_queries(&folders, &mut queries);
let ignored = ignored_relative_paths(vault_path, &queries);
if ignored.is_empty() {
return folders;
}
filter_folder_nodes(folders, &ignored)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::mpsc;
use std::time::Duration;
use tempfile::TempDir;
fn write_file(root: &Path, relative: &str, content: &str) {
let path = root.join(relative);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, content).unwrap();
}
fn init_git_repo(root: &Path) {
crate::hidden_command("git")
.args(["init"])
.current_dir(root)
.output()
.unwrap();
}
fn entry(root: &Path, relative: &str) -> VaultEntry {
VaultEntry {
path: root.join(relative).to_string_lossy().to_string(),
filename: relative.rsplit('/').next().unwrap().to_string(),
title: relative.to_string(),
..VaultEntry::default()
}
}
fn entry_paths(root: &Path, entries: &[VaultEntry]) -> Vec<String> {
entries
.iter()
.map(|entry| relative_path(root, Path::new(&entry.path)).unwrap())
.collect()
}
#[test]
fn filters_ignored_entries_with_git_style_negation() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
write_file(dir.path(), ".gitignore", "ignored/*\n!ignored/keep.md\n");
write_file(dir.path(), "visible.md", "# Visible\n");
write_file(dir.path(), "ignored/hidden.md", "# Hidden\n");
write_file(dir.path(), "ignored/keep.md", "# Keep\n");
let filtered = filter_gitignored_entries(
dir.path(),
vec![
entry(dir.path(), "visible.md"),
entry(dir.path(), "ignored/hidden.md"),
entry(dir.path(), "ignored/keep.md"),
],
true,
);
assert_eq!(
entry_paths(dir.path(), &filtered),
vec!["visible.md", "ignored/keep.md"]
);
}
#[test]
fn keeps_ignored_entries_when_visibility_is_enabled() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
write_file(dir.path(), ".gitignore", "ignored/\n");
let entries = vec![entry(dir.path(), "ignored/hidden.md")];
let filtered = filter_gitignored_entries(dir.path(), entries, false);
assert_eq!(
entry_paths(dir.path(), &filtered),
vec!["ignored/hidden.md"]
);
}
#[test]
fn filters_ignored_folder_trees() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
write_file(dir.path(), ".gitignore", "generated/\n");
fs::create_dir_all(dir.path().join("generated/nested")).unwrap();
fs::create_dir_all(dir.path().join("notes")).unwrap();
let folders = vec![
FolderNode {
name: "generated".to_string(),
path: "generated".to_string(),
children: vec![FolderNode {
name: "nested".to_string(),
path: "generated/nested".to_string(),
children: vec![],
}],
},
FolderNode {
name: "notes".to_string(),
path: "notes".to_string(),
children: vec![],
},
];
let filtered = filter_gitignored_folders(dir.path(), folders, true);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].path, "notes");
}
#[test]
fn filters_large_ignored_folder_sets_without_blocking_on_git_stdout() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
write_file(dir.path(), ".gitignore", "generated/\n");
let folders = (0..6_000)
.map(|index| FolderNode {
name: format!("package-{index}"),
path: format!("generated/package-{index}"),
children: vec![],
})
.collect::<Vec<_>>();
let vault_path = dir.path().to_path_buf();
let (sender, receiver) = mpsc::channel();
std::thread::spawn(move || {
let filtered = filter_gitignored_folders(vault_path.as_path(), folders, true);
let _ = sender.send(filtered);
drop(dir);
});
let filtered = receiver
.recv_timeout(Duration::from_secs(5))
.expect("large gitignored folder filtering should not block on child stdout");
assert!(filtered.is_empty());
}
#[test]
fn has_no_effect_without_gitignore_file() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
let entries = vec![entry(dir.path(), "notes/local.md")];
let filtered = filter_gitignored_entries(dir.path(), entries, true);
assert_eq!(entry_paths(dir.path(), &filtered), vec!["notes/local.md"]);
}
#[cfg(unix)]
#[test]
fn filters_entries_with_real_paths_when_vault_root_is_symlinked() {
let dir = TempDir::new().unwrap();
let real_root = dir.path().join("real-vault");
let symlink_root = dir.path().join("linked-vault");
fs::create_dir_all(&real_root).unwrap();
std::os::unix::fs::symlink(&real_root, &symlink_root).unwrap();
init_git_repo(&symlink_root);
write_file(&real_root, ".gitignore", "tmp/\n");
write_file(&real_root, "visible.md", "# Visible\n");
write_file(&real_root, "tmp/hidden.md", "# Hidden\n");
let filtered = filter_gitignored_entries(
&symlink_root,
vec![
entry(&real_root, "visible.md"),
entry(&real_root, "tmp/hidden.md"),
],
true,
);
assert_eq!(entry_paths(&real_root, &filtered), vec!["visible.md"]);
}
}

View file

@ -0,0 +1,196 @@
use std::fs;
use std::path::Path;
use std::time::UNIX_EPOCH;
/// Check if a character is safe for use in filenames (alphanumeric, dot, dash, underscore).
fn is_safe_filename_char(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '.' | '-' | '_')
}
/// Sanitize a filename by replacing unsafe characters with underscores.
fn sanitize_filename(name: &str) -> String {
name.chars()
.map(|c| if is_safe_filename_char(c) { c } else { '_' })
.collect()
}
/// Image file extensions considered valid for drag-drop import.
const IMAGE_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"];
/// Prepare the attachments directory and generate a unique target path.
fn prepare_attachment_path(vault_path: &str, filename: &str) -> Result<std::path::PathBuf, String> {
let attachments_dir = Path::new(vault_path).join("attachments");
fs::create_dir_all(&attachments_dir)
.map_err(|e| format!("Failed to create attachments directory: {}", e))?;
let timestamp = std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let unique_name = format!("{}-{}", timestamp, sanitize_filename(filename));
Ok(attachments_dir.join(unique_name))
}
/// Save an uploaded image to the vault's attachments directory.
/// Returns the absolute path to the saved file.
pub fn save_image(vault_path: &str, filename: &str, data: &str) -> Result<String, String> {
use base64::Engine;
let target_path = prepare_attachment_path(vault_path, filename)?;
let bytes = base64::engine::general_purpose::STANDARD
.decode(data)
.map_err(|e| format!("Invalid base64 data: {}", e))?;
fs::write(&target_path, bytes).map_err(|e| format!("Failed to write image: {}", e))?;
Ok(target_path.to_string_lossy().to_string())
}
/// Copy an image file from a source path into the vault's attachments directory.
/// Used for Tauri native drag-drop which provides absolute file paths.
/// Returns the absolute path to the saved file.
pub fn copy_image_to_vault(vault_path: &str, source_path: &str) -> Result<String, String> {
let source = Path::new(source_path);
if !source.exists() {
return Err(format!("Source file does not exist: {}", source_path));
}
let ext = source
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if !IMAGE_EXTENSIONS.contains(&ext.as_str()) {
return Err(format!("Not a supported image format: {}", source_path));
}
let filename = source
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("image");
let target_path = prepare_attachment_path(vault_path, filename)?;
fs::copy(source, &target_path).map_err(|e| format!("Failed to copy image: {}", e))?;
Ok(target_path.to_string_lossy().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_sanitize_filename_safe_chars() {
assert_eq!(sanitize_filename("photo.png"), "photo.png");
assert_eq!(sanitize_filename("my-image_01.jpg"), "my-image_01.jpg");
}
#[test]
fn test_sanitize_filename_unsafe_chars() {
assert_eq!(sanitize_filename("my file (1).png"), "my_file__1_.png");
assert_eq!(sanitize_filename("path/to/img.png"), "path_to_img.png");
}
#[test]
fn test_save_image_creates_file() {
use base64::Engine;
let dir = TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let data = base64::engine::general_purpose::STANDARD.encode(b"fake image data");
let result = save_image(vault_path, "test.png", &data);
assert!(result.is_ok());
let saved_path = result.unwrap();
assert!(std::path::Path::new(&saved_path).exists());
assert!(saved_path.contains("attachments"));
assert!(saved_path.contains("test.png"));
let content = fs::read(&saved_path).unwrap();
assert_eq!(content, b"fake image data");
}
#[test]
fn test_save_image_creates_attachments_dir() {
use base64::Engine;
let dir = TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let attachments = dir.path().join("attachments");
assert!(!attachments.exists());
let data = base64::engine::general_purpose::STANDARD.encode(b"test");
save_image(vault_path, "img.png", &data).unwrap();
assert!(attachments.exists());
}
#[test]
fn test_save_image_invalid_base64() {
let dir = TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let result = save_image(vault_path, "test.png", "not-valid-base64!!!");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid base64"));
}
#[test]
fn test_copy_image_to_vault_success() {
let dir = TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
// Create a source image file
let source_path = dir.path().join("source.png");
fs::write(&source_path, b"fake png data").unwrap();
let result = copy_image_to_vault(vault_path, source_path.to_str().unwrap());
assert!(result.is_ok());
let saved_path = result.unwrap();
assert!(std::path::Path::new(&saved_path).exists());
assert!(saved_path.contains("attachments"));
assert!(saved_path.contains("source.png"));
let content = fs::read(&saved_path).unwrap();
assert_eq!(content, b"fake png data");
}
#[test]
fn test_copy_image_to_vault_nonexistent_source() {
let dir = TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let result = copy_image_to_vault(vault_path, "/nonexistent/photo.png");
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
}
#[test]
fn test_copy_image_to_vault_rejects_non_image() {
let dir = TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let source_path = dir.path().join("document.pdf");
fs::write(&source_path, b"fake pdf").unwrap();
let result = copy_image_to_vault(vault_path, source_path.to_str().unwrap());
assert!(result.is_err());
assert!(result.unwrap_err().contains("Not a supported image"));
}
#[test]
fn test_copy_image_to_vault_accepts_all_extensions() {
let dir = TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
for ext in &["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"] {
let source_path = dir.path().join(format!("img.{}", ext));
fs::write(&source_path, b"data").unwrap();
let result = copy_image_to_vault(vault_path, source_path.to_str().unwrap());
assert!(result.is_ok(), "failed for extension: {}", ext);
}
}
}

View file

@ -0,0 +1,250 @@
use std::fs;
use std::path::Path;
use walkdir::WalkDir;
const LEGACY_IS_A_PREFIXES: [&str; 4] = ["is_a:", "\"Is A\":", "'Is A':", "Is A:"];
fn has_legacy_is_a(fm_content: &str) -> bool {
fm_content.lines().any(|line| {
let t = line.trim_start();
LEGACY_IS_A_PREFIXES
.iter()
.any(|prefix| t.starts_with(prefix))
})
}
/// Extract the value from a legacy `is_a` / `Is A` line.
fn extract_is_a_value(line: &str) -> Option<&str> {
let t = line.trim_start();
for prefix in &LEGACY_IS_A_PREFIXES {
if let Some(rest) = t.strip_prefix(prefix) {
let v = rest.trim();
return Some(v);
}
}
None
}
fn frontmatter_content(content: &str) -> Option<(usize, &str)> {
if !content.starts_with("---\n") {
return None;
}
let fm_end = content[4..].find("\n---")? + 4;
Some((fm_end, &content[4..fm_end]))
}
fn has_type_field(fm_content: &str) -> bool {
fm_content
.lines()
.any(|line| line.trim_start().starts_with("type:"))
}
fn migrated_frontmatter_lines(fm_content: &str) -> Option<Vec<String>> {
if !has_legacy_is_a(fm_content) {
return None;
}
let mut new_lines = Vec::new();
let mut is_a_value = None;
for line in fm_content.lines() {
match extract_is_a_value(line) {
Some(value) => is_a_value = Some(value.to_string()),
None => new_lines.push(line.to_string()),
}
}
let value = is_a_value?;
if !has_type_field(fm_content) {
new_lines.insert(0, format!("type: {}", value));
}
Some(new_lines)
}
/// Migrate a single file's frontmatter from `is_a`/`Is A` to `type`.
/// Returns Ok(true) if the file was modified, Ok(false) if no migration needed.
fn migrate_file_is_a_to_type(path: &Path) -> Result<bool, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let Some((fm_end, fm_content)) = frontmatter_content(&content) else {
return Ok(false);
};
let Some(new_lines) = migrated_frontmatter_lines(fm_content) else {
return Ok(false);
};
let rest = &content[fm_end + 4..];
let new_content = format!("---\n{}\n---{}", new_lines.join("\n"), rest);
fs::write(path, &new_content)
.map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
Ok(true)
}
/// Migrate all markdown files in the vault from `is_a`/`Is A` to `type`.
/// Returns the number of files migrated.
pub fn migrate_is_a_to_type(vault_path: &str) -> Result<usize, String> {
let vault = Path::new(vault_path);
if !vault.exists() || !vault.is_dir() {
return Err(format!(
"Vault path does not exist or is not a directory: {}",
vault_path
));
}
let mut migrated = 0;
for entry in WalkDir::new(vault)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() || path.extension().map(|ext| ext != "md").unwrap_or(true) {
continue;
}
match migrate_file_is_a_to_type(path) {
Ok(true) => {
log::info!("Migrated is_a → type: {}", path.display());
migrated += 1;
}
Ok(false) => {}
Err(e) => {
log::warn!("Failed to migrate {}: {}", path.display(), e);
}
}
}
Ok(migrated)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn write_file(dir: &std::path::Path, name: &str, content: &str) -> std::path::PathBuf {
let path = dir.join(name);
fs::write(&path, content).unwrap();
path
}
fn assert_file_migration_result(content: &str, expected: bool) {
let tmp = tempdir().unwrap();
let path = write_file(tmp.path(), "note.md", content);
let result = migrate_file_is_a_to_type(&path).unwrap();
assert_eq!(result, expected);
}
// --- has_legacy_is_a ---
#[test]
fn test_has_legacy_is_a_detects_is_a_colon() {
assert!(has_legacy_is_a("is_a: Person\nname: Alice"));
}
#[test]
fn test_has_legacy_is_a_detects_quoted_is_a() {
assert!(has_legacy_is_a("\"Is A\": Note\nname: Test"));
}
#[test]
fn test_has_legacy_is_a_detects_bare_is_a() {
assert!(has_legacy_is_a("Is A: Topic\n"));
}
#[test]
fn test_has_legacy_is_a_returns_false_for_clean_frontmatter() {
assert!(!has_legacy_is_a("type: Person\nname: Alice"));
}
// --- extract_is_a_value ---
#[test]
fn test_extract_is_a_value_from_is_a_colon() {
assert_eq!(extract_is_a_value("is_a: Person"), Some("Person"));
}
#[test]
fn test_extract_is_a_value_from_quoted() {
assert_eq!(extract_is_a_value("\"Is A\": Note"), Some("Note"));
}
#[test]
fn test_extract_is_a_value_returns_none_for_unrelated_line() {
assert_eq!(extract_is_a_value("name: Alice"), None);
}
// --- migrate_file_is_a_to_type ---
#[test]
fn test_migrate_file_adds_type_and_removes_is_a() {
let tmp = tempdir().unwrap();
let path = write_file(
tmp.path(),
"note.md",
"---\nis_a: Person\nname: Alice\n---\n# Alice\n",
);
let result = migrate_file_is_a_to_type(&path).unwrap();
assert!(result, "file should be migrated");
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("type: Person"), "should have type field");
assert!(!content.contains("is_a:"), "should not have is_a field");
}
#[test]
fn test_migrate_file_skips_when_no_frontmatter() {
assert_file_migration_result("# Just a heading\nNo frontmatter here.\n", false);
}
#[test]
fn test_migrate_file_skips_when_already_has_type() {
assert_file_migration_result("---\ntype: Person\nname: Alice\n---\n# Alice\n", false);
}
#[test]
fn test_migrate_file_skips_when_no_is_a_field() {
assert_file_migration_result("---\nname: Alice\ndate: 2024-01-01\n---\n# Alice\n", false);
}
// --- migrate_is_a_to_type (public function) ---
#[test]
fn test_migrate_vault_returns_count_of_migrated_files() {
let tmp = tempdir().unwrap();
write_file(
tmp.path(),
"note1.md",
"---\nis_a: Person\nname: Alice\n---\n",
);
write_file(tmp.path(), "note2.md", "---\nis_a: Topic\nname: AI\n---\n");
write_file(
tmp.path(),
"note3.md",
"---\ntype: Event\nname: Conf\n---\n",
);
let count = migrate_is_a_to_type(tmp.path().to_str().unwrap()).unwrap();
assert_eq!(count, 2, "should migrate exactly 2 files");
}
#[test]
fn test_migrate_vault_returns_error_for_nonexistent_path() {
let result = migrate_is_a_to_type("/tmp/this-path-does-not-exist-laputa-test");
assert!(result.is_err());
}
#[test]
fn test_migrate_vault_ignores_non_markdown_files() {
let tmp = tempdir().unwrap();
write_file(tmp.path(), "image.png", "not a markdown file");
write_file(tmp.path(), "data.json", "{\"is_a\": \"test\"}");
let count = migrate_is_a_to_type(tmp.path().to_str().unwrap()).unwrap();
assert_eq!(count, 0, "non-markdown files should be ignored");
}
}

View file

@ -0,0 +1,512 @@
mod cache;
mod config_seed;
mod entry;
mod file;
pub(crate) mod filename_rules;
mod folders;
mod frontmatter;
mod getting_started;
mod ignored;
mod image;
mod migration;
mod parsing;
pub(crate) mod path_identity;
mod rename;
mod rename_transaction;
mod title_sync;
mod trash;
mod type_templates;
mod view_date_filters;
mod view_migration;
mod view_relationships;
#[cfg(test)]
mod view_tests;
mod view_value_conversions;
mod views;
pub use cache::{invalidate_cache, scan_vault_cached};
pub use config_seed::{
get_ai_guidance_status, migrate_agents_md, repair_config_files, restore_ai_guidance_files,
seed_config_files, AiGuidanceFileState, WorkspaceAiGuidanceStatus,
};
pub use entry::{FolderNode, VaultEntry};
pub use file::{create_note_content, get_note_content, note_content_matches, save_note_content};
pub use folders::{delete_folder, rename_folder, FolderRenameResult};
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
pub use ignored::{filter_gitignored_entries, filter_gitignored_folders, filter_gitignored_paths};
pub use image::{copy_image_to_vault, save_image};
pub use migration::migrate_is_a_to_type;
pub use rename::{
auto_rename_untitled, detect_renames, move_note_to_folder, move_note_to_workspace, rename_note,
rename_note_filename, update_wikilinks_for_renames, AutoRenameUntitledRequest, DetectedRename,
MoveNoteToFolderRequest, MoveNoteToWorkspaceRequest, RenameNoteFilenameRequest,
RenameNoteRequest, RenameResult,
};
pub use title_sync::{sync_title_on_open, SyncAction};
pub use trash::{batch_delete_notes, delete_note};
pub use views::{
delete_view, evaluate_view, save_view, scan_views, FilterCondition, FilterGroup, FilterNode,
FilterOp, ViewDefinition, ViewFile,
};
use file::read_file_metadata;
use frontmatter::{extract_fm_and_rels, resolve_is_a, resolve_note_display, resolve_note_width};
use parsing::{count_body_words, extract_outgoing_links, extract_snippet, extract_title};
use type_templates::TypeTemplateSource;
use gray_matter::engine::YAML;
use gray_matter::Matter;
use std::fs;
use std::path::Path;
use walkdir::WalkDir;
fn preferred_relationship_refs(
relationships: &std::collections::HashMap<String, Vec<String>>,
canonical_key: &str,
legacy_key: &str,
) -> Vec<String> {
relationships
.get(canonical_key)
.cloned()
.or_else(|| relationships.get(legacy_key).cloned())
.unwrap_or_default()
}
pub(crate) fn derive_markdown_title_from_content(content: &str, filename: &str) -> String {
let matter = Matter::<YAML>::new();
let parsed = matter.parse(content);
let (frontmatter, _, _) = extract_fm_and_rels(parsed.data, content);
extract_title(frontmatter.title.as_deref(), content, filename)
}
fn resolve_entry_dates(
fs_modified: Option<u64>,
fs_created: Option<u64>,
git_dates: Option<(u64, u64)>,
) -> (Option<u64>, Option<u64>) {
match git_dates {
Some((git_modified, git_created)) => {
let modified_at = Some(fs_modified.map_or(git_modified, |fs| fs.max(git_modified)));
(modified_at, Some(git_created))
}
None => (fs_modified, fs_created),
}
}
/// Parse a single markdown file into a VaultEntry.
///
/// If `git_dates` is provided, `created_at` comes from git history while
/// `modified_at` uses the newer of the latest git touch and the current
/// filesystem modified time. Pass `None` to use filesystem dates only
/// (appropriate for non-git vaults).
pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<VaultEntry, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let filename = path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let matter = Matter::<YAML>::new();
let parsed = matter.parse(&content);
let (frontmatter, mut relationships, properties) = extract_fm_and_rels(parsed.data, &content);
let title = derive_markdown_title_from_content(&content, &filename);
let has_h1 = parsing::extract_h1_title(&content).is_some();
let snippet = extract_snippet(&content);
let word_count = count_body_words(&content);
let outgoing_links = extract_outgoing_links(&parsed.content);
let (fs_modified, fs_created, file_size) = read_file_metadata(path)?;
let (modified_at, created_at) = resolve_entry_dates(fs_modified, fs_created, git_dates);
let is_a = resolve_is_a(frontmatter.is_a);
let template = TypeTemplateSource {
explicit_template: frontmatter
.template
.map(|value| value.into_scalar().unwrap_or_default()),
is_a: is_a.as_deref(),
title: &title,
body: &parsed.content,
}
.resolve();
// 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" {
let type_link = if type_name.starts_with("[[") && type_name.ends_with("]]") {
type_name.clone()
} else {
format!("[[{}]]", type_name.to_lowercase())
};
relationships.insert("Type".to_string(), vec![type_link]);
}
}
let belongs_to = preferred_relationship_refs(&relationships, "belongs_to", "Belongs to");
let related_to = preferred_relationship_refs(&relationships, "related_to", "Related to");
Ok(VaultEntry {
path: path.to_string_lossy().to_string(),
filename,
title,
is_a,
snippet,
relationships,
aliases: frontmatter
.aliases
.map(|a| a.into_vec())
.unwrap_or_default(),
belongs_to,
related_to,
status: frontmatter.status.and_then(|v| v.into_scalar()),
archived: frontmatter.archived.unwrap_or(false),
modified_at,
created_at,
file_size,
icon: frontmatter.icon.and_then(|v| v.into_scalar()),
color: frontmatter.color.and_then(|v| v.into_scalar()),
order: frontmatter.order,
sidebar_label: frontmatter.sidebar_label.and_then(|v| v.into_scalar()),
template,
sort: frontmatter.sort.and_then(|v| v.into_scalar()),
view: frontmatter.view.and_then(|v| v.into_scalar()),
note_width: resolve_note_width(frontmatter.note_width),
display: resolve_note_display(frontmatter.display),
visible: frontmatter.visible,
organized: frontmatter.organized.unwrap_or(false),
favorite: frontmatter.favorite.unwrap_or(false),
favorite_index: frontmatter.favorite_index,
list_properties_display: frontmatter.list_properties_display.unwrap_or_default(),
word_count,
outgoing_links,
properties,
has_h1,
file_kind: "markdown".to_string(),
})
}
/// Parse a non-markdown file into a minimal VaultEntry.
/// Uses filename as title, except for `.yml` files where the YAML `name` field is used.
pub(crate) fn parse_non_md_file(
path: &Path,
git_dates: Option<(u64, u64)>,
) -> Result<VaultEntry, String> {
let filename = path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let (fs_modified, fs_created, file_size) = read_file_metadata(path)?;
let (modified_at, created_at) = resolve_entry_dates(fs_modified, fs_created, git_dates);
let file_kind = classify_file_kind(path).to_string();
let title = extract_yml_name(path).unwrap_or_else(|| filename.clone());
Ok(VaultEntry {
path: path.to_string_lossy().to_string(),
filename: filename.clone(),
title,
file_kind,
modified_at,
created_at,
file_size,
..VaultEntry::default()
})
}
/// For `.yml` files, try to extract the `name` field from the YAML content.
fn extract_yml_name(path: &Path) -> Option<String> {
let ext = path.extension()?.to_str()?;
if ext != "yml" && ext != "yaml" {
return None;
}
let content = std::fs::read_to_string(path).ok()?;
let mapping: serde_yaml::Value = serde_yaml::from_str(&content).ok()?;
mapping.get("name")?.as_str().map(|s| s.to_string())
}
/// Re-read a single file from disk and return a fresh VaultEntry.
/// Uses filesystem dates (no git lookup) since the file was likely just saved.
pub fn reload_entry(path: &Path) -> Result<VaultEntry, String> {
if !path.exists() {
return Err(format!("File does not exist: {}", path.display()));
}
if is_md_file(path) {
parse_md_file(path, None)
} else {
parse_non_md_file(path, None)
}
}
/// Directories hidden from user-facing vault scans.
const HIDDEN_DIRS: &[&str] = &[".git", ".laputa", ".DS_Store"];
/// Keep type definitions in their dedicated sidebar section instead of the generic folder tree.
const FOLDER_TREE_EXCLUDED_DIRS: &[&str] = &["type"];
fn is_hidden_dir(name: &str) -> bool {
name.starts_with('.') || HIDDEN_DIRS.contains(&name)
}
fn is_folder_tree_hidden_dir(name: &str) -> bool {
is_hidden_dir(name) || FOLDER_TREE_EXCLUDED_DIRS.contains(&name)
}
pub(crate) fn is_md_file(path: &Path) -> bool {
path.is_file() && path.extension().is_some_and(|ext| ext == "md")
}
/// Extensions recognized as editable text files (opened in raw editor).
const TEXT_EXTENSIONS: &[&str] = &[
"yml",
"yaml",
"json",
"txt",
"toml",
"csv",
"xml",
"html",
"htm",
"css",
"scss",
"less",
"ts",
"tsx",
"js",
"jsx",
"py",
"rs",
"sh",
"bash",
"zsh",
"fish",
"rb",
"go",
"java",
"kt",
"c",
"cpp",
"h",
"hpp",
"swift",
"lua",
"sql",
"graphql",
"env",
"ini",
"cfg",
"conf",
"properties",
"makefile",
"dockerfile",
"gitignore",
"editorconfig",
"mdx",
"svelte",
"vue",
"astro",
"tf",
"hcl",
"nix",
"zig",
"hs",
"ml",
"ex",
"exs",
"erl",
"clj",
"lisp",
"el",
"vim",
"r",
"jl",
"ps1",
"bat",
"cmd",
];
/// Classify a file extension into "markdown", "text", or "binary".
pub(crate) fn classify_file_kind(path: &Path) -> &'static str {
let ext = match path.extension() {
Some(e) => e.to_string_lossy().to_lowercase(),
None => {
// Files without extension: check if name itself is a known text file
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_lowercase())
.unwrap_or_default();
return if [
"makefile",
"dockerfile",
"rakefile",
"gemfile",
"procfile",
"brewfile",
".gitignore",
".gitattributes",
".editorconfig",
".env",
]
.contains(&name.as_str())
{
"text"
} else {
"binary"
};
}
};
if ext == "md" || ext == "markdown" {
"markdown"
} else if TEXT_EXTENSIONS.contains(&ext.as_str()) {
"text"
} else {
"binary"
}
}
use crate::git::GitDates;
use std::collections::HashMap;
fn lookup_git_dates(
path: &Path,
vault_path: &Path,
git_dates: &HashMap<String, GitDates>,
) -> Option<(u64, u64)> {
let rel = path_identity::vault_relative_path_string(vault_path, path).ok()?;
git_dates.get(&rel).map(|d| (d.modified_at, d.created_at))
}
fn try_parse_file(
path: &Path,
vault_path: &Path,
git_dates: &HashMap<String, GitDates>,
entries: &mut Vec<VaultEntry>,
) {
let dates = lookup_git_dates(path, vault_path, git_dates);
let result = if is_md_file(path) {
parse_md_file(path, dates)
} else {
parse_non_md_file(path, dates)
};
match result {
Ok(vault_entry) => entries.push(vault_entry),
Err(e) => log::warn!("Skipping file: {}", e),
}
}
/// Scan all files in the vault, including subdirectories.
/// Hidden directories (starting with `.`) are excluded.
fn scan_all_files(
vault_path: &Path,
git_dates: &HashMap<String, GitDates>,
entries: &mut Vec<VaultEntry>,
) {
let walker = WalkDir::new(vault_path)
.follow_links(true)
.into_iter()
.filter_entry(|e| {
if e.file_type().is_dir() {
let name = e.file_name().to_string_lossy();
// Skip the vault root itself (depth 0) — we only filter subdirs
if e.depth() == 0 {
return true;
}
return !is_hidden_dir(&name);
}
true
});
for entry in walker.filter_map(|e| e.ok()) {
if entry.path().is_file() {
// Skip hidden files (starting with '.') — e.g. .gitignore, .DS_Store
let fname = entry.file_name().to_string_lossy();
if fname.starts_with('.') {
continue;
}
try_parse_file(entry.path(), vault_path, git_dates, entries);
}
}
}
/// Scan a directory recursively for all files and return VaultEntry for each.
/// Pass an empty map for `git_dates` to use filesystem dates only.
pub fn scan_vault(
vault_path: &Path,
git_dates: &HashMap<String, GitDates>,
) -> Result<Vec<VaultEntry>, String> {
if !vault_path.exists() {
return Err(format!(
"Vault path does not exist: {}",
vault_path.display()
));
}
if !vault_path.is_dir() {
return Err(format!(
"Vault path is not a directory: {}",
vault_path.display()
));
}
if let Err(err) = rename::recover_pending_rename_transactions(vault_path) {
log::warn!(
"Failed to recover pending rename transactions in {}: {}",
vault_path.display(),
err
);
}
let mut entries = Vec::new();
scan_all_files(vault_path, git_dates, &mut entries);
entries.sort_by_key(|entry| std::cmp::Reverse(entry.modified_at));
Ok(entries)
}
/// Build a tree of user-created folders in the vault.
pub fn scan_vault_folders(vault_path: &Path) -> Result<Vec<FolderNode>, String> {
if !vault_path.is_dir() {
return Err(format!("Not a directory: {}", vault_path.display()));
}
fn build_tree(dir: &Path, vault_root: &Path) -> Vec<FolderNode> {
let mut nodes: Vec<FolderNode> = Vec::new();
let entries = match fs::read_dir(dir) {
Ok(d) => d,
Err(_) => return nodes,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if is_folder_tree_hidden_dir(&name) {
continue;
}
let rel_path = path_identity::vault_relative_path_string(vault_root, &path)
.unwrap_or_else(|_| {
path_identity::normalize_path_for_identity(&path.to_string_lossy())
});
let children = build_tree(&path, vault_root);
nodes.push(FolderNode {
name,
path: rel_path,
children,
});
}
nodes.sort_by_key(|node| node.name.to_lowercase());
nodes
}
Ok(build_tree(vault_path, vault_path))
}
#[cfg(test)]
#[path = "frontmatter_regression_tests.rs"]
mod frontmatter_regression_tests;
#[cfg(test)]
#[path = "modified_dates_tests.rs"]
mod modified_dates_tests;
#[cfg(test)]
#[path = "relationship_key_tests.rs"]
mod relationship_key_tests;
#[cfg(test)]
#[path = "system_metadata_tests.rs"]
mod system_metadata_tests;
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;

View file

@ -0,0 +1,45 @@
use super::*;
use std::fs;
use std::io::Write;
use std::path::Path;
use tempfile::TempDir;
pub(super) 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();
}
pub(super) 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()
}
#[path = "mod_tests/archival_metadata.rs"]
mod archival_metadata;
#[path = "mod_tests/basics.rs"]
mod basics;
#[path = "mod_tests/complex_frontmatter.rs"]
mod complex_frontmatter;
#[path = "mod_tests/display_metadata.rs"]
mod display_metadata;
#[path = "mod_tests/folder_and_file_kind.rs"]
mod folder_and_file_kind;
#[path = "mod_tests/journal_type_visibility.rs"]
mod journal_type_visibility;
#[path = "mod_tests/real_vault_consistency.rs"]
mod real_vault_consistency;
#[path = "mod_tests/relationships.rs"]
mod relationships;
#[path = "mod_tests/scan_and_file_access.rs"]
mod scan_and_file_access;
#[path = "mod_tests/type_and_links.rs"]
mod type_and_links;
// 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

View file

@ -0,0 +1,217 @@
use super::*;
fn parse_archived_entry(file_name: &str, content: &str) -> VaultEntry {
let dir = TempDir::new().unwrap();
parse_test_entry(&dir, file_name, content)
}
#[test]
fn test_parse_archived_truthy_aliases() {
let cases = [
(
"old-quarter.md",
"---\narchived: true\n---\n# Old Quarter\n",
"lowercase archived alias must be parsed",
),
(
"old-quarter-2.md",
"---\nArchived: true\n---\n# Old Quarter\n",
"titlecase archived alias must be parsed",
),
(
"old.md",
"---\nArchived: Yes\n---\n# Old\n",
"Archived: Yes must be parsed as true",
),
(
"old2.md",
"---\narchived: yes\n---\n# Old\n",
"archived: yes must be parsed as true",
),
(
"old3.md",
"---\nArchived: YES\n---\n# Old\n",
"Archived: YES must be parsed as true",
),
(
"old-new.md",
"---\n_archived: true\n---\n# Old\n",
"_archived canonical key must be parsed",
),
];
for (file_name, content, message) in cases {
let entry = parse_archived_entry(file_name, content);
assert!(entry.archived, "{message}");
}
}
#[test]
fn test_parse_archived_falsy_inputs_and_absence() {
let cases = [
(
"active2.md",
"---\nArchived: No\n---\n# Active\n",
"Archived: No must be parsed as false",
),
(
"active3.md",
"---\nArchived: \"false\"\n---\n# Active\n",
"Archived: false must be parsed as false",
),
(
"active4.md",
"---\nArchived: 0\n---\n# Active\n",
"Archived: 0 must be parsed as false",
),
(
"active5.md",
"---\nIs A: Note\n---\n# Active\n",
"absent archived must default to false",
),
];
for (file_name, content, message) in cases {
let entry = parse_archived_entry(file_name, content);
assert!(!entry.archived, "{message}");
}
}
#[test]
fn test_parse_favorite_fields() {
let favorite = parse_archived_entry(
"fav.md",
"---\n_favorite: true\n_favorite_index: 3\n---\n# Fav\n",
);
assert!(favorite.favorite);
assert_eq!(favorite.favorite_index, Some(3));
let not_favorite = parse_archived_entry("not-fav.md", "---\ntype: Note\n---\n# Not Fav\n");
assert!(!not_favorite.favorite);
assert_eq!(not_favorite.favorite_index, None);
}
#[test]
fn test_parse_visible_values() {
let cases = [
(
"journal.md",
"---\ntype: Type\nvisible: false\n---\n# Journal\n",
Some(false),
),
(
"project.md",
"---\ntype: Type\nvisible: true\n---\n# Project\n",
Some(true),
),
("missing.md", "---\ntype: Type\n---\n# Project\n", None),
];
for (file_name, content, expected) in cases {
let entry = parse_archived_entry(file_name, content);
assert_eq!(
entry.visible, expected,
"unexpected visible value for {file_name}"
);
}
}
#[test]
fn test_archived_true_with_extra_non_string_fields() {
let entry = parse_archived_entry(
"archived-extra.md",
"---\nArchived: true\norder: 5\n---\n# Archived Note\n",
);
assert!(entry.archived);
}
#[test]
fn test_fallback_parser_extracts_archived_from_malformed_yaml() {
let dir = TempDir::new().unwrap();
let frontmatter = [
"---",
"type: Essay",
"Notes:",
" - \"[[slug|{\"Broken\": 'quotes'}]]\"",
"Archived: true",
"---",
"",
"# Archived Essay",
];
let content = frontmatter.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);
assert_eq!(entry.is_a, Some("Essay".to_string()));
}
#[test]
fn test_visible_not_in_relationships_or_properties() {
let entry = parse_archived_entry(
"journal.md",
"---\ntype: Type\nvisible: false\n---\n# Journal\n",
);
assert!(!entry.relationships.contains_key("visible"));
assert!(!entry.properties.contains_key("visible"));
}
#[test]
fn test_roundtrip_type_aliases_parse_correctly() {
let cases = [
("quarter/q1.md", "---\ntype: Quarter\n---\n# Q1 2026\n"),
("quarter/q1-is-a.md", "---\nIs A: Quarter\n---\n# Q1 2026\n"),
(
"quarter/q1-snake.md",
"---\nis_a: Quarter\n---\n# Q1 2026\n",
),
];
for (file_name, content) in cases {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(&dir, file_name, content);
assert_eq!(entry.is_a, Some("Quarter".to_string()));
}
}
#[test]
fn test_string_or_list_normalization_keeps_type_and_scalar_fields() {
let single_status = parse_archived_entry(
"test.md",
"---\ntype: Project\nStatus:\n - Active\n---\n# Test\n",
);
assert_eq!(single_status.status, Some("Active".to_string()));
assert_eq!(single_status.is_a, Some("Project".to_string()));
let scalar_fields = parse_archived_entry(
"scalar.md",
"---\ntype: Project\nOwner: Luca\nCadence: Daily\nStatus: Done\n---\n# Test\n",
);
assert_eq!(
scalar_fields
.properties
.get("Owner")
.and_then(|value| value.as_str()),
Some("Luca")
);
assert_eq!(
scalar_fields
.properties
.get("Cadence")
.and_then(|value| value.as_str()),
Some("Daily")
);
assert_eq!(scalar_fields.status, Some("Done".to_string()));
let absent_fields = parse_archived_entry("absent.md", "---\ntype: Note\n---\n# Test\n");
assert_eq!(absent_fields.status, None);
}
#[test]
fn test_array_field_does_not_break_type_detection() {
let entry = parse_archived_entry(
"array-fields.md",
"---\ntype: Responsibility\nOwner:\n - Luca\nCadence:\n - Weekly\nStatus:\n - Active\n---\n# My Responsibility\n",
);
assert_eq!(entry.is_a, Some("Responsibility".to_string()));
assert_eq!(entry.status, Some("Active".to_string()));
}

View file

@ -0,0 +1,150 @@
use super::*;
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_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()));
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"));
}
#[test]
fn test_parse_full_frontmatter_identity() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(&dir, "laputa.md", FULL_FM_CONTENT);
assert_eq!(entry.title, "Laputa Project");
assert_eq!(entry.is_a, Some("Project".to_string()));
assert_eq!(entry.filename, "laputa.md");
}
#[test]
fn test_parse_full_frontmatter_lists() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(&dir, "laputa.md", FULL_FM_CONTENT);
assert_eq!(entry.aliases, vec!["Laputa", "Castle in the Sky"]);
assert!(!entry.relationships.contains_key("Belongs to"));
assert!(!entry.relationships.contains_key("Related to"));
}
#[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(|value| value.as_str()),
Some("Luca")
);
assert_eq!(
entry
.properties
.get("Cadence")
.and_then(|value| value.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.",
);
assert_eq!(
(
entry.title.as_str(),
entry.aliases.is_empty(),
entry.belongs_to.is_empty(),
entry.status.as_deref(),
),
("Just a Title", true, true, 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();
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_parse_malformed_yaml() {
let dir = TempDir::new().unwrap();
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);
assert!(entry.is_ok());
}
#[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);
}

View file

@ -0,0 +1,179 @@
use super::*;
struct FrontmatterCase {
file_name: &'static str,
content: &'static str,
expected_type: &'static str,
context: &'static str,
}
const COMPLEX_NOTE_CONTENT: &str = r#"---
type: Note
_organized: true
aliases: ["My Complex Note"]
Topics: ["[[topic-writing]]", "[[topic-productivity|Productivity]]"]
"Created at": "2021-12-31T14:19:00.000Z"
Status: Published
Owner: "[[person-luca|Luca]]"
---
# My Complex Note
Content here.
"#;
const WRITING_NOTE_CONTENT: &str = r#"---
Is A: Evergreen
_organized: true
aliases: ["Writing for Clarity vs. Writing for Credit"]
Topics: ["[[topic-writing]]"]
Status: Published
"Last edited": "2024-06-15T10:30:00.000Z"
notion_id: "abc123def456"
---
# Writing for Clarity vs. Writing for Credit
Content.
"#;
const YES_NOTE_CONTENT: &str = "---\ntype: Note\n_organized: Yes\nStatus: Draft\n---\n# Test\n";
const TIMESTAMP_NOTE_CONTENT: &str =
"---\ntype: Note\n_organized: true\nCreated at: 2021-12-31T14:19:00.000Z\nTopics:\n - \"[[topic-writing]]\"\n---\n# Test\n";
const FLOW_ARRAY_CONTENT: &str = r#"---
type: Evergreen
_organized: true
Topics: ["[[topic-writing]]", "[[topic-productivity|Productivity]]"]
Has: ["[[note-one]]", "[[note-two]]", "[[note-three]]"]
---
# Test
"#;
const NOTION_IMPORT_CONTENT: &str = r#"---
type: Readings
aliases:
- "1 to 1s"
"Discarded for digest?": false
"Note Status": Saved
URL: "http://theengineeringmanager.com/management-101/121s/"
Author: James Stanier
Category: Articles
"Full Title": 1 to 1s
Highlights: 21
"Last Synced": 2025-12-10
"Last Highlighted": 2021-04-12
notion_id: 2c5bdf02-815c-81ce-9dce-eca60ddaeb08
_organized: true
---
# 1 to 1s
Content.
"#;
const BITCOIN_NOTE_CONTENT: &str = r#"---
type: Note
workspace: personal
notion_id: de48a4ad-e7ad-42aa-a5ce-1efdc259d7f9
"Created at": "2021-12-17T10:21:00.000Z"
Reviewed: True
Topics:
- "[[web3|Web3 / Crypto]]"
URL: https://studio.glassnode.com/metrics?a=BTC&category=Market%20Indicators&m=indicators.NetUnrealizedProfitLoss&s=1320105600&u=1639267199&zoom=
aliases:
- Bitcoin: Net Unrealized Profit/Loss (NUPL) - Glassnode Studio
- Note
Trashed: true
"Trashed at": 2026-03-11
_organized: true
---
# Bitcoin: Net Unrealized Profit/Loss (NUPL) - Glassnode Studio
"#;
fn complex_frontmatter_cases() -> [FrontmatterCase; 7] {
[
FrontmatterCase {
file_name: "complex-note.md",
content: COMPLEX_NOTE_CONTENT,
expected_type: "Note",
context: "type must be parsed correctly with complex frontmatter",
},
FrontmatterCase {
file_name: "writing-note.md",
content: WRITING_NOTE_CONTENT,
expected_type: "Evergreen",
context: "Is A must be parsed correctly with quoted keys nearby",
},
FrontmatterCase {
file_name: "yes-note.md",
content: YES_NOTE_CONTENT,
expected_type: "Note",
context: "_organized: Yes must be parsed as true",
},
FrontmatterCase {
file_name: "timestamp-note.md",
content: TIMESTAMP_NOTE_CONTENT,
expected_type: "Note",
context: "type must survive unquoted timestamp in sibling field",
},
FrontmatterCase {
file_name: "flow-array.md",
content: FLOW_ARRAY_CONTENT,
expected_type: "Evergreen",
context: "type must survive flow arrays with wikilinks",
},
FrontmatterCase {
file_name: "1-to-1s.md",
content: NOTION_IMPORT_CONTENT,
expected_type: "Readings",
context: "type must be parsed with Notion-style quoted keys",
},
FrontmatterCase {
file_name: "bitcoin-note.md",
content: BITCOIN_NOTE_CONTENT,
expected_type: "Note",
context: "type must be parsed correctly even with unquoted colon in alias",
},
]
}
fn assert_complex_frontmatter_case(case: FrontmatterCase) {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(&dir, case.file_name, case.content);
assert_eq!(
entry.is_a,
Some(case.expected_type.to_string()),
"{}",
case.context
);
assert!(
entry.organized,
"{} must keep _organized=true",
case.file_name
);
}
#[test]
fn test_complex_frontmatter_preserves_type_and_organized() {
for case in complex_frontmatter_cases() {
assert_complex_frontmatter_case(case);
}
}
#[test]
fn test_fallback_parser_extracts_type_and_organized() {
use super::frontmatter::extract_fm_and_rels;
let raw_content =
"---\ntype: Note\n_organized: true\nBroken: value: with: colons\n---\n# Test\n";
let (frontmatter, _, _) = extract_fm_and_rels(None, raw_content);
assert_eq!(
frontmatter
.is_a
.as_ref()
.and_then(|value| value.clone().into_scalar()),
Some("Note".to_string())
);
assert_eq!(frontmatter.organized, Some(true));
}

View file

@ -0,0 +1,198 @@
use super::*;
use std::collections::HashMap;
fn assert_string_property(entry: &VaultEntry, key: &str, expected: &str) {
assert_eq!(
entry.properties.get(key).and_then(|value| value.as_str()),
Some(expected),
"unexpected value for {key}"
);
}
#[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_display_from_note_entry() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Project\n_display: sheet\n---\nMetric,January\n";
let entry = parse_test_entry(&dir, "project.md", content);
assert_eq!(entry.display, Some("sheet".to_string()));
}
#[test]
fn test_parse_display_text_from_note_entry() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Project\n_display: text\n---\nMetric,January\n";
let entry = parse_test_entry(&dir, "project.md", content);
assert_eq!(entry.display, Some("text".to_string()));
}
#[test]
fn test_display_metadata_not_in_properties_or_relationships() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Project\n_display: sheet\n---\nMetric,January\n";
let entry = parse_test_entry(&dir, "project.md", content);
assert!(!entry.properties.contains_key("_display"));
assert!(!entry.relationships.contains_key("_display"));
}
#[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.contains_key("sidebar label"));
}
#[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 template = entry.template.unwrap();
assert!(template.contains("## Objective"));
assert!(template.contains("## Timeline"));
}
#[test]
fn test_parse_template_from_type_body_when_it_looks_like_a_template() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\n---\n\n# Book\n\nTitle:\nAuthor:\n\n## Summary\n";
let entry = parse_test_entry(&dir, "book.md", content);
assert_eq!(
entry.template,
Some("Title:\nAuthor:\n\n## Summary".to_string())
);
}
#[test]
fn test_descriptive_type_body_is_not_a_template() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\n---\n\n# Project\n\nProjects are time-bound efforts with a clear outcome.\n";
let entry = parse_test_entry(&dir, "project.md", content);
assert_eq!(entry.template, None);
}
#[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.contains_key("template"));
}
#[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.contains_key("sort"));
}
#[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.contains_key("sort"));
}
#[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);
assert_eq!(entry.properties.len(), 3);
for (key, value) in [
("Priority", "High"),
("Owner", "Luca"),
("Cadence", "Weekly"),
] {
assert_string_property(&entry, key, value);
}
}

View file

@ -0,0 +1,122 @@
use super::*;
use std::path::Path;
#[test]
fn test_scan_vault_folders_returns_tree() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("projects/laputa")).unwrap();
std::fs::create_dir_all(dir.path().join("areas")).unwrap();
let folders = scan_vault_folders(dir.path()).unwrap();
let names: Vec<&str> = folders.iter().map(|folder| folder.name.as_str()).collect();
assert!(names.contains(&"projects"));
assert!(names.contains(&"areas"));
let projects = folders
.iter()
.find(|folder| folder.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();
std::fs::create_dir_all(dir.path().join(".git")).unwrap();
std::fs::create_dir_all(dir.path().join(".laputa")).unwrap();
std::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_keeps_default_vault_folders_visible() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("attachments")).unwrap();
std::fs::create_dir_all(dir.path().join("type")).unwrap();
std::fs::create_dir_all(dir.path().join("views")).unwrap();
std::fs::create_dir_all(dir.path().join("projects")).unwrap();
let folders = scan_vault_folders(dir.path()).unwrap();
let names: Vec<&str> = folders.iter().map(|folder| folder.name.as_str()).collect();
assert_eq!(names, vec!["attachments", "projects", "views"]);
}
#[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");
}
#[test]
fn test_list_properties_display_values_and_non_leakage() {
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"]);
assert!(!entry.properties.contains_key("_list_properties_display"));
assert!(!entry.relationships.contains_key("_list_properties_display"));
let absent = parse_test_entry(&dir, "books.md", "---\ntype: Type\n---\n# Books\n");
assert!(absent.list_properties_display.is_empty());
}
#[test]
fn test_non_markdown_files_use_expected_titles_and_kinds() {
let dir = TempDir::new().unwrap();
let named_yml_path = dir.path().join("active-projects.yml");
std::fs::write(
&named_yml_path,
"name: Active Projects\nicon: rocket\ncolor: blue\n",
)
.unwrap();
let named_yml_entry = super::parse_non_md_file(&named_yml_path, None).unwrap();
assert_eq!(named_yml_entry.title, "Active Projects");
assert_eq!(named_yml_entry.filename, "active-projects.yml");
let unnamed_yml_path = dir.path().join("config.yml");
std::fs::write(&unnamed_yml_path, "key: value\n").unwrap();
let unnamed_yml_entry = super::parse_non_md_file(&unnamed_yml_path, None).unwrap();
assert_eq!(unnamed_yml_entry.title, "config.yml");
let txt_path = dir.path().join("notes.txt");
std::fs::write(&txt_path, "some content").unwrap();
let txt_entry = super::parse_non_md_file(&txt_path, None).unwrap();
assert_eq!(txt_entry.title, "notes.txt");
create_test_file(
dir.path(),
"views/my-view.yml",
"name: My View\nicon: rocket\n",
);
let text_entry = super::parse_non_md_file(&dir.path().join("views/my-view.yml"), None).unwrap();
assert_eq!(text_entry.file_kind, "text");
assert_eq!(text_entry.title, "My View");
}
#[test]
fn test_classify_file_kind_by_extension() {
for (path, expected_kind) in [
("views/active-projects.yml", "text"),
("config.yaml", "text"),
("data.json", "text"),
("script.py", "text"),
("readme.txt", "text"),
("note.md", "markdown"),
("README.markdown", "markdown"),
("photo.png", "binary"),
("archive.zip", "binary"),
] {
assert_eq!(classify_file_kind(Path::new(path)), expected_kind, "{path}");
}
}

View file

@ -0,0 +1,38 @@
use super::*;
use std::collections::HashMap;
#[test]
fn test_scan_vault_preserves_explicit_journal_type_definition() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"journal.md",
"---\ntype: Type\nvisible: true\n---\n# Journal\n",
);
create_test_file(
dir.path(),
"2026-03-11.md",
"---\ntitle: March 11\ntype: Journal\n---\n# March 11\n",
);
let entries = scan_vault(dir.path(), &HashMap::new()).unwrap();
assert_eq!(entries.len(), 2);
let journal_type = entries
.iter()
.find(|entry| entry.filename == "journal.md")
.expect("expected the explicit Journal type file to be scanned");
assert_eq!(journal_type.title, "Journal");
assert_eq!(journal_type.is_a.as_deref(), Some("Type"));
assert_eq!(journal_type.visible, Some(true));
let journal_note = entries
.iter()
.find(|entry| entry.filename == "2026-03-11.md")
.expect("expected the Journal note to be scanned");
assert_eq!(journal_note.is_a.as_deref(), Some("Journal"));
assert_eq!(
journal_note.relationships.get("Type"),
Some(&vec!["[[journal]]".to_string()]),
);
}

View file

@ -0,0 +1,118 @@
use super::*;
use std::path::Path;
#[derive(Clone, Copy, Debug, Default)]
struct FrontmatterExpectations {
has_type_with_value: bool,
has_organized_true: bool,
}
fn frontmatter_block(content: &str) -> Option<&str> {
if !content.starts_with("---\n") {
return None;
}
let end = content[4..].find("\n---")?;
Some(&content[4..end + 4])
}
fn frontmatter_expectations(content: &str) -> Option<FrontmatterExpectations> {
let block = frontmatter_block(content)?;
let has_type_with_value = block.lines().any(|line| {
let trimmed = line.trim();
["type:", "Is A:", "is_a:"]
.iter()
.find_map(|prefix| trimmed.strip_prefix(prefix))
.is_some_and(|rest| !rest.trim().is_empty())
});
let has_organized_true = block.lines().any(|line| {
matches!(
line.trim(),
"_organized: true" | "_organized: True" | "_organized: yes" | "_organized: Yes"
)
});
if !has_type_with_value && !has_organized_true {
return None;
}
Some(FrontmatterExpectations {
has_type_with_value,
has_organized_true,
})
}
fn mismatch_messages(
path: &Path,
expectations: FrontmatterExpectations,
parsed: &VaultEntry,
) -> Vec<String> {
let mut mismatches = Vec::new();
if expectations.has_type_with_value && parsed.is_a.is_none() {
mismatches.push(format!(
"TYPE MISSING: {} (raw has type with value but parsed isA=None)",
path.display()
));
}
if expectations.has_organized_true && !parsed.organized {
mismatches.push(format!(
"ORGANIZED MISSING: {} (raw has _organized: true but parsed organized=false)",
path.display()
));
}
mismatches
}
fn parse_real_vault_mismatches(vault_path: &Path) -> Vec<String> {
let mut mismatches = Vec::new();
let walker = walkdir::WalkDir::new(vault_path)
.into_iter()
.filter_entry(|entry| !entry.file_name().to_string_lossy().starts_with('.'));
for dir_entry in walker.filter_map(|entry| entry.ok()) {
let path = dir_entry.path();
if !path.is_file() || path.extension().is_none_or(|ext| ext != "md") {
continue;
}
let Ok(content) = std::fs::read_to_string(path) else {
continue;
};
let Some(expectations) = frontmatter_expectations(&content) else {
continue;
};
match parse_md_file(path, None) {
Ok(parsed) => mismatches.extend(mismatch_messages(path, expectations, &parsed)),
Err(error) => mismatches.push(format!("PARSE ERROR: {} -> {}", path.display(), error)),
}
}
mismatches
}
#[test]
fn test_real_vault_type_and_organized_consistency() {
let vault_path = Path::new("/Users/luca/Laputa");
if !vault_path.exists() {
eprintln!("Skipping: ~/Laputa vault not found");
return;
}
let mismatches = parse_real_vault_mismatches(vault_path);
if mismatches.is_empty() {
return;
}
let summary = mismatches
.iter()
.take(20)
.cloned()
.collect::<Vec<_>>()
.join("\n");
panic!(
"Found {} parsing mismatches in real vault:\n{}",
mismatches.len(),
summary
);
}

View file

@ -0,0 +1,286 @@
use super::*;
use std::collections::HashMap;
fn assert_relationship_values(
relationships: &HashMap<String, Vec<String>>,
expected: &[(&str, &[&str])],
) {
for (key, values) in expected {
let actual = relationships.get(*key).unwrap();
let expected_values = values
.iter()
.map(|value| (*value).to_string())
.collect::<Vec<_>>();
assert_eq!(actual, &expected_values, "relationship {key} mismatch");
}
}
#[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);
assert_relationship_values(
&entry.relationships,
&[
(
"Has",
&["[[essay/foo|Foo Essay]]", "[[essay/bar|Bar Essay]]"],
),
("Topics", &["[[topic/rust]]", "[[topic/wasm]]"]),
("Type", &["[[responsibility]]"]),
],
);
}
#[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();
assert!(entry.relationships.contains_key("Owner"));
assert!(!entry.properties.contains_key("Owner"));
assert_relationship_values(
&entry.relationships,
&[("Belongs to", &["[[responsibility/grow-newsletter]]"])],
);
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();
assert_eq!(entry.relationships.len(), 1);
assert_relationship_values(&entry.relationships, &[("Type", &["[[note]]"])]);
}
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_relationships() -> 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 relationships = parse_big_project_relationships();
assert_eq!(relationships.get("Has").unwrap().len(), 2);
assert_eq!(relationships.get("Topics").unwrap().len(), 2);
assert_eq!(relationships.get("Events").unwrap().len(), 1);
}
#[test]
fn test_parse_relationships_owner_and_notes() {
let relationships = parse_big_project_relationships();
assert_eq!(relationships.get("Notes").unwrap().len(), 3);
assert!(relationships.contains_key("Owner"));
}
#[test]
fn test_parse_relationships_builtin_wikilink_fields() {
let relationships = parse_big_project_relationships();
assert_eq!(relationships.get("Related to").unwrap().len(), 1);
assert_eq!(relationships.get("Belongs to").unwrap().len(), 1);
}
#[test]
fn test_parse_relationships_skip_keys_excluded_from_generic() {
let relationships = parse_big_project_relationships();
assert!(!relationships.contains_key("Status"));
assert!(!relationships.contains_key("Is A"));
}
#[test]
fn test_parse_relationships_single_vs_array_wikilinks() {
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();
assert_relationship_values(
&entry.relationships,
&[
("Mentor", &["[[person/bob|Bob Smith]]"]),
("Reviewers", &["[[person/carol]]", "[[person/dave]]"]),
("Context", &["[[area/research]]"]),
],
);
}
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_key_relationships() -> (HashMap<String, Vec<String>>, usize) {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(&dir, "skip-keys.md", SKIP_KEYS_CONTENT);
let relationship_count = entry.relationships.len();
(entry.relationships, relationship_count)
}
#[test]
fn test_skip_keys_identity_fields_excluded() {
let (relationships, _) = parse_skip_key_relationships();
assert!(!relationships.contains_key("Is A"));
assert!(!relationships.contains_key("Aliases"));
assert!(!relationships.contains_key("Status"));
}
#[test]
fn test_skip_keys_temporal_fields_excluded() {
let (relationships, _) = parse_skip_key_relationships();
for key in ["Cadence", "Created at", "Created time"] {
assert!(relationships.contains_key(key), "missing {key}");
}
}
#[test]
fn test_skip_keys_real_relation_included() {
let (relationships, relationship_count) = parse_skip_key_relationships();
assert_eq!(relationship_count, 5);
assert_relationship_values(
&relationships,
&[
("Real Relation", &["[[note/important]]"]),
("Type", &["[[project]]"]),
],
);
for key in ["Cadence", "Created at", "Created time"] {
assert!(relationships.contains_key(key), "missing {key}");
}
}
#[test]
fn test_parse_relationships_mixed_wikilinks_and_plain_in_array() {
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();
assert_relationship_values(
&entry.relationships,
&[("References", &["[[source/paper-a]]", "[[source/paper-b]]"])],
);
}
#[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_to = entry
.relationships
.get("Belongs to")
.expect("Belongs to should exist");
assert_eq!(belongs_to.len(), 1);
}

View file

@ -0,0 +1,136 @@
use super::*;
use std::collections::HashMap;
use std::path::Path;
fn entry_filenames(entries: &[VaultEntry]) -> Vec<&str> {
entries
.iter()
.map(|entry| entry.filename.as_str())
.collect()
}
fn assert_filenames_include(entries: &[VaultEntry], expected: &[&str]) {
let filenames = entry_filenames(entries);
for filename in expected {
assert!(filenames.contains(filename), "missing {filename}");
}
}
#[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);
assert_filenames_include(
&entries,
&["root.md", "project.md", "notes.md", "not-markdown.txt"],
);
let txt_entry = entries
.iter()
.find(|entry| entry.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"
);
assert_filenames_include(&entries, &["root.md", "nested.md", "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_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_get_note_content_invalid_utf8() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("invalid.csv");
std::fs::write(&path, [0x66, 0x6f, 0x80]).unwrap();
let result = get_note_content(&path);
assert_eq!(
result.unwrap_err(),
format!("File is not valid UTF-8 text: {}", path.display())
);
}

View file

@ -0,0 +1,203 @@
use super::*;
use std::fs;
#[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");
}
#[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();
assert!(
entry.created_at.is_some(),
"created_at should come from filesystem"
);
}
#[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.contains_key("Type"));
}
#[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.contains_key("Type"));
}
#[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()));
}
#[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_parse_type_key_case_insensitive() {
for (key, expected_type) in [("Type", "Project"), ("TYPE", "Person")] {
let dir = TempDir::new().unwrap();
let content = format!("---\n{key}: {expected_type}\n---\n# Test\n");
let entry = parse_test_entry(&dir, "note/test.md", &content);
assert_eq!(entry.is_a, Some(expected_type.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);
assert_eq!(entry.relationships.len(), 2);
assert!(entry.relationships.contains_key("Has"));
assert!(entry.relationships.contains_key("Type"));
}
#[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()));
}
#[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);
}
#[test]
fn test_create_note_content_creates_parent_directory() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("new-type/briefing.md");
let content = "---\ntitle: Briefing\ntype: Note\n---\n";
assert!(!path.parent().unwrap().exists());
create_note_content(path.to_str().unwrap(), content).unwrap();
assert!(path.exists());
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
#[test]
fn test_create_note_content_rejects_existing_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("briefing.md");
fs::write(&path, "# Existing\n").unwrap();
let err = create_note_content(path.to_str().unwrap(), "# Replacement\n")
.expect_err("expected create-only write to reject collisions");
assert!(err.contains("already exists"));
assert_eq!(fs::read_to_string(&path).unwrap(), "# Existing\n");
}

View file

@ -0,0 +1,91 @@
use super::{parse_md_file, parse_non_md_file, resolve_entry_dates};
use crate::git::GitDates;
use std::collections::HashMap;
use std::fs;
use std::thread;
use std::time::Duration;
use tempfile::TempDir;
#[test]
fn resolve_entry_dates_prefers_newer_filesystem_modified_time() {
let resolved = resolve_entry_dates(Some(200), Some(50), Some((150, 25)));
assert_eq!(resolved, (Some(200), Some(25)));
}
#[test]
fn resolve_entry_dates_keeps_newer_git_modified_time() {
let resolved = resolve_entry_dates(Some(150), Some(50), Some((200, 25)));
assert_eq!(resolved, (Some(200), Some(25)));
}
#[test]
fn parse_md_file_uses_newer_filesystem_modified_time_than_git() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("note.md");
fs::write(&path, "# Note\n\nBody\n").unwrap();
let (fs_modified, _, _) = super::file::read_file_metadata(&path).unwrap();
let fs_modified = fs_modified.unwrap();
let git_created = fs_modified.saturating_sub(600);
let git_modified = fs_modified.saturating_sub(60);
let entry = parse_md_file(&path, Some((git_modified, git_created))).unwrap();
assert_eq!(entry.modified_at, Some(fs_modified));
assert_eq!(entry.created_at, Some(git_created));
}
#[test]
fn parse_non_md_file_falls_back_to_git_modified_when_filesystem_missing_newer_date() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("assets/data.txt");
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "hello").unwrap();
let (fs_modified, _, _) = super::file::read_file_metadata(&path).unwrap();
let git_modified = fs_modified.unwrap().saturating_add(60);
let git_created = git_modified.saturating_sub(600);
let entry = parse_non_md_file(&path, Some((git_modified, git_created))).unwrap();
assert_eq!(entry.modified_at, Some(git_modified));
assert_eq!(entry.created_at, Some(git_created));
}
#[test]
fn scan_vault_sorts_by_newer_of_git_and_filesystem_modified_time() {
let dir = TempDir::new().unwrap();
let older_path = dir.path().join("older-git-newer-file.md");
let newer_git_path = dir.path().join("newer-git-older-file.md");
fs::write(&newer_git_path, "# Newer Git\n\nBody\n").unwrap();
thread::sleep(Duration::from_secs(1));
fs::write(&older_path, "# Newer File\n\nBody\n").unwrap();
let (older_file_modified, _, _) = super::file::read_file_metadata(&older_path).unwrap();
let older_file_modified = older_file_modified.unwrap();
let git_dates = HashMap::from([
(
"older-git-newer-file.md".to_string(),
GitDates {
created_at: older_file_modified.saturating_sub(600),
modified_at: older_file_modified.saturating_sub(120),
},
),
(
"newer-git-older-file.md".to_string(),
GitDates {
created_at: older_file_modified.saturating_sub(700),
modified_at: older_file_modified.saturating_sub(30),
},
),
]);
let entries = super::scan_vault(dir.path(), &git_dates).unwrap();
let titles: Vec<_> = entries.iter().map(|entry| entry.title.as_str()).collect();
assert_eq!(titles, vec!["Newer File", "Newer Git"]);
}

View file

@ -0,0 +1,346 @@
//! Pure text-processing helpers for markdown content parsing.
//! Snippet extraction, markdown stripping, date parsing, and string utilities.
#[derive(Clone, Copy)]
struct TextSlice<'a>(&'a str);
impl<'a> TextSlice<'a> {
fn as_str(self) -> &'a str {
self.0
}
}
/// Derive a human-readable title from a filename stem (slug).
/// Converts hyphens to spaces and title-cases each word.
/// Example: `career-tracks-depend-on-company-shape` → `Career Tracks Depend on Company Shape`
pub(super) fn slug_to_title(stem: &str) -> String {
stem.split('-')
.filter(|s| !s.is_empty())
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(c) => {
let upper: String = c.to_uppercase().collect();
format!("{}{}", upper, chars.as_str())
}
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
/// Extract the H1 title from the first non-empty line of the body (after frontmatter).
/// Returns `None` if no H1 is found on the first non-empty line.
pub(super) fn extract_h1_title(content: &str) -> Option<String> {
let body = strip_frontmatter(TextSlice(content));
let title =
first_non_empty_line(TextSlice(body)).and_then(|line| markdown_h1_text(TextSlice(line)))?;
let stripped = strip_markdown_chars(TextSlice(title));
non_empty_trimmed(TextSlice(&stripped)).map(str::to_string)
}
fn non_empty_trimmed(value: TextSlice<'_>) -> Option<&str> {
let trimmed = value.as_str().trim();
(!trimmed.is_empty()).then_some(trimmed)
}
fn first_non_empty_line(value: TextSlice<'_>) -> Option<&str> {
value
.as_str()
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
}
fn markdown_h1_text(line: TextSlice<'_>) -> Option<&str> {
line.as_str()
.strip_prefix("# ")
.and_then(|text| non_empty_trimmed(TextSlice(text)))
}
/// Extract the display title for a note.
/// Priority: H1 on first non-empty line → frontmatter `title:` → filename-derived title.
pub(super) fn extract_title(fm_title: Option<&str>, content: &str, filename: &str) -> String {
// 1. H1 on first non-empty line of body
if let Some(h1) = extract_h1_title(content) {
return h1;
}
// 2. frontmatter title (legacy, backward compat)
if let Some(title) = fm_title {
if !title.is_empty() {
return title.to_string();
}
}
// 3. filename slug
let stem = filename.strip_suffix(".md").unwrap_or(filename);
slug_to_title(stem)
}
/// Remove YAML frontmatter (triple-dash delimited) from content.
/// The closing `---` must appear at the start of a line to avoid matching
/// occurrences inside frontmatter values (e.g. `title: foo---bar`).
fn strip_frontmatter(content: TextSlice<'_>) -> &str {
let value = content.as_str();
let Some(rest) = value.strip_prefix("---") else {
return value;
};
// Find closing `---` at the start of a line (preceded by newline)
match rest.find("\n---") {
Some(end) => {
let after = end + 4; // skip past "\n---"
rest[after..].trim_start()
}
None => value,
}
}
/// Check if a line is useful for snippet extraction (not blank, heading, code fence, or rule).
fn is_snippet_line(line: TextSlice<'_>) -> bool {
let t = line.as_str().trim();
!t.is_empty() && !t.starts_with('#') && !t.starts_with("```") && !t.starts_with("---")
}
/// Extract sub-heading text (## , ### , etc.) stripped of the `#` prefix.
fn extract_subheading_text(line: TextSlice<'_>) -> Option<&str> {
let t = line.as_str().trim();
let stripped = t.trim_start_matches('#');
if stripped.len() < t.len() && stripped.starts_with(' ') {
let text = stripped.trim();
if !text.is_empty() {
return Some(text);
}
}
None
}
/// Strip leading list markers (*, -, +, 1.) from a line.
fn strip_list_marker(line: TextSlice<'_>) -> &str {
let t = line.as_str().trim_start();
strip_unordered_marker(TextSlice(t))
.or_else(|| strip_ordered_marker(TextSlice(t)))
.unwrap_or(t)
}
/// Strip unordered list markers: "* ", "- ", "+ "
fn strip_unordered_marker(s: TextSlice<'_>) -> Option<&str> {
["* ", "- ", "+ "]
.iter()
.find_map(|prefix| s.as_str().strip_prefix(prefix))
}
/// Strip ordered list markers: "1. ", "2. ", etc.
fn strip_ordered_marker(s: TextSlice<'_>) -> Option<&str> {
let value = s.as_str();
let dot_pos = value.find(". ")?;
if dot_pos <= 3 && value[..dot_pos].chars().all(|c| c.is_ascii_digit()) {
Some(&value[dot_pos + 2..])
} else {
None
}
}
/// Truncate a string to `max_len` bytes at a valid UTF-8 boundary, appending "...".
fn truncate_with_ellipsis(s: TextSlice<'_>, max_len: usize) -> String {
let value = s.as_str();
if value.len() <= max_len {
return value.to_string();
}
let mut idx = max_len;
while idx > 0 && !value.is_char_boundary(idx) {
idx -= 1;
}
format!("{}...", &value[..idx])
}
/// Count the number of words in the note body (excluding frontmatter and H1 title).
pub(super) fn count_body_words(content: &str) -> u32 {
let without_fm = strip_frontmatter(TextSlice(content));
let body = without_h1_line(TextSlice(without_fm)).unwrap_or(without_fm);
body.split_whitespace()
.filter(|w| {
!w.chars()
.all(|c| matches!(c, '#' | '*' | '_' | '`' | '~' | '-' | '>' | '|'))
})
.count() as u32
}
/// Extract a snippet: first ~160 chars of content after frontmatter/title, stripped of markdown.
pub(super) fn extract_snippet(content: &str) -> String {
let without_fm = strip_frontmatter(TextSlice(content));
let body = without_h1_line(TextSlice(without_fm)).unwrap_or(without_fm);
let clean: String = body
.lines()
.filter(|line| is_snippet_line(TextSlice(line)))
.map(|line| strip_list_marker(TextSlice(line)))
.collect::<Vec<&str>>()
.join(" ");
let stripped = strip_markdown_chars(TextSlice(&clean));
let trimmed = stripped.trim();
if !trimmed.is_empty() {
return truncate_with_ellipsis(TextSlice(trimmed), 160);
}
// Fallback: collect sub-heading text when no paragraph content exists
let heading_text: String = body
.lines()
.filter_map(|line| extract_subheading_text(TextSlice(line)))
.collect::<Vec<&str>>()
.join(" ");
let heading_trimmed = strip_markdown_chars(TextSlice(&heading_text));
let heading_trimmed = heading_trimmed.trim();
if heading_trimmed.is_empty() {
return String::new();
}
truncate_with_ellipsis(TextSlice(heading_trimmed), 160)
}
fn without_h1_line(s: TextSlice<'_>) -> Option<&str> {
let value = s.as_str();
let mut offset = 0;
for line in value.split_inclusive('\n') {
let trimmed = line.trim_end_matches(['\r', '\n']).trim();
if trimmed.starts_with("# ") {
return Some(&value[offset + line.len()..]);
}
// If we hit non-empty non-heading content first, there's no H1 to skip
if !trimmed.is_empty() {
return None;
}
offset += line.len();
}
None
}
/// Collect chars until a delimiter, returning the collected string.
fn collect_until(chars: &mut impl Iterator<Item = char>, delimiter: char) -> String {
let mut buf = String::new();
for c in chars.by_ref() {
if c == delimiter {
break;
}
buf.push(c);
}
buf
}
/// Skip all chars until a delimiter (consuming the delimiter).
fn skip_until(chars: &mut impl Iterator<Item = char>, delimiter: char) {
for c in chars.by_ref() {
if c == delimiter {
break;
}
}
}
/// Check if a char is markdown formatting that should be stripped.
fn is_markdown_formatting(ch: char) -> bool {
matches!(ch, '*' | '_' | '`' | '~')
}
fn strip_markdown_chars(s: TextSlice<'_>) -> String {
let value = s.as_str();
let mut result = String::with_capacity(value.len());
let mut chars = value.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'[' if chars.peek() == Some(&'[') => {
process_wikilink(&mut chars, &mut result);
}
'[' => {
process_markdown_link(&mut chars, &mut result);
}
c if is_markdown_formatting(c) => {}
_ => result.push(ch),
}
}
result
}
/// Process a wikilink `[[...]]` or `[[...|display]]`, extracting the display text.
fn process_wikilink(
chars: &mut std::iter::Peekable<impl Iterator<Item = char>>,
result: &mut String,
) {
chars.next(); // consume second '['
let inner = collect_wikilink_inner(chars);
let display_text = extract_wikilink_display(&inner);
result.push_str(display_text);
}
/// Extract display text from wikilink inner content.
/// Returns the part after '|' if present, otherwise the whole inner text.
fn extract_wikilink_display(inner: &str) -> &str {
inner.find('|').map_or(inner, |idx| &inner[idx + 1..])
}
/// Process bracketed text.
/// Real markdown links `[text](url)` are unwrapped to `text`.
/// Plain bracketed text `[text]` is preserved verbatim.
fn process_markdown_link(
chars: &mut std::iter::Peekable<impl Iterator<Item = char>>,
result: &mut String,
) {
let inner = collect_until(chars, ']');
if chars.peek() == Some(&'(') {
chars.next();
skip_until(chars, ')');
result.push_str(&inner);
return;
}
result.push('[');
result.push_str(&inner);
result.push(']');
}
/// Collect chars inside a wikilink until `]]`, consuming both closing brackets.
fn collect_wikilink_inner(chars: &mut std::iter::Peekable<impl Iterator<Item = char>>) -> String {
let mut buf = String::new();
while let Some(c) = chars.next() {
if c == ']' && chars.peek() == Some(&']') {
chars.next();
break;
}
buf.push(c);
}
buf
}
/// Check if a string contains a wikilink pattern `[[...]]`.
pub(super) fn contains_wikilink(s: &str) -> bool {
s.contains("[[") && s.contains("]]")
}
/// Extract all outgoing wikilink targets from content.
/// Finds `[[target]]` and `[[target|display]]` patterns, returning just the target part.
/// Returns a sorted, deduplicated Vec of targets.
pub(super) fn extract_outgoing_links(content: &str) -> Vec<String> {
let mut links = Vec::new();
let mut search_from = 0;
let bytes = content.as_bytes();
while search_from + 3 < bytes.len() {
let Some(start) = content[search_from..].find("[[") else {
break;
};
let abs_start = search_from + start + 2;
let Some(end) = content[abs_start..].find("]]") else {
break;
};
let inner = &content[abs_start..abs_start + end];
let target = match inner.find('|') {
Some(idx) => &inner[..idx],
None => inner,
};
if !target.is_empty() {
links.push(target.to_string());
}
search_from = abs_start + end + 2;
}
links.sort();
links.dedup();
links
}
#[cfg(test)]
#[path = "parsing_tests.rs"]
mod tests;

View file

@ -0,0 +1,559 @@
use super::*;
fn text(value: &str) -> TextSlice<'_> {
TextSlice(value)
}
// --- slug_to_title tests ---
#[test]
fn test_slug_to_title_basic() {
assert_eq!(slug_to_title("career-tracks"), "Career Tracks");
}
#[test]
fn test_slug_to_title_single_word() {
assert_eq!(slug_to_title("hello"), "Hello");
}
#[test]
fn test_slug_to_title_empty() {
assert_eq!(slug_to_title(""), "");
}
#[test]
fn test_slug_to_title_e2e() {
assert_eq!(slug_to_title("e2e-test"), "E2e Test");
}
#[test]
fn test_slug_to_title_multiple_hyphens() {
assert_eq!(slug_to_title("a--b"), "A B");
}
// --- extract_h1_title tests ---
#[test]
fn test_extract_h1_title_basic() {
assert_eq!(
extract_h1_title("# Hello World\n\nBody."),
Some("Hello World".to_string())
);
}
#[test]
fn test_extract_h1_title_after_frontmatter() {
let content = "---\ntype: Note\n---\n# My Note\n\nBody.";
assert_eq!(extract_h1_title(content), Some("My Note".to_string()));
}
#[test]
fn test_extract_h1_title_with_empty_lines_before() {
let content = "---\ntype: Note\n---\n\n# Spaced Title\n\nBody.";
assert_eq!(extract_h1_title(content), Some("Spaced Title".to_string()));
}
#[test]
fn test_extract_h1_title_preserves_plain_square_brackets() {
let content = "# [26Q2] Tolaria MVP\n\nBody.";
assert_eq!(
extract_h1_title(content),
Some("[26Q2] Tolaria MVP".to_string())
);
}
#[test]
fn test_extract_h1_title_none_when_no_h1() {
assert_eq!(extract_h1_title("Just body text."), None);
}
#[test]
fn test_extract_h1_title_none_when_h1_not_first() {
assert_eq!(extract_h1_title("Some text\n# Not first\n"), None);
}
// --- extract_title tests ---
#[test]
fn test_extract_title_h1_takes_priority_over_frontmatter() {
assert_eq!(
extract_title(
Some("FM Title"),
"---\ntitle: FM Title\n---\n# H1 Title\n\nBody.",
"note.md"
),
"H1 Title"
);
}
#[test]
fn test_extract_title_h1_when_no_frontmatter_title() {
assert_eq!(
extract_title(None, "# Hello World\n\nBody text.", "some-file.md"),
"Hello World"
);
}
#[test]
fn test_extract_title_h1_after_frontmatter() {
let content = "---\nIs A: Note\n---\n# My Note\n\nBody.";
assert_eq!(extract_title(None, content, "fallback.md"), "My Note");
}
#[test]
fn test_extract_title_frontmatter_when_no_h1() {
assert_eq!(
extract_title(Some("My Great Note"), "Just body text.", "my-great-note.md"),
"My Great Note"
);
}
#[test]
fn test_extract_title_fallback_to_filename() {
assert_eq!(
extract_title(None, "", "fallback-title.md"),
"Fallback Title"
);
}
#[test]
fn test_extract_title_h1_wins_over_empty_frontmatter() {
assert_eq!(
extract_title(Some(""), "# From H1\n", "empty-h1.md"),
"From H1"
);
}
#[test]
fn test_extract_title_empty_fm_no_h1_falls_back_to_filename() {
assert_eq!(
extract_title(Some(""), "No heading here.", "empty-h1.md"),
"Empty H1"
);
}
// --- extract_snippet tests ---
#[test]
fn test_extract_snippet_basic() {
let content = "---\nIs A: Note\n---\n# My Note\n\nThis is the first paragraph of content.\n\n## Section Two\n\nMore content here.";
let snippet = extract_snippet(content);
assert!(snippet.starts_with("This is the first paragraph"));
assert!(snippet.contains("More content here"));
}
#[test]
fn test_extract_snippet_strips_markdown() {
let content = "# Title\n\nSome **bold** and *italic* and `code` text.";
let snippet = extract_snippet(content);
assert_eq!(snippet, "Some bold and italic and code text.");
}
#[test]
fn test_extract_snippet_strips_links() {
let content = "# Title\n\nSee [this link](https://example.com) and [[wiki link]].";
let snippet = extract_snippet(content);
assert!(snippet.contains("this link"));
assert!(!snippet.contains("https://example.com"));
assert!(snippet.contains("wiki link"));
assert!(!snippet.contains("[["));
assert!(!snippet.contains("]]"));
}
#[test]
fn test_extract_snippet_wikilink_alias() {
let content = "# Title\n\nDiscussed in [[meetings/standup|standup]] today.";
let snippet = extract_snippet(content);
assert_eq!(snippet, "Discussed in standup today.");
}
#[test]
fn test_extract_snippet_truncates() {
let long_content = format!("# Title\n\n{}", "word ".repeat(100));
let snippet = extract_snippet(&long_content);
assert!(snippet.len() <= 165); // 160 + "..."
assert!(snippet.ends_with("..."));
}
#[test]
fn test_extract_snippet_no_content() {
let content = "---\nIs A: Note\n---\n# Just a Title\n";
let snippet = extract_snippet(content);
assert_eq!(snippet, "");
}
#[test]
fn test_extract_snippet_code_fence_delimiters_skipped() {
let content = "# Title\n\n```rust\nfn main() {}\n```\n\nReal content here.";
let snippet = extract_snippet(content);
assert!(!snippet.contains("```"));
assert!(snippet.contains("Real content here"));
}
#[test]
fn test_extract_snippet_only_headings_uses_fallback() {
let content = "# Title\n\n## Section One\n\n### Sub Section\n";
let snippet = extract_snippet(content);
assert_eq!(snippet, "Section One Sub Section");
}
#[test]
fn test_extract_snippet_no_frontmatter_no_h1() {
let content = "Just plain text content without any heading.";
let snippet = extract_snippet(content);
assert_eq!(snippet, "Just plain text content without any heading.");
}
#[test]
fn test_extract_snippet_unclosed_frontmatter() {
let content = "---\nIs A: Note\nThis has no closing fence\n# Title\n\nBody text.";
let snippet = extract_snippet(content);
assert!(snippet.contains("Body text"));
}
#[test]
fn test_extract_snippet_horizontal_rules_skipped() {
let content = "# Title\n\n---\n\nContent after rule.";
let snippet = extract_snippet(content);
assert_eq!(snippet, "Content after rule.");
}
// --- strip_list_marker tests ---
#[test]
fn test_strip_list_marker_unordered() {
assert_eq!(strip_list_marker(text("* Item one")), "Item one");
assert_eq!(strip_list_marker(text("- Item two")), "Item two");
assert_eq!(strip_list_marker(text("+ Item three")), "Item three");
}
#[test]
fn test_strip_list_marker_ordered() {
assert_eq!(strip_list_marker(text("1. First item")), "First item");
assert_eq!(strip_list_marker(text("10. Tenth item")), "Tenth item");
assert_eq!(strip_list_marker(text("99. Large number")), "Large number");
}
#[test]
fn test_strip_list_marker_preserves_non_list() {
assert_eq!(strip_list_marker(text("Regular text")), "Regular text");
assert_eq!(strip_list_marker(text(" Indented text")), "Indented text");
}
#[test]
fn test_extract_snippet_strips_list_markers() {
let content =
"---\ntype: Project\n---\n# My Project\n\n* First bullet\n* Second bullet\n- Dash item";
let snippet = extract_snippet(content);
assert_eq!(snippet, "First bullet Second bullet Dash item");
}
#[test]
fn test_extract_snippet_mixed_headings_and_bullets() {
let content = "---\ntype: Project\nstatus: Active\n---\n# Migrate newsletter to Beehiiv\n\n### 1) Newsletter is 100% on Beehiiv\n\n* Migration is successful\n\n### 2) Open rate is >27%\n\n* No regressions on open rate";
let snippet = extract_snippet(content);
assert!(
snippet.starts_with("Migration is successful"),
"snippet should start with first bullet content, got: {}",
snippet
);
assert!(snippet.contains("No regressions on open rate"));
}
#[test]
fn test_extract_snippet_ordered_list() {
let content = "# Title\n\n1. First step\n2. Second step\n3. Third step";
let snippet = extract_snippet(content);
assert_eq!(snippet, "First step Second step Third step");
}
#[test]
fn test_extract_snippet_only_subheadings_fallback() {
let content =
"---\ntype: Project\n---\n# My Project\n\n## Description\n\n---\n\n## Key Results\n\n---\n";
let snippet = extract_snippet(content);
assert_eq!(snippet, "Description Key Results");
}
#[test]
fn test_extract_snippet_subheadings_with_emoji() {
let content = "# Daily\n\n## Intentions\n\n## Reflections\n";
let snippet = extract_snippet(content);
assert_eq!(snippet, "Intentions Reflections");
}
#[test]
fn test_extract_snippet_paragraph_takes_priority_over_headings() {
let content = "# Title\n\n## Section One\n\nActual paragraph content.\n\n## Section Two\n";
let snippet = extract_snippet(content);
assert!(
snippet.starts_with("Actual paragraph content"),
"paragraph content should be preferred over headings, got: {}",
snippet
);
}
#[test]
fn test_extract_snippet_crlf_chinese_h1_table_content() {
let content =
"\r\n\r\n# 上海复盘\r\n\r\n| 指标 | 值 |\r\n| --- | --- |\r\n| 收入 | 增长 |\r\n\r\n正文包含中文字符。";
let snippet = extract_snippet(content);
assert!(snippet.contains("指标"));
assert!(snippet.contains("正文包含中文字符"));
}
// --- count_body_words tests ---
#[test]
fn test_count_body_words_basic() {
let content = "---\nIs A: Note\n---\n# My Note\n\nHello world, this is a test.";
assert_eq!(count_body_words(content), 6);
}
#[test]
fn test_count_body_words_no_frontmatter() {
let content = "# Title\n\nOne two three four five.";
assert_eq!(count_body_words(content), 5);
}
#[test]
fn test_count_body_words_empty_body() {
let content = "---\nIs A: Note\n---\n# Just a Title\n";
assert_eq!(count_body_words(content), 0);
}
#[test]
fn test_count_body_words_no_content() {
assert_eq!(count_body_words(""), 0);
}
#[test]
fn test_count_body_words_excludes_markdown_markers() {
let content = "# Title\n\n## Section\n\nReal words here. ---\n\n> quote text";
// "Real", "words", "here.", "quote", "text" = 5 real words
// "##", "Section", "---", ">" are markdown markers (## is a heading, --- is a rule, > is blockquote)
// "Section" passes the filter (not all markdown chars), so count includes it
assert_eq!(count_body_words(content), 6);
}
#[test]
fn test_count_body_words_plain_text_only() {
let content = "Just plain text without any heading.";
assert_eq!(count_body_words(content), 6);
}
// --- strip_frontmatter tests ---
#[test]
fn test_strip_frontmatter_basic() {
let content = "---\ntitle: Test\n---\nBody content.";
assert_eq!(strip_frontmatter(text(content)), "Body content.");
}
#[test]
fn test_strip_frontmatter_no_frontmatter() {
let content = "Just plain content.";
assert_eq!(strip_frontmatter(text(content)), "Just plain content.");
}
#[test]
fn test_strip_frontmatter_dashes_in_value() {
// The closing --- must be at line start, not inside a value
let content = "---\ntitle: foo---bar\nstatus: active\n---\nBody here.";
assert_eq!(strip_frontmatter(text(content)), "Body here.");
}
#[test]
fn test_strip_frontmatter_unclosed() {
let content = "---\ntitle: Test\nNo closing fence";
assert_eq!(strip_frontmatter(text(content)), content);
}
#[test]
fn test_strip_frontmatter_empty_body() {
let content = "---\ntitle: Test\n---\n";
assert_eq!(strip_frontmatter(text(content)), "");
}
#[test]
fn test_count_body_words_with_dashes_in_frontmatter_value() {
// Regression: strip_frontmatter previously matched --- inside values
let content = "---\ntitle: my---note\nstatus: active\n---\n# Title\n\nThree body words.";
assert_eq!(count_body_words(content), 3);
}
// --- strip_markdown_chars tests ---
#[test]
fn test_strip_markdown_chars_plain_text() {
assert_eq!(strip_markdown_chars(text("hello world")), "hello world");
}
#[test]
fn test_strip_markdown_chars_emphasis() {
assert_eq!(
strip_markdown_chars(text("**bold** and *italic*")),
"bold and italic"
);
}
#[test]
fn test_strip_markdown_chars_backticks() {
assert_eq!(
strip_markdown_chars(text("use `code` here")),
"use code here"
);
}
#[test]
fn test_strip_markdown_chars_strikethrough() {
assert_eq!(strip_markdown_chars(text("~~deleted~~")), "deleted");
}
#[test]
fn test_strip_markdown_chars_link_with_url() {
assert_eq!(
strip_markdown_chars(text("[click here](https://example.com)")),
"click here"
);
}
#[test]
fn test_strip_markdown_chars_wikilink() {
assert_eq!(strip_markdown_chars(text("see [[my note]]")), "see my note");
}
#[test]
fn test_strip_markdown_chars_wikilink_alias() {
assert_eq!(
strip_markdown_chars(text("visit [[project/alpha|Alpha Project]]")),
"visit Alpha Project"
);
}
#[test]
fn test_strip_markdown_chars_wikilink_unclosed() {
assert_eq!(
strip_markdown_chars(text("see [[broken link")),
"see broken link"
);
}
#[test]
fn test_strip_markdown_chars_bracket_without_url() {
assert_eq!(
strip_markdown_chars(text("[just brackets]")),
"[just brackets]"
);
}
#[test]
fn test_strip_markdown_chars_empty() {
assert_eq!(strip_markdown_chars(text("")), "");
}
// --- without_h1_line tests ---
#[test]
fn test_without_h1_line_starts_with_h1() {
let result = without_h1_line(text("# Title\nBody text"));
assert!(result.is_some());
assert_eq!(result.unwrap(), "Body text");
}
#[test]
fn test_without_h1_line_blank_lines_then_h1() {
let result = without_h1_line(text("\n\n# Title\nBody"));
assert!(result.is_some());
assert_eq!(result.unwrap(), "Body");
}
#[test]
fn test_without_h1_line_non_heading_first() {
let result = without_h1_line(text("Some text\n# Title\n"));
assert!(result.is_none());
}
#[test]
fn test_without_h1_line_empty() {
let result = without_h1_line(text(""));
assert!(result.is_none());
}
#[test]
fn test_without_h1_line_only_blank_lines() {
let result = without_h1_line(text("\n\n\n"));
assert!(result.is_none());
}
// --- contains_wikilink tests ---
#[test]
fn test_contains_wikilink_true() {
assert!(contains_wikilink("[[some note]]"));
assert!(contains_wikilink("text before [[link]] text after"));
}
#[test]
fn test_contains_wikilink_false_plain_text() {
assert!(!contains_wikilink("no links here"));
assert!(!contains_wikilink("[single bracket]"));
}
#[test]
fn test_contains_wikilink_false_partial_markers() {
assert!(!contains_wikilink("only [[ opening"));
assert!(!contains_wikilink("only ]] closing"));
}
// --- extract_outgoing_links tests ---
#[test]
fn test_extract_outgoing_links_basic() {
let content = "# Note\n\nSee [[Alice]] and [[Bob]] for details.";
let links = extract_outgoing_links(content);
assert_eq!(links, vec!["Alice", "Bob"]);
}
#[test]
fn test_extract_outgoing_links_pipe_syntax() {
let content = "Link to [[project/alpha|Alpha Project]] here.";
let links = extract_outgoing_links(content);
assert_eq!(links, vec!["project/alpha"]);
}
#[test]
fn test_extract_outgoing_links_deduplicates() {
let content = "See [[Alice]] and then [[Alice]] again.";
let links = extract_outgoing_links(content);
assert_eq!(links, vec!["Alice"]);
}
#[test]
fn test_extract_outgoing_links_sorted() {
let content = "[[Zebra]] then [[Alpha]] then [[Middle]]";
let links = extract_outgoing_links(content);
assert_eq!(links, vec!["Alpha", "Middle", "Zebra"]);
}
#[test]
fn test_extract_outgoing_links_with_frontmatter() {
let content = "---\nHas:\n - \"[[task/design]]\"\n---\n# Note\n\nSee [[person/alice]].";
let links = extract_outgoing_links(content);
assert_eq!(links, vec!["person/alice", "task/design"]);
}
#[test]
fn test_extract_outgoing_links_empty_content() {
assert!(extract_outgoing_links("").is_empty());
assert!(extract_outgoing_links("No links here").is_empty());
}
#[test]
fn test_extract_outgoing_links_unclosed_bracket() {
// First [[ matches with the only ]], yielding "unclosed and [[valid"
let content = "[[unclosed and [[valid]]";
let links = extract_outgoing_links(content);
assert_eq!(links, vec!["unclosed and [[valid"]);
}

View file

@ -0,0 +1,117 @@
use std::path::Path;
type PathText = str;
type RelativePathText = str;
fn normalize_tmp_alias(path: &PathText) -> String {
if path == "/private/tmp" {
return "/tmp".to_string();
}
if let Some(rest) = path.strip_prefix("/private/tmp/") {
return format!("/tmp/{rest}");
}
path.to_string()
}
fn trim_trailing_slashes(path: String) -> String {
let trimmed = path.trim_end_matches('/');
if trimmed.is_empty() && path.starts_with('/') {
"/".to_string()
} else {
trimmed.to_string()
}
}
pub(crate) fn normalize_path_for_identity(path: &PathText) -> String {
trim_trailing_slashes(normalize_tmp_alias(&path.replace('\\', "/")))
}
pub(crate) fn normalize_relative_path(path: &RelativePathText) -> String {
path.replace('\\', "/").trim_matches('/').to_string()
}
pub(crate) fn relative_path_key(path: &RelativePathText) -> String {
normalize_relative_path(path).to_lowercase()
}
pub(crate) fn has_hidden_segment(path: &RelativePathText) -> bool {
normalize_relative_path(path)
.split('/')
.any(|segment| segment.starts_with('.'))
}
pub(crate) fn push_unique_relative_path(
paths: &mut Vec<String>,
path: impl AsRef<RelativePathText>,
) {
let normalized = normalize_relative_path(path.as_ref());
if normalized.is_empty() || has_hidden_segment(&normalized) {
return;
}
let key = relative_path_key(&normalized);
if !paths
.iter()
.any(|existing| relative_path_key(existing) == key)
{
paths.push(normalized);
}
}
pub(crate) fn vault_relative_path_string(vault: &Path, file: &Path) -> Result<String, String> {
let vault_path = normalize_path_for_identity(&vault.to_string_lossy());
let file_path = normalize_path_for_identity(&file.to_string_lossy());
if file_path == vault_path {
return Ok(String::new());
}
let prefix = format!("{vault_path}/");
file_path
.strip_prefix(&prefix)
.map(normalize_relative_path)
.ok_or_else(|| {
format!(
"File {} is not inside vault {}",
file.display(),
vault.display()
)
})
}
pub(crate) fn vault_relative_markdown_stem(path: &Path, vault: &Path) -> String {
let relative = vault_relative_path_string(vault, path)
.unwrap_or_else(|_| normalize_path_for_identity(&path.to_string_lossy()));
relative
.strip_suffix(".md")
.unwrap_or(&relative)
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_vault_relative_path_string_normalizes_tmp_alias_and_backslashes() {
assert_eq!(
vault_relative_path_string(
Path::new("/private/tmp/tolaria-vault"),
Path::new("/tmp/tolaria-vault/projects\\active.md"),
)
.unwrap(),
"projects/active.md"
);
}
#[test]
fn test_relative_path_key_is_case_insensitive_without_changing_output_path() {
let mut paths = vec![];
push_unique_relative_path(&mut paths, "Projects\\Active.md");
push_unique_relative_path(&mut paths, "projects/active.md");
assert_eq!(paths, vec!["Projects/Active.md"]);
assert_eq!(
relative_path_key("Projects\\Active.md"),
"projects/active.md"
);
}
}

View file

@ -0,0 +1,63 @@
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(content: &str) -> VaultEntry {
let dir = TempDir::new().unwrap();
create_test_file(dir.path(), "relationship-note.md", content);
parse_md_file(&dir.path().join("relationship-note.md"), None).unwrap()
}
#[test]
fn prefers_snake_case_relationship_keys_for_convenience_fields() {
let entry = parse_test_entry(
"---\n\
belongs_to:\n\
- \"[[canonical-parent]]\"\n\
\"Belongs to\":\n\
- \"[[legacy-parent]]\"\n\
related_to:\n\
- \"[[canonical-related]]\"\n\
\"Related to\":\n\
- \"[[legacy-related]]\"\n\
---\n\
# Relationship Note\n",
);
assert_eq!(entry.belongs_to, vec!["[[canonical-parent]]"]);
assert_eq!(entry.related_to, vec!["[[canonical-related]]"]);
assert_eq!(
entry.relationships.get("belongs_to"),
Some(&vec!["[[canonical-parent]]".to_string()])
);
assert_eq!(
entry.relationships.get("Belongs to"),
Some(&vec!["[[legacy-parent]]".to_string()])
);
}
#[test]
fn falls_back_to_legacy_relationship_keys_when_snake_case_is_absent() {
let entry = parse_test_entry(
"---\n\
Belongs to:\n\
- \"[[legacy-parent]]\"\n\
Related to:\n\
- \"[[legacy-related]]\"\n\
---\n\
# Relationship Note\n",
);
assert_eq!(entry.belongs_to, vec!["[[legacy-parent]]"]);
assert_eq!(entry.related_to, vec!["[[legacy-related]]"]);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,287 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize)]
struct RenameTransaction {
old_path: String,
new_path: String,
backup_path: String,
}
pub(super) struct RenameWorkspace {
dir: PathBuf,
}
impl RenameWorkspace {
pub(super) fn new(vault: &Path) -> Result<Self, String> {
let dir = vault.join(".tolaria-rename-txn");
fs::create_dir_all(&dir).map_err(|e| {
format!(
"Failed to create rename transaction dir {}: {}",
dir.display(),
e
)
})?;
Ok(Self { dir })
}
pub(super) fn stage_note_content(&self, content: &str) -> Result<NamedTempFile, String> {
let mut staged = NamedTempFile::new_in(&self.dir)
.map_err(|e| format!("Failed to create staged rename file: {}", e))?;
staged
.write_all(content.as_bytes())
.map_err(|e| format!("Failed to write staged rename file: {}", e))?;
staged
.as_file_mut()
.sync_all()
.map_err(|e| format!("Failed to sync staged rename file: {}", e))?;
Ok(staged)
}
pub(super) fn operation<'a>(
&self,
old_path: &'a str,
old_file: &'a Path,
) -> RenameOperation<'a> {
RenameOperation {
old_path,
old_file,
backup_path: self.dir.join(format!("{}.bak", Uuid::new_v4())),
manifest_path: self.dir.join(format!("{}.json", Uuid::new_v4())),
}
}
}
pub(super) struct CommittedRename {
new_file: PathBuf,
manifest_path: PathBuf,
backup_path: PathBuf,
}
impl CommittedRename {
pub(super) fn new_file(&self) -> &Path {
&self.new_file
}
}
impl Drop for CommittedRename {
fn drop(&mut self) {
let _ = fs::remove_file(&self.backup_path);
let _ = fs::remove_file(&self.manifest_path);
}
}
pub(super) struct RenameOperation<'a> {
old_path: &'a str,
old_file: &'a Path,
backup_path: PathBuf,
manifest_path: PathBuf,
}
impl<'a> RenameOperation<'a> {
pub(super) fn rename_with_candidates(
&self,
staged: NamedTempFile,
desired_filename: &str,
parent_dir: &Path,
) -> Result<CommittedRename, String> {
let mut staged = staged;
for attempt in 0.. {
let candidate = parent_dir.join(candidate_filename(desired_filename, attempt));
self.prepare(&candidate)?;
match staged.persist_noclobber(&candidate) {
Ok(_) => return Ok(self.committed(candidate)),
Err(err) if err.error.kind() == ErrorKind::AlreadyExists => {
staged = err.file;
self.rollback()?;
}
Err(err) => {
self.rollback()?;
return Err(format!(
"Failed to create {}: {}",
candidate.display(),
err.error
));
}
}
}
unreachable!()
}
pub(super) fn rename_exact(
&self,
staged: NamedTempFile,
new_file: &Path,
) -> Result<CommittedRename, String> {
self.prepare(new_file)?;
match staged.persist_noclobber(new_file) {
Ok(_) => Ok(self.committed(new_file.to_path_buf())),
Err(err) if err.error.kind() == ErrorKind::AlreadyExists => {
self.rollback()?;
Err("A note with that name already exists".to_string())
}
Err(err) => {
self.rollback()?;
Err(format!(
"Failed to rename {} to {}: {}",
self.old_path,
new_file.to_string_lossy(),
err.error
))
}
}
}
fn prepare(&self, new_file: &Path) -> Result<(), String> {
self.write_manifest(new_file)?;
self.move_into_backup()
}
fn write_manifest(&self, new_file: &Path) -> Result<(), String> {
let transaction = RenameTransaction {
old_path: self.old_path.to_string(),
new_path: new_file.to_string_lossy().to_string(),
backup_path: self.backup_path.to_string_lossy().to_string(),
};
let data = serde_json::to_string(&transaction)
.map_err(|e| format!("Failed to serialize rename transaction: {}", e))?;
fs::write(&self.manifest_path, data).map_err(|e| {
format!(
"Failed to write rename transaction {}: {}",
self.manifest_path.display(),
e
)
})
}
fn move_into_backup(&self) -> Result<(), String> {
fs::rename(self.old_file, &self.backup_path).map_err(|e| {
format!(
"Failed to move {} into rename backup {}: {}",
self.old_file.display(),
self.backup_path.display(),
e
)
})
}
fn rollback(&self) -> Result<(), String> {
if self.backup_path.exists() {
if let Some(parent) = self.old_file.parent() {
let _ = fs::create_dir_all(parent);
}
fs::rename(&self.backup_path, self.old_file).map_err(|e| {
format!(
"Failed to restore {} from {}: {}",
self.old_file.display(),
self.backup_path.display(),
e
)
})?;
}
let _ = fs::remove_file(&self.manifest_path);
Ok(())
}
fn committed(&self, new_file: PathBuf) -> CommittedRename {
CommittedRename {
new_file,
manifest_path: self.manifest_path.clone(),
backup_path: self.backup_path.clone(),
}
}
}
fn candidate_filename(filename: &str, attempt: usize) -> String {
let stem = Path::new(filename)
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let ext = Path::new(filename)
.extension()
.map(|s| format!(".{}", s.to_string_lossy()))
.unwrap_or_default();
if attempt == 0 {
return filename.to_string();
}
format!("{}-{}{}", stem, attempt + 1, ext)
}
fn transaction_dir(vault: &Path) -> PathBuf {
vault.join(".tolaria-rename-txn")
}
pub(super) fn recover_pending_rename_transactions(vault: &Path) -> Result<(), String> {
let txn_dir = transaction_dir(vault);
if !txn_dir.exists() {
return Ok(());
}
let entries = fs::read_dir(&txn_dir).map_err(|e| {
format!(
"Failed to read rename transaction dir {}: {}",
txn_dir.display(),
e
)
})?;
for entry in entries {
let path = entry
.map_err(|e| format!("Failed to read rename transaction entry: {}", e))?
.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
continue;
}
let Ok(data) = fs::read_to_string(&path) else {
let _ = fs::remove_file(&path);
continue;
};
let Ok(transaction) = serde_json::from_str::<RenameTransaction>(&data) else {
let _ = fs::remove_file(&path);
continue;
};
recover_rename_transaction(&path, transaction)?;
}
Ok(())
}
fn recover_rename_transaction(
manifest_path: &Path,
transaction: RenameTransaction,
) -> Result<(), String> {
let old_path = Path::new(&transaction.old_path);
let new_path = Path::new(&transaction.new_path);
let backup_path = Path::new(&transaction.backup_path);
if !backup_path.exists() {
let _ = fs::remove_file(manifest_path);
return Ok(());
}
if new_path.exists() || old_path.exists() {
let _ = fs::remove_file(backup_path);
let _ = fs::remove_file(manifest_path);
return Ok(());
}
if let Some(parent) = old_path.parent() {
let _ = fs::create_dir_all(parent);
}
fs::rename(backup_path, old_path).map_err(|e| {
format!(
"Failed to recover {} from {}: {}",
old_path.display(),
backup_path.display(),
e
)
})?;
let _ = fs::remove_file(manifest_path);
Ok(())
}

View file

@ -0,0 +1,99 @@
use super::*;
use std::fs;
use std::io::Write;
use std::path::Path;
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 parses_canonical_system_metadata_keys() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"project.md",
"---\ntype: Type\n_icon: rocket\n_order: 4\n_sidebar_label: Projects\n_sort: title:asc\n---\n# Project\n",
);
assert_eq!(entry.icon.as_deref(), Some("rocket"));
assert_eq!(entry.order, Some(4));
assert_eq!(entry.sidebar_label.as_deref(), Some("Projects"));
assert_eq!(entry.sort.as_deref(), Some("title:asc"));
}
#[test]
fn parses_legacy_system_metadata_keys_without_property_leaks() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"project.md",
"---\ntype: Type\nicon: rocket\norder: 4\nsidebar label: Projects\nsort: title:asc\n---\n# Project\n",
);
assert_eq!(entry.icon.as_deref(), Some("rocket"));
assert_eq!(entry.order, Some(4));
assert_eq!(entry.sidebar_label.as_deref(), Some("Projects"));
assert_eq!(entry.sort.as_deref(), Some("title:asc"));
assert!(!entry.properties.contains_key("icon"));
assert!(!entry.properties.contains_key("order"));
assert!(!entry.properties.contains_key("sidebar label"));
assert!(!entry.properties.contains_key("sort"));
}
#[test]
fn ignores_unknown_underscore_keys_in_properties_and_relationships() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"note.md",
"---\ntype: Note\n_internal: secret\n_hidden_link: \"[[secret]]\"\nOwner: Luca\n---\n# Note\n",
);
assert!(!entry.properties.contains_key("_internal"));
assert!(!entry.relationships.contains_key("_hidden_link"));
assert_eq!(
entry
.properties
.get("Owner")
.and_then(|value| value.as_str()),
Some("Luca")
);
}
#[test]
fn parses_note_width_without_property_leaks() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"note.md",
"---\ntype: Note\n_width: wide\n---\n# Note\n",
);
assert_eq!(entry.note_width.as_deref(), Some("wide"));
assert!(!entry.properties.contains_key("_width"));
assert!(!entry.relationships.contains_key("_width"));
}
#[test]
fn ignores_invalid_note_width_modes() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"note.md",
"---\ntype: Note\n_width: expanded\n---\n# Note\n",
);
assert_eq!(entry.note_width, None);
}

View file

@ -0,0 +1,179 @@
use std::fs;
use std::path::Path;
use crate::frontmatter::{update_frontmatter_content, FrontmatterValue};
use super::parsing::slug_to_title;
use super::rename::title_to_slug;
const TITLE_PREFIXES: [&str; 2] = ["title:", "\"title\":"];
/// Result of a title sync check.
#[derive(Debug, PartialEq)]
pub enum SyncAction {
/// Title and filename are already in sync.
InSync,
/// Title was absent or desynced; frontmatter was updated on disk.
Updated { title: String },
}
/// Extract the raw `title:` value from frontmatter in file content.
fn extract_raw_title(content: &str) -> Option<String> {
if !content.starts_with("---\n") {
return None;
}
let fm = content[4..].split("\n---").next()?;
fm.lines().find_map(extract_title_from_line)
}
fn extract_title_from_line(line: &str) -> Option<String> {
TITLE_PREFIXES
.iter()
.find_map(|prefix| line.trim_start().strip_prefix(prefix))
.map(clean_title_value)
.filter(|value| !value.is_empty())
}
fn clean_title_value(raw_value: &str) -> String {
raw_value
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string()
}
/// Sync the `title` frontmatter field with the filename.
///
/// Rules (filename is source of truth):
/// - If `title` is absent → derive from filename, write to frontmatter
/// - If `title` is present but its slug doesn't match the filename stem → overwrite
/// - If both are in sync → no-op
pub fn sync_title_on_open(path: &Path) -> Result<SyncAction, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let filename = path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let stem = filename.strip_suffix(".md").unwrap_or(&filename);
let expected_title = slug_to_title(stem);
let fm_title = extract_raw_title(&content);
match fm_title {
Some(ref title) if title_to_slug(title) == stem => Ok(SyncAction::InSync),
_ => {
// Title absent or desynced — filename wins
let value = FrontmatterValue::String(expected_title.clone());
let updated = update_frontmatter_content(&content, "title", Some(value))
.map_err(|e| format!("Failed to update frontmatter: {}", e))?;
fs::write(path, &updated)
.map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
Ok(SyncAction::Updated {
title: expected_title,
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_note(dir: &Path, name: &str, content: &str) -> std::path::PathBuf {
let path = dir.join(name);
fs::write(&path, content).unwrap();
path
}
fn assert_title_update(dir: &Path, name: &str, content: &str, expected_title: &str) -> String {
let path = write_note(dir, name, content);
let result = sync_title_on_open(&path).unwrap();
assert_eq!(
result,
SyncAction::Updated {
title: expected_title.to_string()
}
);
fs::read_to_string(&path).unwrap()
}
#[test]
fn test_sync_adds_title_when_absent() {
let dir = TempDir::new().unwrap();
let content = assert_title_update(
dir.path(),
"career-tracks.md",
"---\ntype: Note\n---\n# Career Tracks\n",
"Career Tracks",
);
assert!(content.contains("title: Career Tracks"));
}
#[test]
fn test_sync_noop_when_in_sync() {
let dir = TempDir::new().unwrap();
let path = write_note(
dir.path(),
"my-note.md",
"---\ntitle: My Note\ntype: Note\n---\n# My Note\n",
);
let result = sync_title_on_open(&path).unwrap();
assert_eq!(result, SyncAction::InSync);
}
#[test]
fn test_sync_overwrites_desynced_title() {
let dir = TempDir::new().unwrap();
// Filename says "new-name" but title says "Old Name"
let content = assert_title_update(
dir.path(),
"new-name.md",
"---\ntitle: Old Name\ntype: Note\n---\n# Old Name\n",
"New Name",
);
assert!(content.contains("title: New Name"));
assert!(!content.contains("title: Old Name"));
}
#[test]
fn test_sync_adds_frontmatter_when_none_exists() {
let dir = TempDir::new().unwrap();
let content = assert_title_update(
dir.path(),
"plain-note.md",
"# Plain Note\n\nSome content.\n",
"Plain Note",
);
assert!(content.starts_with("---\n"));
assert!(content.contains("title: Plain Note"));
}
#[test]
fn test_sync_e2e_filename() {
let dir = TempDir::new().unwrap();
assert_title_update(
dir.path(),
"e2e-test.md",
"---\ntype: Note\n---\n",
"E2e Test",
);
}
#[test]
fn test_sync_preserves_other_frontmatter() {
let dir = TempDir::new().unwrap();
let path = write_note(
dir.path(),
"my-note.md",
"---\ntype: Project\nstatus: Active\n---\n# My Note\n",
);
sync_title_on_open(&path).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("type: Project"));
assert!(content.contains("status: Active"));
assert!(content.contains("title: My Note"));
}
}

View file

@ -0,0 +1,111 @@
use std::fs;
use std::path::Path;
/// Permanently delete a single note file.
/// Returns the deleted path on success, or an error if the file doesn't exist.
pub fn delete_note(path: &str) -> Result<String, String> {
let file = Path::new(path);
if !file.exists() {
return Err(format!("File does not exist: {}", path));
}
if !file.is_file() {
return Err(format!("Path is not a file: {}", path));
}
fs::remove_file(file).map_err(|e| format!("Failed to delete {}: {}", path, e))?;
log::info!("Permanently deleted note: {}", path);
Ok(path.to_string())
}
/// Delete multiple note files from disk.
/// Returns the list of successfully deleted paths.
/// Skips files that don't exist or fail to delete (logs warnings).
pub fn batch_delete_notes(paths: &[String]) -> Result<Vec<String>, String> {
let mut deleted = Vec::new();
for path in paths {
let file = Path::new(path.as_str());
if !file.exists() {
log::warn!("File does not exist, skipping: {}", path);
continue;
}
match fs::remove_file(file) {
Ok(()) => {
log::info!("Permanently deleted note: {}", path);
deleted.push(path.clone());
}
Err(e) => {
log::warn!("Failed to delete {}: {}", path, e);
}
}
}
Ok(deleted)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn create_test_file(dir: &Path, name: &str, content: &str) {
let file_path = dir.join(name);
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).unwrap();
}
let mut file = fs::File::create(file_path).unwrap();
file.write_all(content.as_bytes()).unwrap();
}
#[test]
fn test_delete_note_removes_file() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"doomed.md",
"---\ntitle: Doomed\n---\n# Doomed\n",
);
let path = dir.path().join("doomed.md");
assert!(path.exists());
let result = delete_note(path.to_str().unwrap());
assert!(result.is_ok());
assert!(!path.exists());
}
#[test]
fn test_delete_note_nonexistent_file() {
let result = delete_note("/nonexistent/path/that/does/not/exist.md");
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
}
#[test]
fn test_batch_delete_notes_removes_files() {
let dir = TempDir::new().unwrap();
create_test_file(dir.path(), "a.md", "---\ntitle: A\n---\n# A\n");
create_test_file(dir.path(), "b.md", "---\ntitle: B\n---\n# B\n");
create_test_file(dir.path(), "keep.md", "---\ntitle: Keep\n---\n# Keep\n");
let paths = vec![
dir.path().join("a.md").to_str().unwrap().to_string(),
dir.path().join("b.md").to_str().unwrap().to_string(),
];
let deleted = batch_delete_notes(&paths).unwrap();
assert_eq!(deleted.len(), 2);
assert!(!dir.path().join("a.md").exists());
assert!(!dir.path().join("b.md").exists());
assert!(dir.path().join("keep.md").exists());
}
#[test]
fn test_batch_delete_notes_skips_nonexistent() {
let dir = TempDir::new().unwrap();
create_test_file(dir.path(), "exists.md", "---\ntitle: X\n---\n# X\n");
let paths = vec![
dir.path().join("exists.md").to_str().unwrap().to_string(),
"/nonexistent/path.md".to_string(),
];
let deleted = batch_delete_notes(&paths).unwrap();
assert_eq!(deleted.len(), 1);
assert!(!dir.path().join("exists.md").exists());
}
}

View file

@ -0,0 +1,57 @@
pub(crate) struct TypeTemplateSource<'a> {
pub explicit_template: Option<String>,
pub is_a: Option<&'a str>,
pub title: &'a str,
pub body: &'a str,
}
struct TemplateLine<'a>(&'a str);
impl TemplateLine<'_> {
fn has_structure(&self) -> bool {
let trimmed = self.0.trim_start();
trimmed.starts_with("## ")
|| trimmed.starts_with("- [ ] ")
|| TemplateLine(trimmed).is_field()
}
fn is_field(&self) -> bool {
let trimmed = self.0.trim();
if !trimmed.ends_with(':') {
return false;
}
let label = trimmed.trim_end_matches(':').trim();
!label.is_empty() && !label.starts_with('-')
}
}
impl TypeTemplateSource<'_> {
pub fn resolve(self) -> Option<String> {
match self.explicit_template {
Some(template) => Some(template),
None if self.is_a == Some("Type") => self.body_template(),
None => None,
}
}
fn body_template(&self) -> Option<String> {
let template = self.body_after_type_title()?.trim();
if template
.lines()
.map(TemplateLine)
.any(|line| line.has_structure())
{
Some(template.to_string())
} else {
None
}
}
fn body_after_type_title(&self) -> Option<&str> {
let body = self.body.trim_start();
let (first_line, rest) = body.split_once('\n').unwrap_or((body, ""));
let first_line = first_line.trim_end_matches('\r');
let heading = first_line.strip_prefix("# ")?.trim();
(heading == self.title).then_some(rest)
}
}

View file

@ -0,0 +1,107 @@
use chrono::{DateTime, Duration, Months, NaiveDate, NaiveDateTime, Utc};
fn parse_relative_amount(token: &str) -> Option<u32> {
match token {
"a" | "an" | "one" => Some(1),
"two" => Some(2),
"three" => Some(3),
"four" => Some(4),
"five" => Some(5),
"six" => Some(6),
"seven" => Some(7),
"eight" => Some(8),
"nine" => Some(9),
"ten" => Some(10),
"eleven" => Some(11),
"twelve" => Some(12),
_ => token.parse::<u32>().ok(),
}
}
fn parse_relative_date_filter(value: &str, reference: DateTime<Utc>) -> Option<DateTime<Utc>> {
let normalized = value.trim().to_lowercase();
if normalized.is_empty() {
return None;
}
let base = reference.date_naive().and_hms_opt(0, 0, 0)?.and_utc();
match normalized.as_str() {
"today" => return Some(base),
"yesterday" => return Some(base - Duration::days(1)),
"tomorrow" => return Some(base + Duration::days(1)),
_ => {}
}
let tokens: Vec<&str> = normalized.split_whitespace().collect();
let (future, amount_token, unit_token) = match tokens.as_slice() {
["in", amount, unit] => (true, *amount, *unit),
[amount, unit, "ago"] => (false, *amount, *unit),
_ => return None,
};
let amount = parse_relative_amount(amount_token)?;
let unit = unit_token.strip_suffix('s').unwrap_or(unit_token);
match (future, unit) {
(true, "day") => Some(base + Duration::days(amount as i64)),
(false, "day") => Some(base - Duration::days(amount as i64)),
(true, "week") => Some(base + Duration::weeks(amount as i64)),
(false, "week") => Some(base - Duration::weeks(amount as i64)),
(true, "month") => base.checked_add_months(Months::new(amount)),
(false, "month") => base.checked_sub_months(Months::new(amount)),
(true, "year") => base.checked_add_months(Months::new(amount * 12)),
(false, "year") => base.checked_sub_months(Months::new(amount * 12)),
_ => None,
}
}
pub(super) fn parse_date_filter_timestamp(value: &str, reference: DateTime<Utc>) -> Option<i64> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
if let Ok(date) = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
return Some(date.and_hms_opt(0, 0, 0)?.and_utc().timestamp_millis());
}
if let Ok(datetime) = NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S") {
return Some(datetime.and_utc().timestamp_millis());
}
if let Ok(datetime) = DateTime::parse_from_rfc3339(trimmed) {
return Some(datetime.with_timezone(&Utc).timestamp_millis());
}
parse_relative_date_filter(trimmed, reference).map(|datetime| datetime.timestamp_millis())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn parses_relative_days_ago() {
let reference = Utc.with_ymd_and_hms(2026, 4, 7, 12, 0, 0).unwrap();
let parsed = parse_date_filter_timestamp("10 days ago", reference).unwrap();
let expected = Utc
.with_ymd_and_hms(2026, 3, 28, 0, 0, 0)
.unwrap()
.timestamp_millis();
assert_eq!(parsed, expected);
}
#[test]
fn parses_relative_one_week_ago() {
let reference = Utc.with_ymd_and_hms(2026, 4, 7, 12, 0, 0).unwrap();
let parsed = parse_date_filter_timestamp("one week ago", reference).unwrap();
let expected = Utc
.with_ymd_and_hms(2026, 3, 31, 0, 0, 0)
.unwrap()
.timestamp_millis();
assert_eq!(parsed, expected);
}
}

View file

@ -0,0 +1,69 @@
use std::fs;
use std::path::{Path, PathBuf};
pub(super) fn is_view_definition_file(path: &Path) -> bool {
path.extension().and_then(|ext| ext.to_str()) == Some("yml")
}
pub(super) fn migrate_views(vault_path: &Path) {
let old_dir = legacy_views_dir(vault_path);
if !old_dir.is_dir() {
return;
}
let Some(yml_files) = legacy_view_files(&old_dir) else {
return;
};
let new_dir = current_views_dir(vault_path);
if fs::create_dir_all(&new_dir).is_err() {
log::warn!("Failed to create views/ directory for migration");
return;
}
for entry in yml_files {
migrate_view_file(&new_dir, entry);
}
remove_empty_legacy_views_dir(&old_dir);
}
fn legacy_views_dir(vault_path: &Path) -> PathBuf {
vault_path.join(".laputa").join("views")
}
fn current_views_dir(vault_path: &Path) -> PathBuf {
vault_path.join("views")
}
fn legacy_view_files(old_dir: &Path) -> Option<Vec<fs::DirEntry>> {
let entries = fs::read_dir(old_dir).ok()?;
let yml_files: Vec<_> = entries
.flatten()
.filter(|entry| is_view_definition_file(&entry.path()))
.collect();
(!yml_files.is_empty()).then_some(yml_files)
}
fn migrate_view_file(new_dir: &Path, entry: fs::DirEntry) {
let src = entry.path();
let dst = new_dir.join(entry.file_name());
if dst.exists() {
return;
}
if let Err(error) = fs::rename(&src, &dst) {
log::warn!("Failed to migrate view {:?}: {}", src, error);
} else {
log::info!("Migrated view {:?} -> {:?}", src, dst);
}
}
fn remove_empty_legacy_views_dir(old_dir: &Path) {
if fs::read_dir(old_dir)
.map(|mut entries| entries.next().is_none())
.unwrap_or(false)
{
let _ = fs::remove_dir(old_dir);
}
}

View file

@ -0,0 +1,156 @@
use super::view_value_conversions::{yaml_value_to_string, yaml_value_to_string_vec};
use super::views::FilterOp;
pub(super) fn relationship_candidates(link: &str) -> Vec<String> {
RelationshipLink::new(link).candidates()
}
pub(super) fn evaluate_relationship_op(
op: &FilterOp,
rels: &[String],
value: &Option<serde_yaml::Value>,
) -> bool {
let relationships = Relationships::new(rels);
match op {
FilterOp::Contains => {
relationship_target(value).is_some_and(|target| relationships.contains(&target))
}
FilterOp::NotContains => {
relationship_target(value).map_or(true, |target| !relationships.contains(&target))
}
FilterOp::AnyOf => relationships.matches_any(&relationship_values(value)),
FilterOp::NoneOf => !relationships.matches_any(&relationship_values(value)),
FilterOp::IsEmpty => relationships.is_empty(),
FilterOp::IsNotEmpty => !relationships.is_empty(),
FilterOp::Equals => relationship_target(value).map_or_else(
|| relationships.is_empty(),
|target| relationships.equals(&target),
),
FilterOp::NotEquals => relationship_target(value).map_or_else(
|| !relationships.is_empty(),
|target| !relationships.equals(&target),
),
_ => false,
}
}
struct Relationships<'a> {
values: &'a [String],
}
impl<'a> Relationships<'a> {
fn new(values: &'a [String]) -> Self {
Self { values }
}
fn is_empty(&self) -> bool {
self.values.is_empty()
}
fn contains(&self, target: &RelationshipTarget) -> bool {
self.values
.iter()
.any(|relationship| target.matches(RelationshipLink::new(relationship)))
}
fn matches_any(&self, targets: &RelationshipTargets) -> bool {
self.values
.iter()
.any(|relationship| targets.matches(RelationshipLink::new(relationship)))
}
fn equals(&self, target: &RelationshipTarget) -> bool {
self.values.len() == 1 && self.contains(target)
}
}
struct RelationshipTarget {
value: String,
}
impl RelationshipTarget {
fn new(value: String) -> Self {
Self { value }
}
fn matches(&self, relationship: RelationshipLink<'_>) -> bool {
self.as_link().normalized_stem() == relationship.normalized_stem()
}
fn as_link(&self) -> RelationshipLink<'_> {
RelationshipLink::new(&self.value)
}
}
struct RelationshipTargets {
values: Vec<RelationshipTarget>,
}
impl RelationshipTargets {
fn new(values: Vec<RelationshipTarget>) -> Self {
Self { values }
}
fn matches(&self, relationship: RelationshipLink<'_>) -> bool {
let relationship_stem = relationship.normalized_stem();
self.values
.iter()
.any(|value| value.as_link().normalized_stem() == relationship_stem)
}
}
struct RelationshipLink<'a> {
value: &'a str,
}
impl<'a> RelationshipLink<'a> {
fn new(value: &'a str) -> Self {
Self { value }
}
fn candidates(&self) -> Vec<String> {
let trimmed = self.value.trim();
match self.inner().split_once('|') {
Some((stem, alias)) => vec![trimmed.to_string(), stem.to_string(), alias.to_string()],
None => vec![trimmed.to_string(), self.inner().to_string()],
}
}
fn normalized_stem(&self) -> String {
self.stem().to_lowercase()
}
fn stem(&self) -> &str {
match self.inner().split_once('|') {
Some((stem, _)) => stem,
None => self.inner(),
}
}
fn inner(&self) -> &str {
let trimmed = self.value.trim();
trimmed
.strip_prefix("[[")
.unwrap_or(trimmed)
.strip_suffix("]]")
.unwrap_or(trimmed)
}
}
fn relationship_target(value: &Option<serde_yaml::Value>) -> Option<RelationshipTarget> {
value
.as_ref()
.and_then(yaml_value_to_string)
.map(RelationshipTarget::new)
}
fn relationship_values(value: &Option<serde_yaml::Value>) -> RelationshipTargets {
let values = value
.as_ref()
.and_then(yaml_value_to_string_vec)
.unwrap_or_default()
.into_iter()
.map(RelationshipTarget::new)
.collect();
RelationshipTargets::new(values)
}

View file

@ -0,0 +1,541 @@
use super::views::*;
use super::VaultEntry;
use std::fs;
mod tests {
use super::*;
use std::collections::HashMap;
fn make_entry(overrides: impl FnOnce(&mut VaultEntry)) -> VaultEntry {
let mut entry = VaultEntry::default();
overrides(&mut entry);
entry
}
fn make_project_view(name: &str) -> ViewDefinition {
ViewDefinition {
name: name.to_string(),
icon: None,
color: None,
order: None,
sort: None,
list_properties_display: Vec::new(),
filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition {
field: "type".to_string(),
op: FilterOp::Equals,
value: Some(serde_yaml::Value::String("Project".to_string())),
regex: false,
})]),
}
}
#[test]
fn test_parse_simple_view() {
let yaml = r#"
name: Active Projects
icon: rocket
filters:
all:
- field: type
op: equals
value: Project
"#;
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
assert_eq!(def.name, "Active Projects");
assert_eq!(def.icon.as_deref(), Some("rocket"));
assert!(def.list_properties_display.is_empty());
match &def.filters {
FilterGroup::All(nodes) => {
assert_eq!(nodes.len(), 1);
match &nodes[0] {
FilterNode::Condition(c) => {
assert_eq!(c.field, "type");
}
_ => panic!("Expected condition"),
}
}
_ => panic!("Expected All group"),
}
}
#[test]
fn test_evaluate_equals() {
let yaml = r#"
name: Projects
filters:
all:
- field: type
op: equals
value: Project
"#;
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
let matching = make_entry(|e| e.is_a = Some("Project".to_string()));
let non_matching = make_entry(|e| e.is_a = Some("Note".to_string()));
let entries = vec![matching, non_matching];
let result = evaluate_view(&def, &entries);
assert_eq!(result, vec![0]);
}
#[test]
fn test_evaluate_contains_relationship() {
let yaml = r#"
name: Related to Target
filters:
all:
- field: Related to
op: contains
value: "[[target]]"
"#;
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
let mut rels = HashMap::new();
rels.insert(
"Related to".to_string(),
vec!["[[target]]".to_string(), "[[other]]".to_string()],
);
let matching = make_entry(|e| e.relationships = rels);
let non_matching = make_entry(|_| {});
let entries = vec![matching, non_matching];
let result = evaluate_view(&def, &entries);
assert_eq!(result, vec![0]);
}
#[test]
fn test_evaluate_regex_on_scalar_field() {
let yaml = r#"
name: Regex Title
filters:
all:
- field: title
op: contains
value: "^alpha\\s+project$"
regex: true
"#;
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
let matching = make_entry(|e| e.title = "Alpha Project".to_string());
let case_matching = make_entry(|e| e.title = "alpha project".to_string());
let non_matching = make_entry(|e| e.title = "Alpha Notes".to_string());
let entries = vec![matching, case_matching, non_matching];
let result = evaluate_view(&def, &entries);
assert_eq!(result, vec![0, 1]);
}
#[test]
fn test_evaluate_regex_on_relationship_field() {
let yaml = r#"
name: Regex Relationship
filters:
all:
- field: Related to
op: contains
value: "monday-(112|113)|Monday #112"
regex: true
"#;
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
let mut alias_rels = HashMap::new();
alias_rels.insert(
"Related to".to_string(),
vec!["[[monday-112|Monday #112]]".to_string()],
);
let alias_match = make_entry(|e| e.relationships = alias_rels);
let mut stem_rels = HashMap::new();
stem_rels.insert("Related to".to_string(), vec!["[[monday-113]]".to_string()]);
let stem_match = make_entry(|e| e.relationships = stem_rels);
let mut other_rels = HashMap::new();
other_rels.insert(
"Related to".to_string(),
vec!["[[tuesday-200|Tuesday]]".to_string()],
);
let non_matching = make_entry(|e| e.relationships = other_rels);
let entries = vec![alias_match, stem_match, non_matching];
let result = evaluate_view(&def, &entries);
assert_eq!(result, vec![0, 1]);
}
#[test]
fn test_invalid_regex_matches_nothing() {
let yaml = r#"
name: Broken Regex
filters:
all:
- field: title
op: contains
value: "("
regex: true
"#;
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
let entries = vec![
make_entry(|e| e.title = "Alpha Project".to_string()),
make_entry(|e| e.title = "Beta Project".to_string()),
];
let result = evaluate_view(&def, &entries);
assert!(result.is_empty());
}
#[test]
fn test_evaluate_nested_and_or() {
let yaml = r#"
name: Complex
filters:
all:
- field: type
op: equals
value: Project
- any:
- field: status
op: equals
value: Active
- field: status
op: equals
value: Planning
"#;
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
let active_project = make_entry(|e| {
e.is_a = Some("Project".to_string());
e.status = Some("Active".to_string());
});
let planning_project = make_entry(|e| {
e.is_a = Some("Project".to_string());
e.status = Some("Planning".to_string());
});
let done_project = make_entry(|e| {
e.is_a = Some("Project".to_string());
e.status = Some("Done".to_string());
});
let active_note = make_entry(|e| {
e.is_a = Some("Note".to_string());
e.status = Some("Active".to_string());
});
let entries = vec![active_project, planning_project, done_project, active_note];
let result = evaluate_view(&def, &entries);
assert_eq!(result, vec![0, 1]);
}
#[test]
fn test_evaluate_is_empty() {
let yaml_empty = r#"
name: No Status
filters:
all:
- field: status
op: is_empty
"#;
let yaml_not_empty = r#"
name: Has Status
filters:
all:
- field: status
op: is_not_empty
"#;
let def_empty: ViewDefinition = serde_yaml::from_str(yaml_empty).unwrap();
let def_not_empty: ViewDefinition = serde_yaml::from_str(yaml_not_empty).unwrap();
let with_status = make_entry(|e| e.status = Some("Active".to_string()));
let without_status = make_entry(|_| {});
let entries = vec![with_status, without_status];
assert_eq!(evaluate_view(&def_empty, &entries), vec![1]);
assert_eq!(evaluate_view(&def_not_empty, &entries), vec![0]);
}
#[test]
fn test_scan_views_reads_yml_files() {
let dir = tempfile::TempDir::new().unwrap();
let views_dir = dir.path().join("views");
fs::create_dir_all(&views_dir).unwrap();
let yaml_a = "name: Alpha\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n";
let yaml_b =
"name: Beta\nfilters:\n any:\n - field: status\n op: equals\n value: Active\n";
fs::write(views_dir.join("a-view.yml"), yaml_a).unwrap();
fs::write(views_dir.join("b-view.yml"), yaml_b).unwrap();
fs::write(views_dir.join("readme.txt"), "ignore me").unwrap();
let views = scan_views(dir.path());
assert_eq!(views.len(), 2);
assert_eq!(views[0].filename, "a-view.yml");
assert_eq!(views[0].definition.name, "Alpha");
assert_eq!(views[1].filename, "b-view.yml");
assert_eq!(views[1].definition.name, "Beta");
}
#[test]
fn test_scan_views_sorts_by_persisted_order_then_filename() {
let dir = tempfile::TempDir::new().unwrap();
let views_dir = dir.path().join("views");
fs::create_dir_all(&views_dir).unwrap();
let alpha =
"name: Alpha\norder: 20\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n";
let beta =
"name: Beta\norder: 10\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n";
let gamma =
"name: Gamma\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n";
fs::write(views_dir.join("alpha.yml"), alpha).unwrap();
fs::write(views_dir.join("beta.yml"), beta).unwrap();
fs::write(views_dir.join("gamma.yml"), gamma).unwrap();
let views = scan_views(dir.path());
assert_eq!(
views
.iter()
.map(|view| (view.filename.as_str(), view.definition.order))
.collect::<Vec<_>>(),
vec![
("beta.yml", Some(10)),
("alpha.yml", Some(20)),
("gamma.yml", None),
]
);
}
#[test]
fn test_migrate_views_from_old_location() {
let dir = tempfile::TempDir::new().unwrap();
let old_dir = dir.path().join(".laputa").join("views");
fs::create_dir_all(&old_dir).unwrap();
let yaml = "name: Migrated\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n";
fs::write(old_dir.join("test.yml"), yaml).unwrap();
let views = scan_views(dir.path());
assert_eq!(views.len(), 1);
assert_eq!(views[0].definition.name, "Migrated");
assert!(dir.path().join("views").join("test.yml").exists());
assert!(!old_dir.join("test.yml").exists());
}
#[test]
fn test_save_and_read_view() {
let dir = tempfile::TempDir::new().unwrap();
let mut def = make_project_view("Test View");
def.icon = Some("star".to_string());
def.sort = Some("modified:desc".to_string());
def.list_properties_display = vec!["Priority".to_string(), "Owner".to_string()];
save_view(dir.path(), "test.yml", &def).unwrap();
let views = scan_views(dir.path());
assert_eq!(views.len(), 1);
assert_eq!(views[0].definition.name, "Test View");
assert_eq!(views[0].definition.icon.as_deref(), Some("star"));
assert_eq!(
views[0].definition.list_properties_display,
vec!["Priority".to_string(), "Owner".to_string()]
);
delete_view(dir.path(), "test.yml").unwrap();
let views = scan_views(dir.path());
assert_eq!(views.len(), 0);
}
#[test]
fn test_delete_view_treats_missing_file_as_deleted() {
let dir = tempfile::TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("views")).unwrap();
delete_view(dir.path(), "missing.yml").unwrap();
assert!(scan_views(dir.path()).is_empty());
}
#[test]
fn test_delete_view_treats_missing_views_directory_as_deleted() {
let dir = tempfile::TempDir::new().unwrap();
delete_view(dir.path(), "missing.yml").unwrap();
assert!(scan_views(dir.path()).is_empty());
}
#[test]
fn test_save_and_read_view_with_emoji_icon() {
let dir = tempfile::TempDir::new().unwrap();
let mut def = make_project_view("Monday");
def.icon = Some("🗂️".to_string());
save_view(dir.path(), "monday.yml", &def).unwrap();
let views = scan_views(dir.path());
assert_eq!(views.len(), 1);
assert_eq!(views[0].definition.name, "Monday");
assert_eq!(views[0].definition.icon.as_deref(), Some("🗂️"));
}
#[test]
fn test_wikilink_stem_matching() {
let yaml = r#"
name: Linked
filters:
all:
- field: Topics
op: contains
value: "[[target]]"
"#;
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
let mut rels = HashMap::new();
rels.insert("Topics".to_string(), vec!["[[target|Alias]]".to_string()]);
let matching = make_entry(|e| e.relationships = rels);
let mut rels2 = HashMap::new();
rels2.insert("Topics".to_string(), vec!["[[other|Alias]]".to_string()]);
let non_matching = make_entry(|e| e.relationships = rels2);
let entries = vec![matching, non_matching];
let result = evaluate_view(&def, &entries);
assert_eq!(result, vec![0]);
}
#[test]
fn test_contains_matches_scalar_array_property_element() {
let yaml = r#"
name: Tagged
filters:
all:
- field: tags
op: contains
value: blues
"#;
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
let matching = make_entry(|e| {
e.properties
.insert("tags".to_string(), serde_json::json!(["blues", "chicago"]));
});
let partial = make_entry(|e| {
e.properties.insert(
"tags".to_string(),
serde_json::json!(["bluegrass", "chicago"]),
);
});
let entries = vec![matching, partial];
let result = evaluate_view(&def, &entries);
assert_eq!(result, vec![0]);
}
#[test]
fn test_body_contains_filters_on_snippet() {
let yaml = r#"
name: Body Search
filters:
all:
- field: body
op: contains
value: "quarterly"
"#;
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
let matching = make_entry(|e| {
e.title = "Match".to_string();
e.snippet = "This is the quarterly review summary".to_string();
});
let non_matching = make_entry(|e| {
e.title = "No match".to_string();
e.snippet = "Daily standup notes".to_string();
});
let case_match = make_entry(|e| {
e.title = "Case match".to_string();
e.snippet = "QUARTERLY PLANNING session".to_string();
});
let entries = vec![matching, non_matching, case_match];
let result = evaluate_view(&def, &entries);
assert_eq!(result, vec![0, 2]);
}
#[test]
fn test_body_not_contains() {
let yaml = r#"
name: Body Exclude
filters:
all:
- field: body
op: not_contains
value: "draft"
"#;
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
let final_note = make_entry(|e| {
e.snippet = "Final version of the document".to_string();
});
let draft_note = make_entry(|e| {
e.snippet = "This is a draft version".to_string();
});
let entries = vec![final_note, draft_note];
let result = evaluate_view(&def, &entries);
assert_eq!(result, vec![0]);
}
#[test]
fn test_body_combined_with_type_filter() {
let yaml = r#"
name: Combined
filters:
all:
- field: type
op: equals
value: Note
- field: body
op: contains
value: "important"
"#;
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
let yes = make_entry(|e| {
e.is_a = Some("Note".to_string());
e.snippet = "This is important content".to_string();
});
let wrong_type = make_entry(|e| {
e.is_a = Some("Project".to_string());
e.snippet = "This is important content".to_string();
});
let no_match = make_entry(|e| {
e.is_a = Some("Note".to_string());
e.snippet = "Regular content".to_string();
});
let entries = vec![yes, wrong_type, no_match];
let result = evaluate_view(&def, &entries);
assert_eq!(result, vec![0]);
}
#[test]
fn test_body_is_empty() {
let yaml = r#"
name: Empty Body
filters:
all:
- field: body
op: is_empty
"#;
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
let empty = make_entry(|e| e.snippet = String::new());
let has_content = make_entry(|e| e.snippet = "Some text here".to_string());
let entries = vec![empty, has_content];
let result = evaluate_view(&def, &entries);
assert_eq!(result, vec![0]);
}
}

View file

@ -0,0 +1,29 @@
pub(super) fn json_scalar_to_string(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::String(value) => Some(value.clone()),
serde_json::Value::Number(value) => Some(value.to_string()),
serde_json::Value::Bool(value) => Some(value.to_string()),
_ => None,
}
}
pub(super) fn json_scalar_array_to_strings(value: &serde_json::Value) -> Option<Vec<String>> {
value
.as_array()
.map(|sequence| sequence.iter().filter_map(json_scalar_to_string).collect())
}
pub(super) fn yaml_value_to_string(value: &serde_yaml::Value) -> Option<String> {
match value {
serde_yaml::Value::String(value) => Some(value.clone()),
serde_yaml::Value::Number(value) => Some(value.to_string()),
serde_yaml::Value::Bool(value) => Some(value.to_string()),
_ => None,
}
}
pub(super) fn yaml_value_to_string_vec(value: &serde_yaml::Value) -> Option<Vec<String>> {
value
.as_sequence()
.map(|sequence| sequence.iter().filter_map(yaml_value_to_string).collect())
}

View file

@ -0,0 +1,532 @@
use chrono::Utc;
use regex::{Regex, RegexBuilder};
use serde::de::{self, MapAccess, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::cmp::Ordering;
use std::fmt;
use std::fs;
use std::io::ErrorKind;
use std::path::Path;
use super::view_date_filters::parse_date_filter_timestamp;
use super::view_migration::{is_view_definition_file, migrate_views};
use super::view_relationships::{evaluate_relationship_op, relationship_candidates};
use super::view_value_conversions::{
json_scalar_array_to_strings, json_scalar_to_string, yaml_value_to_string,
yaml_value_to_string_vec,
};
use super::VaultEntry;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ViewDefinition {
pub name: String,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub order: Option<i64>,
#[serde(default)]
pub sort: Option<String>,
#[serde(
default,
rename = "listPropertiesDisplay",
skip_serializing_if = "Vec::is_empty"
)]
pub list_properties_display: Vec<String>,
pub filters: FilterGroup,
}
#[derive(Debug, Clone)]
pub enum FilterGroup {
All(Vec<FilterNode>),
Any(Vec<FilterNode>),
}
impl Serialize for FilterGroup {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(Some(1))?;
match self {
FilterGroup::All(nodes) => map.serialize_entry("all", nodes)?,
FilterGroup::Any(nodes) => map.serialize_entry("any", nodes)?,
}
map.end()
}
}
impl<'de> Deserialize<'de> for FilterGroup {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct FilterGroupVisitor;
impl<'de> Visitor<'de> for FilterGroupVisitor {
type Value = FilterGroup;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a map with key 'all' or 'any'")
}
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<FilterGroup, M::Error> {
let key: String = map
.next_key()?
.ok_or_else(|| de::Error::custom("expected 'all' or 'any' key"))?;
match key.as_str() {
"all" => {
let nodes: Vec<FilterNode> = map.next_value()?;
Ok(FilterGroup::All(nodes))
}
"any" => {
let nodes: Vec<FilterNode> = map.next_value()?;
Ok(FilterGroup::Any(nodes))
}
other => Err(de::Error::unknown_field(other, &["all", "any"])),
}
}
}
deserializer.deserialize_map(FilterGroupVisitor)
}
}
#[derive(Debug, Clone)]
pub enum FilterNode {
Condition(FilterCondition),
Group(FilterGroup),
}
impl Serialize for FilterNode {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
FilterNode::Condition(c) => c.serialize(serializer),
FilterNode::Group(g) => g.serialize(serializer),
}
}
}
impl<'de> Deserialize<'de> for FilterNode {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
// Deserialize into a generic YAML value, then try group first, then condition
let value = serde_yaml::Value::deserialize(deserializer)?;
if let serde_yaml::Value::Mapping(ref m) = value {
// If the map has an "all" or "any" key, it's a group
let all_key = serde_yaml::Value::String("all".to_string());
let any_key = serde_yaml::Value::String("any".to_string());
if m.contains_key(&all_key) || m.contains_key(&any_key) {
let group: FilterGroup =
serde_yaml::from_value(value).map_err(de::Error::custom)?;
return Ok(FilterNode::Group(group));
}
}
let cond: FilterCondition = serde_yaml::from_value(value).map_err(de::Error::custom)?;
Ok(FilterNode::Condition(cond))
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FilterCondition {
pub field: String,
pub op: FilterOp,
#[serde(default)]
pub value: Option<serde_yaml::Value>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub regex: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub enum FilterOp {
#[serde(rename = "equals")]
Equals,
#[serde(rename = "not_equals")]
NotEquals,
#[serde(rename = "contains")]
Contains,
#[serde(rename = "not_contains")]
NotContains,
#[serde(rename = "any_of")]
AnyOf,
#[serde(rename = "none_of")]
NoneOf,
#[serde(rename = "is_empty")]
IsEmpty,
#[serde(rename = "is_not_empty")]
IsNotEmpty,
#[serde(rename = "before")]
Before,
#[serde(rename = "after")]
After,
}
/// A view file on disk: filename + parsed definition.
#[derive(Debug, Serialize, Clone)]
pub struct ViewFile {
pub filename: String,
pub definition: ViewDefinition,
}
fn read_view_file(path: &Path) -> Option<ViewFile> {
if !is_view_definition_file(path) {
return None;
}
let filename = path.file_name()?.to_string_lossy().to_string();
let content = match fs::read_to_string(path) {
Ok(content) => content,
Err(error) => {
log::warn!("Failed to read view file {}: {}", filename, error);
return None;
}
};
let definition = match serde_yaml::from_str::<ViewDefinition>(&content) {
Ok(definition) => definition,
Err(error) => {
log::warn!("Failed to parse view {}: {}", filename, error);
return None;
}
};
Some(ViewFile {
filename,
definition,
})
}
pub fn scan_views(vault_path: &Path) -> Vec<ViewFile> {
migrate_views(vault_path);
let views_dir = vault_path.join("views");
if !views_dir.is_dir() {
return Vec::new();
}
let mut views = Vec::new();
let entries = match fs::read_dir(&views_dir) {
Ok(e) => e,
Err(e) => {
log::warn!("Failed to read views directory: {}", e);
return Vec::new();
}
};
for entry in entries.flatten() {
if let Some(view) = read_view_file(&entry.path()) {
views.push(view);
}
}
views.sort_by(compare_views);
views
}
fn compare_views(left: &ViewFile, right: &ViewFile) -> Ordering {
let order = left
.definition
.order
.unwrap_or(i64::MAX)
.cmp(&right.definition.order.unwrap_or(i64::MAX));
order.then_with(|| left.filename.cmp(&right.filename))
}
/// Save a view definition as YAML to `vault_path/views/{filename}`.
pub fn save_view(
vault_path: &Path,
filename: &str,
definition: &ViewDefinition,
) -> Result<(), String> {
if !filename.ends_with(".yml") {
return Err("Filename must end with .yml".to_string());
}
let views_dir = vault_path.join("views");
fs::create_dir_all(&views_dir)
.map_err(|e| format!("Failed to create views directory: {}", e))?;
let yaml = serde_yaml::to_string(definition)
.map_err(|e| format!("Failed to serialize view: {}", e))?;
fs::write(views_dir.join(filename), yaml)
.map_err(|e| format!("Failed to write view file: {}", e))
}
/// Delete a view file at `vault_path/views/{filename}`.
pub fn delete_view(vault_path: &Path, filename: &str) -> Result<(), String> {
let path = vault_path.join("views").join(filename);
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
Err(error) => Err(format!("Failed to delete view: {}", error)),
}
}
/// Evaluate a view definition against vault entries, returning indices of matching entries.
pub fn evaluate_view(definition: &ViewDefinition, entries: &[VaultEntry]) -> Vec<usize> {
entries
.iter()
.enumerate()
.filter(|(_, entry)| evaluate_group(&definition.filters, entry))
.map(|(i, _)| i)
.collect()
}
fn evaluate_group(group: &FilterGroup, entry: &VaultEntry) -> bool {
match group {
FilterGroup::All(nodes) => nodes.iter().all(|n| evaluate_node(n, entry)),
FilterGroup::Any(nodes) => nodes.iter().any(|n| evaluate_node(n, entry)),
}
}
fn evaluate_node(node: &FilterNode, entry: &VaultEntry) -> bool {
match node {
FilterNode::Condition(cond) => evaluate_condition(cond, entry),
FilterNode::Group(group) => evaluate_group(group, entry),
}
}
fn build_regex(pattern: &str) -> Option<regex::Regex> {
RegexBuilder::new(pattern)
.case_insensitive(true)
.build()
.ok()
}
fn supports_regex(op: &FilterOp) -> bool {
matches!(
op,
FilterOp::Contains | FilterOp::Equals | FilterOp::NotContains | FilterOp::NotEquals
)
}
enum ConditionField<'a> {
Scalar(Option<String>),
PropertyArray(Vec<String>),
Relationship(&'a [String]),
}
fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool {
let field = cond.field.as_str();
if let Some(result) = evaluate_condition_bool_field(field, entry, &cond.op, &cond.value) {
return result;
}
let field_value = resolve_condition_field(field, entry);
let cond_value = cond.value.as_ref().and_then(yaml_value_to_string);
let regex = condition_regex(cond, cond_value.as_deref());
if invalid_regex_requested(cond, regex.as_ref()) {
return false;
}
if let Some(re) = regex.as_ref() {
return evaluate_regex_condition(&cond.op, &field_value, re);
}
match field_value {
ConditionField::PropertyArray(values) => {
evaluate_property_array_op(&cond.op, &values, &cond.value)
}
ConditionField::Relationship(rels) => evaluate_relationship_op(&cond.op, rels, &cond.value),
ConditionField::Scalar(value) => evaluate_scalar_op(
&cond.op,
value.as_deref(),
cond_value.as_deref(),
&cond.value,
),
}
}
fn evaluate_condition_bool_field(
field: &str,
entry: &VaultEntry,
op: &FilterOp,
value: &Option<serde_yaml::Value>,
) -> Option<bool> {
match field {
"archived" => Some(evaluate_bool_field(entry.archived, op, value)),
"favorite" => Some(evaluate_bool_field(entry.favorite, op, value)),
_ => None,
}
}
fn resolve_condition_field<'a>(field: &str, entry: &'a VaultEntry) -> ConditionField<'a> {
match field {
"type" | "isA" => ConditionField::Scalar(entry.is_a.clone()),
"status" => ConditionField::Scalar(entry.status.clone()),
"title" => ConditionField::Scalar(Some(entry.title.clone())),
"body" => ConditionField::Scalar(Some(entry.snippet.clone())),
_ => resolve_dynamic_condition_field(field, entry),
}
}
fn resolve_dynamic_condition_field<'a>(field: &str, entry: &'a VaultEntry) -> ConditionField<'a> {
if let Some(prop) = entry.properties.get(field) {
if let Some(values) = json_scalar_array_to_strings(prop) {
return ConditionField::PropertyArray(values);
}
return ConditionField::Scalar(json_scalar_to_string(prop));
}
if let Some(relationships) = entry.relationships.get(field) {
return ConditionField::Relationship(relationships);
}
ConditionField::Scalar(None)
}
fn condition_regex(cond: &FilterCondition, cond_value: Option<&str>) -> Option<Regex> {
if !cond.regex {
return None;
}
if !supports_regex(&cond.op) {
return None;
}
cond_value.and_then(build_regex)
}
fn invalid_regex_requested(cond: &FilterCondition, regex: Option<&Regex>) -> bool {
if !cond.regex {
return false;
}
if !supports_regex(&cond.op) {
return false;
}
regex.is_none()
}
fn evaluate_regex_condition(op: &FilterOp, field: &ConditionField<'_>, regex: &Regex) -> bool {
let matched = match field {
ConditionField::Scalar(Some(value)) => regex.is_match(value),
ConditionField::PropertyArray(values) => values.iter().any(|value| regex.is_match(value)),
ConditionField::Relationship(values) => values.iter().any(|item| {
relationship_candidates(item)
.into_iter()
.any(|candidate| regex.is_match(&candidate))
}),
ConditionField::Scalar(None) => false,
};
match op {
FilterOp::Contains | FilterOp::Equals => matched,
FilterOp::NotContains | FilterOp::NotEquals => !matched,
_ => false,
}
}
fn evaluate_property_array_op(
op: &FilterOp,
values: &[String],
raw_value: &Option<serde_yaml::Value>,
) -> bool {
match op {
FilterOp::Contains => property_array_matches_value(values, raw_value),
FilterOp::NotContains => !property_array_matches_value(values, raw_value),
FilterOp::AnyOf => property_array_matches_any(values, raw_value),
FilterOp::NoneOf => !property_array_matches_any(values, raw_value),
FilterOp::Equals => values.len() == 1 && property_array_matches_value(values, raw_value),
FilterOp::NotEquals => {
!(values.len() == 1 && property_array_matches_value(values, raw_value))
}
FilterOp::IsEmpty => values.is_empty(),
FilterOp::IsNotEmpty => !values.is_empty(),
_ => false,
}
}
fn property_array_matches_value(values: &[String], raw_value: &Option<serde_yaml::Value>) -> bool {
raw_value
.as_ref()
.and_then(yaml_value_to_string)
.is_some_and(|target| property_array_contains(values, &target))
}
fn property_array_matches_any(values: &[String], raw_value: &Option<serde_yaml::Value>) -> bool {
raw_value
.as_ref()
.and_then(yaml_value_to_string_vec)
.unwrap_or_default()
.iter()
.any(|target| property_array_contains(values, target))
}
fn property_array_contains(values: &[String], target: &str) -> bool {
values
.iter()
.any(|value| value.eq_ignore_ascii_case(target))
}
fn evaluate_scalar_op(
op: &FilterOp,
field_value: Option<&str>,
cond_value: Option<&str>,
raw_value: &Option<serde_yaml::Value>,
) -> bool {
match op {
FilterOp::Equals => scalar_equals(field_value, cond_value),
FilterOp::NotEquals => !scalar_equals(field_value, cond_value),
FilterOp::Contains => scalar_contains(field_value, cond_value),
FilterOp::NotContains => !scalar_contains(field_value, cond_value),
FilterOp::AnyOf => scalar_matches_any(field_value, raw_value),
FilterOp::NoneOf => !scalar_matches_any(field_value, raw_value),
FilterOp::IsEmpty => field_value.map_or(true, str::is_empty),
FilterOp::IsNotEmpty => field_value.is_some_and(|s| !s.is_empty()),
FilterOp::Before => {
scalar_date_compare(field_value, cond_value, |field, target| field < target)
}
FilterOp::After => {
scalar_date_compare(field_value, cond_value, |field, target| field > target)
}
}
}
fn scalar_equals(field_value: Option<&str>, cond_value: Option<&str>) -> bool {
match (field_value, cond_value) {
(Some(field), Some(value)) => field.eq_ignore_ascii_case(value),
(None, None) => true,
_ => false,
}
}
fn scalar_contains(field_value: Option<&str>, cond_value: Option<&str>) -> bool {
match (field_value, cond_value) {
(Some(field), Some(value)) => field.to_lowercase().contains(&value.to_lowercase()),
_ => false,
}
}
fn scalar_matches_any(field_value: Option<&str>, raw_value: &Option<serde_yaml::Value>) -> bool {
let Some(field) = field_value else {
return false;
};
raw_value
.as_ref()
.and_then(yaml_value_to_string_vec)
.unwrap_or_default()
.iter()
.any(|value| field.eq_ignore_ascii_case(value))
}
fn scalar_date_compare(
field_value: Option<&str>,
cond_value: Option<&str>,
predicate: impl FnOnce(i64, i64) -> bool,
) -> bool {
let (Some(field), Some(value)) = (field_value, cond_value) else {
return false;
};
let reference = Utc::now();
match (
parse_date_filter_timestamp(field, reference),
parse_date_filter_timestamp(value, reference),
) {
(Some(field_ts), Some(target_ts)) => predicate(field_ts, target_ts),
_ => false,
}
}
fn evaluate_bool_field(field_val: bool, op: &FilterOp, value: &Option<serde_yaml::Value>) -> bool {
match op {
FilterOp::Equals => {
let expected = value.as_ref().and_then(|v| v.as_bool()).unwrap_or(true);
field_val == expected
}
FilterOp::NotEquals => {
let expected = value.as_ref().and_then(|v| v.as_bool()).unwrap_or(true);
field_val != expected
}
FilterOp::IsEmpty => !field_val,
FilterOp::IsNotEmpty => field_val,
_ => false,
}
}