feat: add config/ vault type with agents.md migration

- 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 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-07 12:35:52 +01:00
parent b60bdb685d
commit fb2067ec79
5 changed files with 429 additions and 32 deletions

View File

@@ -511,6 +511,16 @@ pub fn restore_default_themes(vault_path: String) -> Result<String, String> {
theme::restore_default_themes(&vault_path)
}
#[tauri::command]
pub fn repair_vault(vault_path: String) -> Result<String, String> {
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]

View File

@@ -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
])

View File

@@ -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<String, String> {
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"));
}
}

View File

@@ -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<String, String>
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]

View File

@@ -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),