From fb2067ec79cb79a5bf14a3e13c957406afa93128 Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 7 Mar 2026 12:35:52 +0100 Subject: [PATCH] feat: add config/ vault type with agents.md migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add config_seed module: seed_config_files, migrate_agents_md, repair_config_files - New vaults seed config/agents.md + root AGENTS.md stub (Codex compat) - Existing vaults auto-migrate root AGENTS.md → config/agents.md on open - Add type/config.md (icon: gear-six, sidebar label: Config) - Add "config" → "Config" folder type mapping - Add repair_vault Tauri command (themes + config files) - 24 new tests covering seeding, migration, repair, idempotency Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands.rs | 10 + src-tauri/src/lib.rs | 6 + src-tauri/src/vault/config_seed.rs | 355 +++++++++++++++++++++++++ src-tauri/src/vault/getting_started.rs | 87 +++--- src-tauri/src/vault/mod.rs | 3 + 5 files changed, 429 insertions(+), 32 deletions(-) create mode 100644 src-tauri/src/vault/config_seed.rs diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 5037599d..2f7b54b2 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -511,6 +511,16 @@ pub fn restore_default_themes(vault_path: String) -> Result { theme::restore_default_themes(&vault_path) } +#[tauri::command] +pub fn repair_vault(vault_path: String) -> Result { + let vault_path = expand_tilde(&vault_path); + // Repair themes + theme::restore_default_themes(&vault_path)?; + // Repair config files (config/agents.md, type/config.md, AGENTS.md stub) + vault::repair_config_files(&vault_path)?; + Ok("Vault repaired".to_string()) +} + // ── Settings & config commands ────────────────────────────────────────────── #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7e5f87de..2c95a51d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -56,6 +56,11 @@ fn run_startup_tasks() { // Seed type/theme.md so the Theme type has an icon in the sidebar let _ = theme::ensure_theme_type_definition(vp_str); + // Migrate root AGENTS.md → config/agents.md (one-time, idempotent) + vault::migrate_agents_md(vp_str); + // Seed config/ with default config files if missing + vault::seed_config_files(vp_str); + // Register Laputa MCP server in Claude Code and Cursor configs match mcp::register_mcp(vp_str) { Ok(status) => log::info!("MCP registration: {status}"), @@ -167,6 +172,7 @@ pub fn run() { commands::create_vault_theme, commands::ensure_vault_themes, commands::restore_default_themes, + commands::repair_vault, commands::get_vault_config, commands::save_vault_config ]) diff --git a/src-tauri/src/vault/config_seed.rs b/src-tauri/src/vault/config_seed.rs new file mode 100644 index 00000000..5ecfd39a --- /dev/null +++ b/src-tauri/src/vault/config_seed.rs @@ -0,0 +1,355 @@ +use std::fs; +use std::path::Path; + +use super::getting_started::AGENTS_MD; + +/// Content for `type/config.md` — gives the Config type a sidebar icon and label. +const CONFIG_TYPE_DEFINITION: &str = "\ +--- +Is A: Type +icon: gear-six +color: gray +order: 90 +sidebar label: Config +--- + +# Config + +Vault configuration files. These control how AI agents, tools, and other integrations interact with this vault. +"; + +/// Minimal root `AGENTS.md` stub that redirects to `config/agents.md`. +const AGENTS_MD_STUB: &str = "\ +# Agent Instructions + +See config/agents.md for vault instructions. +"; + +/// Seed `config/agents.md` if missing or empty (idempotent, per-file). +/// Also seeds `type/config.md` for sidebar visibility. +pub fn seed_config_files(vault_path: &str) { + let vault = Path::new(vault_path); + let config_dir = vault.join("config"); + if fs::create_dir_all(&config_dir).is_err() { + return; + } + + let agents_path = config_dir.join("agents.md"); + let needs_write = + !agents_path.exists() || fs::metadata(&agents_path).map_or(true, |m| m.len() == 0); + if needs_write { + let _ = fs::write(&agents_path, AGENTS_MD); + log::info!("Seeded config/agents.md"); + } + + ensure_config_type_definition(vault_path); +} + +/// Ensure `type/config.md` exists (gives Config type a sidebar icon/color). +fn ensure_config_type_definition(vault_path: &str) { + let type_dir = Path::new(vault_path).join("type"); + if fs::create_dir_all(&type_dir).is_err() { + return; + } + let path = type_dir.join("config.md"); + let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0); + if needs_write { + let _ = fs::write(&path, CONFIG_TYPE_DEFINITION); + } +} + +/// Migrate root `AGENTS.md` → `config/agents.md` for existing vaults. +/// - If root `AGENTS.md` exists and `config/agents.md` does not: move content, write stub. +/// - If root `AGENTS.md` exists and `config/agents.md` also exists: just replace root with stub. +/// - If root `AGENTS.md` doesn't exist: write the stub anyway (for Codex discoverability). +/// Always idempotent and silent. +pub fn migrate_agents_md(vault_path: &str) { + let vault = Path::new(vault_path); + let root_agents = vault.join("AGENTS.md"); + let config_dir = vault.join("config"); + let config_agents = config_dir.join("agents.md"); + + // Ensure config/ directory exists + if fs::create_dir_all(&config_dir).is_err() { + return; + } + + // If root AGENTS.md has real content (not already a stub), migrate it + if root_agents.exists() { + let content = fs::read_to_string(&root_agents).unwrap_or_default(); + let is_stub = content.contains("See config/agents.md"); + + if !is_stub { + // Only move content if config/agents.md doesn't exist yet + let config_needs_write = !config_agents.exists() + || fs::metadata(&config_agents).map_or(true, |m| m.len() == 0); + if config_needs_write { + let _ = fs::write(&config_agents, &content); + log::info!("Migrated AGENTS.md content to config/agents.md"); + } + // Replace root with stub + let _ = fs::write(&root_agents, AGENTS_MD_STUB); + log::info!("Replaced root AGENTS.md with stub pointing to config/agents.md"); + } + } else { + // No root AGENTS.md — write stub for Codex discoverability + let _ = fs::write(&root_agents, AGENTS_MD_STUB); + } +} + +/// Repair config files: re-create missing `config/agents.md` and `type/config.md`. +/// Called by the "Repair Vault" command. Returns a status message. +pub fn repair_config_files(vault_path: &str) -> Result { + let vault = Path::new(vault_path); + + // Ensure config/ directory + let config_dir = vault.join("config"); + fs::create_dir_all(&config_dir) + .map_err(|e| format!("Failed to create config directory: {e}"))?; + + let agents_path = config_dir.join("agents.md"); + let root_agents = vault.join("AGENTS.md"); + + // Step 1: Migrate root AGENTS.md content → config/agents.md if needed + if root_agents.exists() { + let root_content = fs::read_to_string(&root_agents).unwrap_or_default(); + let is_stub = root_content.contains("See config/agents.md"); + if !is_stub && !root_content.is_empty() { + let config_needs_write = !agents_path.exists() + || fs::metadata(&agents_path).map_or(true, |m| m.len() == 0); + if config_needs_write { + fs::write(&agents_path, &root_content) + .map_err(|e| format!("Failed to migrate AGENTS.md: {e}"))?; + } + fs::write(&root_agents, AGENTS_MD_STUB) + .map_err(|e| format!("Failed to write AGENTS.md stub: {e}"))?; + } + } + + // Step 2: Seed config/agents.md with defaults if still missing or empty + let needs_write = + !agents_path.exists() || fs::metadata(&agents_path).map_or(true, |m| m.len() == 0); + if needs_write { + fs::write(&agents_path, AGENTS_MD) + .map_err(|e| format!("Failed to write config/agents.md: {e}"))?; + } + + // Step 3: Ensure type/config.md + let type_dir = vault.join("type"); + fs::create_dir_all(&type_dir) + .map_err(|e| format!("Failed to create type directory: {e}"))?; + let config_type_path = type_dir.join("config.md"); + let type_needs_write = !config_type_path.exists() + || fs::metadata(&config_type_path).map_or(true, |m| m.len() == 0); + if type_needs_write { + fs::write(&config_type_path, CONFIG_TYPE_DEFINITION) + .map_err(|e| format!("Failed to write type/config.md: {e}"))?; + } + + // Step 4: Ensure root AGENTS.md stub exists + let stub_needs_write = !root_agents.exists() + || fs::read_to_string(&root_agents) + .map_or(true, |c| !c.contains("See config/agents.md")); + if stub_needs_write { + fs::write(&root_agents, AGENTS_MD_STUB) + .map_err(|e| format!("Failed to write AGENTS.md stub: {e}"))?; + } + + Ok("Config files repaired".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_seed_config_files_creates_dir_and_agents() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("vault"); + fs::create_dir_all(&vault).unwrap(); + + seed_config_files(vault.to_str().unwrap()); + + assert!(vault.join("config").is_dir()); + assert!(vault.join("config/agents.md").exists()); + let content = fs::read_to_string(vault.join("config/agents.md")).unwrap(); + assert!(content.contains("Vault Instructions for AI Agents")); + } + + #[test] + fn test_seed_config_files_creates_type_definition() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("vault"); + fs::create_dir_all(&vault).unwrap(); + + seed_config_files(vault.to_str().unwrap()); + + assert!(vault.join("type/config.md").exists()); + let content = fs::read_to_string(vault.join("type/config.md")).unwrap(); + assert!(content.contains("Is A: Type")); + assert!(content.contains("icon: gear-six")); + } + + #[test] + fn test_seed_config_files_is_idempotent() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("vault"); + fs::create_dir_all(&vault).unwrap(); + + seed_config_files(vault.to_str().unwrap()); + // Customize the file + let custom = "---\nIs A: Config\n---\n# Custom Agents\nMy custom instructions\n"; + fs::write(vault.join("config/agents.md"), custom).unwrap(); + + seed_config_files(vault.to_str().unwrap()); + let content = fs::read_to_string(vault.join("config/agents.md")).unwrap(); + assert!( + content.contains("Custom Agents"), + "must preserve existing content" + ); + } + + #[test] + fn test_seed_config_files_reseeds_empty() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("vault"); + let config_dir = vault.join("config"); + fs::create_dir_all(&config_dir).unwrap(); + fs::write(config_dir.join("agents.md"), "").unwrap(); + + seed_config_files(vault.to_str().unwrap()); + let content = fs::read_to_string(config_dir.join("agents.md")).unwrap(); + assert!(content.contains("Vault Instructions for AI Agents")); + } + + #[test] + fn test_migrate_agents_md_moves_content() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("vault"); + fs::create_dir_all(&vault).unwrap(); + fs::write(vault.join("AGENTS.md"), AGENTS_MD).unwrap(); + + migrate_agents_md(vault.to_str().unwrap()); + + // config/agents.md should have the original content + let config_content = fs::read_to_string(vault.join("config/agents.md")).unwrap(); + assert!(config_content.contains("Vault Instructions for AI Agents")); + + // Root AGENTS.md should be a stub + let root_content = fs::read_to_string(vault.join("AGENTS.md")).unwrap(); + assert!(root_content.contains("See config/agents.md")); + assert!(!root_content.contains("## Structure")); + } + + #[test] + fn test_migrate_agents_md_preserves_existing_config() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("vault"); + let config_dir = vault.join("config"); + fs::create_dir_all(&config_dir).unwrap(); + let custom = "# Custom agent instructions\n"; + fs::write(config_dir.join("agents.md"), custom).unwrap(); + fs::write(vault.join("AGENTS.md"), AGENTS_MD).unwrap(); + + migrate_agents_md(vault.to_str().unwrap()); + + // config/agents.md should preserve custom content + let content = fs::read_to_string(config_dir.join("agents.md")).unwrap(); + assert!(content.contains("Custom agent instructions")); + + // Root should be a stub + let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap(); + assert!(root.contains("See config/agents.md")); + } + + #[test] + fn test_migrate_agents_md_idempotent_on_stub() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("vault"); + fs::create_dir_all(&vault).unwrap(); + fs::write(vault.join("AGENTS.md"), AGENTS_MD_STUB).unwrap(); + + migrate_agents_md(vault.to_str().unwrap()); + + // Stub should remain unchanged + let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap(); + assert!(root.contains("See config/agents.md")); + } + + #[test] + fn test_migrate_agents_md_writes_stub_when_no_root() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("vault"); + fs::create_dir_all(&vault).unwrap(); + + migrate_agents_md(vault.to_str().unwrap()); + + assert!(vault.join("AGENTS.md").exists()); + let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap(); + assert!(root.contains("See config/agents.md")); + } + + #[test] + fn test_repair_config_files_creates_all() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("vault"); + fs::create_dir_all(&vault).unwrap(); + + let msg = repair_config_files(vault.to_str().unwrap()).unwrap(); + assert_eq!(msg, "Config files repaired"); + + assert!(vault.join("config/agents.md").exists()); + assert!(vault.join("type/config.md").exists()); + assert!(vault.join("AGENTS.md").exists()); + + let agents = fs::read_to_string(vault.join("config/agents.md")).unwrap(); + assert!(agents.contains("Vault Instructions for AI Agents")); + + let stub = fs::read_to_string(vault.join("AGENTS.md")).unwrap(); + assert!(stub.contains("See config/agents.md")); + } + + #[test] + fn test_repair_config_files_preserves_custom_content() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("vault"); + let config_dir = vault.join("config"); + fs::create_dir_all(&config_dir).unwrap(); + let custom = "# My custom agent config\nDo not overwrite me\n"; + fs::write(config_dir.join("agents.md"), custom).unwrap(); + fs::write( + vault.join("AGENTS.md"), + "# Agent Instructions\nSee config/agents.md for vault instructions.\n", + ) + .unwrap(); + + repair_config_files(vault.to_str().unwrap()).unwrap(); + + let content = fs::read_to_string(config_dir.join("agents.md")).unwrap(); + assert!( + content.contains("My custom agent config"), + "must preserve existing content" + ); + } + + #[test] + fn test_repair_config_files_migrates_root_agents() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("vault"); + fs::create_dir_all(&vault).unwrap(); + let original = "# My vault agents instructions\nCustom content here\n"; + fs::write(vault.join("AGENTS.md"), original).unwrap(); + + repair_config_files(vault.to_str().unwrap()).unwrap(); + + // Root should be a stub + let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap(); + assert!(root.contains("See config/agents.md")); + + // config/agents.md should have the original content + let config = fs::read_to_string(vault.join("config/agents.md")).unwrap(); + assert!(config.contains("My vault agents instructions")); + } +} diff --git a/src-tauri/src/vault/getting_started.rs b/src-tauri/src/vault/getting_started.rs index e238c6ea..f37705d3 100644 --- a/src-tauri/src/vault/getting_started.rs +++ b/src-tauri/src/vault/getting_started.rs @@ -18,10 +18,10 @@ struct SampleFile { content: &'static str, } -/// Content for the AGENTS.md file written to the vault root. +/// Content for config/agents.md — vault instructions for AI agents. /// This file has no YAML frontmatter — it is a convention file for AI agents, /// not a vault note. The vault scanner will still pick it up as a regular entry. -const AGENTS_MD: &str = r#"# AGENTS.md — Vault Instructions for AI Agents +pub(super) const AGENTS_MD: &str = r#"# AGENTS.md — Vault Instructions for AI Agents This is a [Laputa](https://github.com/refactoring-ai/laputa) vault — a folder of markdown files with YAML frontmatter that form a personal knowledge graph. @@ -137,6 +137,10 @@ const SAMPLE_FILES: &[SampleFile] = &[ rel_path: "type/theme.md", content: "---\nIs A: Type\nicon: palette\ncolor: purple\norder: 50\n---\n\n# Theme\n\nA visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n", }, + SampleFile { + rel_path: "type/config.md", + content: "---\nIs A: Type\nicon: gear-six\ncolor: gray\norder: 90\nsidebar label: Config\n---\n\n# Config\n\nVault configuration files. These control how AI agents, tools, and other integrations interact with this vault.\n", + }, SampleFile { rel_path: "note/welcome-to-laputa.md", content: r#"--- @@ -384,9 +388,19 @@ pub fn create_getting_started_vault(target_path: &str) -> Result fs::create_dir_all(vault_dir) .map_err(|e| format!("Failed to create vault directory: {}", e))?; - // Write AGENTS.md at the vault root - fs::write(vault_dir.join("AGENTS.md"), AGENTS_MD) - .map_err(|e| format!("Failed to write AGENTS.md: {}", e))?; + // Write config/agents.md with vault instructions for AI agents + let config_dir = vault_dir.join("config"); + fs::create_dir_all(&config_dir) + .map_err(|e| format!("Failed to create config directory: {}", e))?; + fs::write(config_dir.join("agents.md"), AGENTS_MD) + .map_err(|e| format!("Failed to write config/agents.md: {}", e))?; + + // Write root AGENTS.md stub for Codex discoverability + fs::write( + vault_dir.join("AGENTS.md"), + "# Agent Instructions\n\nSee config/agents.md for vault instructions.\n", + ) + .map_err(|e| format!("Failed to write AGENTS.md stub: {}", e))?; for sample in SAMPLE_FILES { let file_path = vault_dir.join(sample.rel_path); @@ -462,6 +476,7 @@ mod tests { assert!(result.is_ok()); // Verify key files exist + assert!(vault_path.join("config/agents.md").exists()); assert!(vault_path.join("AGENTS.md").exists()); assert!(vault_path.join("note/welcome-to-laputa.md").exists()); assert!(vault_path.join("note/editor-basics.md").exists()); @@ -476,6 +491,7 @@ mod tests { assert!(vault_path.join("type/note.md").exists()); assert!(vault_path.join("type/person.md").exists()); assert!(vault_path.join("type/topic.md").exists()); + assert!(vault_path.join("type/config.md").exists()); } #[test] @@ -530,57 +546,64 @@ mod tests { create_getting_started_vault(vault_path.to_str().unwrap()).unwrap(); let entries = crate::vault::scan_vault(&vault_path).unwrap(); - // SAMPLE_FILES + AGENTS.md + 3 vault theme notes (theme/default.md, dark.md, minimal.md) - assert_eq!(entries.len(), SAMPLE_FILES.len() + 1 + 3); + // SAMPLE_FILES + config/agents.md + AGENTS.md stub + 3 vault theme notes + assert_eq!(entries.len(), SAMPLE_FILES.len() + 2 + 3); } #[test] - fn test_agents_md_present_after_vault_creation() { + fn test_config_agents_md_present_after_vault_creation() { let dir = tempfile::TempDir::new().unwrap(); let vault_path = dir.path().join("agents-vault"); create_getting_started_vault(vault_path.to_str().unwrap()).unwrap(); - let agents_path = vault_path.join("AGENTS.md"); - assert!(agents_path.exists(), "AGENTS.md should exist at vault root"); + let agents_path = vault_path.join("config/agents.md"); + assert!( + agents_path.exists(), + "config/agents.md should exist in vault" + ); let content = fs::read_to_string(&agents_path).unwrap(); + assert!(content.contains("Vault Instructions for AI Agents")); + assert!(content.contains("## Structure")); + assert!(content.contains("## Frontmatter")); + assert!(content.contains("## Wikilinks")); + assert!(content.contains("## Type definitions")); + assert!(content.contains("## Conventions")); + } + + #[test] + fn test_root_agents_md_is_stub_after_vault_creation() { + let dir = tempfile::TempDir::new().unwrap(); + let vault_path = dir.path().join("stub-vault"); + create_getting_started_vault(vault_path.to_str().unwrap()).unwrap(); + + let root_path = vault_path.join("AGENTS.md"); + assert!(root_path.exists(), "Root AGENTS.md stub should exist"); + + let content = fs::read_to_string(&root_path).unwrap(); assert!( - content.contains("Vault Instructions for AI Agents"), - "AGENTS.md should contain instructions header" + content.contains("See config/agents.md"), + "Root AGENTS.md should redirect to config/agents.md" ); assert!( - content.contains("## Structure"), - "AGENTS.md should describe vault structure" - ); - assert!( - content.contains("## Frontmatter"), - "AGENTS.md should describe frontmatter" - ); - assert!( - content.contains("## Wikilinks"), - "AGENTS.md should describe wikilinks" - ); - assert!( - content.contains("## Type definitions"), - "AGENTS.md should describe type definitions" - ); - assert!( - content.contains("## Conventions"), - "AGENTS.md should describe conventions" + !content.contains("## Structure"), + "Root AGENTS.md should not contain full instructions" ); } #[test] - fn test_agents_md_parseable_as_vault_entry() { + fn test_config_agents_md_parseable_as_vault_entry() { let dir = tempfile::TempDir::new().unwrap(); let vault_path = dir.path().join("agents-parse-vault"); create_getting_started_vault(vault_path.to_str().unwrap()).unwrap(); - let entry = crate::vault::parse_md_file(&vault_path.join("AGENTS.md")).unwrap(); + let entry = + crate::vault::parse_md_file(&vault_path.join("config/agents.md")).unwrap(); assert_eq!( entry.title, "AGENTS.md \u{2014} Vault Instructions for AI Agents" ); + assert_eq!(entry.is_a.as_deref(), Some("Config")); } #[test] diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index 56862ebe..b998bae7 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -1,4 +1,5 @@ mod cache; +mod config_seed; mod getting_started; mod image; mod migration; @@ -7,6 +8,7 @@ mod rename; mod trash; pub use cache::scan_vault_cached; +pub use config_seed::{migrate_agents_md, repair_config_files, seed_config_files}; pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists}; pub use image::{copy_image_to_vault, save_image}; pub use migration::migrate_is_a_to_type; @@ -277,6 +279,7 @@ fn infer_type_from_folder(folder: &str) -> String { "target" => "Target", "journal" => "Journal", "month" => "Month", + "config" => "Config", "essay" => "Essay", "evergreen" => "Evergreen", _ => return title_case_folder(folder),