use std::fs; use std::path::{Path, PathBuf}; /// Default location for the Getting Started vault. pub fn default_vault_path() -> Result { dirs::document_dir() .map(|d| d.join("Getting Started")) .ok_or_else(|| "Could not determine Documents directory".to_string()) } /// Check whether a vault path exists on disk. pub fn vault_exists(path: &str) -> bool { Path::new(path).is_dir() } struct SampleFile { rel_path: &'static str, content: &'static str, } /// 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. 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. ## Structure Files are organized in folders by type: | Folder | Type | Purpose | |--------|------|---------| | `note/` | Note | General-purpose documents, research, meeting notes | | `project/` | Project | Time-bounded efforts with clear goals | | `person/` | Person | People — colleagues, collaborators, contacts | | `topic/` | Topic | Subject areas that group related notes | | `responsibility/` | Responsibility | Long-running duties with KPIs | | `procedure/` | Procedure | Recurring workflows (weekly, monthly) | | `event/` | Event | Something that happened on a specific date | | `quarter/` | Quarter | Time containers (e.g. 24Q1) | | `measure/` | Measure | Trackable metrics tied to responsibilities | | `target/` | Target | Time-bound goals for a measure | | `type/` | Type | Type definitions — icon, color, ordering | Custom folders are valid — the folder name becomes the type (capitalized). ## Frontmatter YAML frontmatter between `---` delimiters defines metadata: ```yaml --- Is A: Project Status: Active Owner: "[[person/jane-doe]]" Belongs to: "[[quarter/24q1]]" Related to: - "[[topic/growth]]" - "[[note/research-findings]]" --- ``` ### Standard fields | Field | Purpose | |-------|---------| | `Is A` | Entity type (usually inferred from folder) | | `Status` | Active, Done, Paused, Archived, Dropped | | `Owner` | Person responsible (wikilink) | | `Belongs to` | Parent relationship(s) | | `Related to` | Lateral associations | | `Cadence` | For Procedures: Weekly, Monthly, etc. | | `aliases` | Alternative names for wikilink resolution | ### Custom fields Any YAML field containing `[[wikilinks]]` becomes a navigable relationship: ```yaml Has Measures: ["[[measure/revenue]]", "[[measure/churn]]"] Resources: "[[note/api-docs]]" ``` ## Wikilinks Connect notes with double-bracket syntax: - `[[note/my-note]]` — link by path - `[[My Note Title]]` — link by title or alias - `[[note/my-note|display text]]` — link with custom display text Wikilinks work in both frontmatter values and markdown body. Backlinks are computed automatically — linking A to B makes B show a backlink to A. ## Type definitions Files in `type/` define entity types and control how they appear in the sidebar: ```yaml --- Is A: Type icon: rocket-launch color: purple order: 1 --- ``` Available colors: red, purple, blue, green, yellow, orange. Icons are Phosphor names in kebab-case. ## Conventions - First `# Heading` in a file becomes its title - One entity per file - Filenames use kebab-case: `my-note-title.md` - Type is inferred from parent folder if not set in frontmatter - Relationships are bidirectional via automatic backlinks "#; const SAMPLE_FILES: &[SampleFile] = &[ SampleFile { rel_path: "type/project.md", content: "---\nIs A: Type\nicon: rocket-launch\ncolor: purple\norder: 1\n---\n\n# Project\n\nA Project is a time-bounded effort with a clear goal and an eventual completion date. Projects belong to a quarter or area and advance specific goals.\n", }, SampleFile { rel_path: "type/note.md", content: "---\nIs A: Type\nicon: note\ncolor: blue\norder: 2\n---\n\n# Note\n\nA Note is a general-purpose document — research notes, meeting notes, strategy docs, or anything that doesn't fit a more specific type.\n", }, SampleFile { rel_path: "type/person.md", content: "---\nIs A: Type\nicon: user\ncolor: green\norder: 3\n---\n\n# Person\n\nA Person represents someone you interact with — a colleague, friend, mentor, or collaborator.\n", }, SampleFile { rel_path: "type/topic.md", content: "---\nIs A: Type\nicon: tag\ncolor: yellow\norder: 4\n---\n\n# Topic\n\nA Topic is a subject area or interest category that groups related notes, projects, and people.\n", }, 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#"--- Is A: Note Related to: - "[[note/editor-basics]]" - "[[note/using-properties]]" - "[[note/wiki-links-and-relationships]]" --- # Welcome to Laputa Welcome to your new knowledge vault! Laputa helps you organize your thoughts, projects, and relationships using **wiki-linked markdown files**. ## How it works Every note is a markdown file with optional YAML frontmatter at the top. Notes live in folders that define their **type** — a file in the `project/` folder is automatically a Project, a file in `person/` is a Person, and so on. ## What to explore - [[note/editor-basics]] — Learn about headings, lists, checkboxes, and formatting - [[note/using-properties]] — See how frontmatter properties work (status, dates, relationships) - [[note/wiki-links-and-relationships]] — Connect your notes with `[[wiki-links]]` - [[project/sample-project]] — A sample project with relationships and status - [[person/sample-collaborator]] — A sample person entry ## Tips - Press **⌘P** to quick-open any note by title - Press **⌘K** to open the command palette - Press **⌘N** to create a new note - Use the **sidebar** on the left to browse by type - Use the **inspector** on the right to edit properties and see backlinks "#, }, SampleFile { rel_path: "note/editor-basics.md", content: r#"--- Is A: Note Related to: "[[note/welcome-to-laputa]]" --- # Editor Basics Laputa uses a rich markdown editor. Here are the key formatting features: ## Headings Use `#` for headings. The first H1 heading becomes the note's title. ## Lists - Bullet lists use `-` or `*` - They can be nested - Like this - And this 1. Numbered lists work too 2. Just start with a number ## Checkboxes - [x] Completed task - [ ] Pending task - [ ] Another thing to do ## Text formatting You can use **bold**, *italic*, `inline code`, and ~~strikethrough~~ text. ## Code blocks ```javascript function hello() { console.log("Hello from Laputa!"); } ``` ## Blockquotes > "The best way to have a good idea is to have lots of ideas." — Linus Pauling "#, }, SampleFile { rel_path: "note/using-properties.md", content: r#"--- Is A: Note Status: Active Related to: - "[[note/welcome-to-laputa]]" - "[[note/wiki-links-and-relationships]]" --- # Using Properties Every note can have **properties** defined in the YAML frontmatter at the top of the file. Properties appear in the inspector panel on the right side of the screen. ## Common properties - **Is A** — The note's type (Project, Note, Person, etc.) - **Status** — Current state: Active, Done, Paused, Archived, Dropped - **Belongs to** — Parent relationship (e.g., a project belongs to a quarter) - **Related to** — Lateral connections to other notes - **Owner** — The person responsible ## How to edit properties 1. Open the **inspector panel** (right side) 2. Click on any property value to edit it 3. For relationship fields, type `[[` to search for notes 4. Use the **+ Add property** button to add custom fields ## Custom properties You can add any custom property. If the value contains `[[wiki-links]]`, Laputa will treat it as a relationship and show it as a clickable link in the inspector. "#, }, SampleFile { rel_path: "note/wiki-links-and-relationships.md", content: r#"--- Is A: Note Related to: - "[[note/welcome-to-laputa]]" - "[[note/using-properties]]" --- # Wiki-Links and Relationships Wiki-links are the core of Laputa's knowledge graph. They let you connect any note to any other note using the `[[double bracket]]` syntax. ## Creating links Type `[[` in the editor to open the link suggestion menu. Start typing to search for a note, then select it. The link will look like this: [[note/welcome-to-laputa]]. ## Backlinks When note A links to note B, note B automatically shows a **backlink** to note A in the inspector panel. This means you never have to manually maintain bidirectional links. ## Relationships in frontmatter You can also define relationships in the frontmatter: ```yaml Belongs to: "[[project/sample-project]]" Related to: - "[[note/editor-basics]]" - "[[note/using-properties]]" ``` These appear as clickable pills in the inspector and are navigable with a single click. ## Building your knowledge graph Over time, your wiki-links form a rich web of connections. Use the **Referenced By** section in the inspector to discover how notes relate to each other. "#, }, SampleFile { rel_path: "project/sample-project.md", content: r#"--- Is A: Project Status: Active Owner: "[[person/sample-collaborator]]" Related to: "[[topic/getting-started]]" --- # Sample Project This is an example project to show how projects work in Laputa. ## Overview Projects are time-bounded efforts with clear goals. They have a **status** (Active, Paused, Done, Dropped) and can be linked to people, topics, and other notes. ## Goals - [ ] Explore the Laputa editor and its features - [ ] Create your first custom note - [ ] Link notes together using wiki-links - [ ] Try editing properties in the inspector ## Notes This project is owned by [[person/sample-collaborator]] and relates to [[topic/getting-started]]. You can see these relationships in the inspector panel on the right. "#, }, SampleFile { rel_path: "person/sample-collaborator.md", content: r#"--- Is A: Person --- # Sample Collaborator This is an example person entry. In your vault, you might create entries for colleagues, friends, mentors, or anyone you interact with regularly. ## What person entries are for - Track who owns which projects - Record meeting notes linked to specific people - Build a network of relationships between people, projects, and topics ## Connections This person is the owner of [[project/sample-project]]. Check the **Referenced By** section in the inspector to see all notes that link back here. "#, }, SampleFile { rel_path: "topic/getting-started.md", content: r#"--- Is A: Topic --- # Getting Started This topic groups notes related to learning and getting started with Laputa. ## Related notes - [[note/welcome-to-laputa]] — Start here for an overview - [[note/editor-basics]] — Formatting and editor features - [[note/using-properties]] — Frontmatter and the inspector - [[note/wiki-links-and-relationships]] — Building your knowledge graph - [[project/sample-project]] — A sample project with relationships "#, }, ]; /// Create the Getting Started vault at the specified path. /// Returns the absolute path to the created vault. pub fn create_getting_started_vault(target_path: &str) -> Result { let vault_dir = Path::new(target_path); if vault_dir.exists() && vault_dir .read_dir() .map(|mut d| d.next().is_some()) .unwrap_or(false) { return Err(format!( "Directory already exists and is not empty: {}", target_path )); } fs::create_dir_all(vault_dir) .map_err(|e| format!("Failed to create vault directory: {}", 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); if let Some(parent) = file_path.parent() { fs::create_dir_all(parent) .map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?; } fs::write(&file_path, sample.content) .map_err(|e| format!("Failed to write {}: {}", sample.rel_path, e))?; } // Seed built-in themes (both JSON legacy and vault-based markdown notes) let themes_dir = vault_dir.join("_themes"); fs::create_dir_all(&themes_dir) .map_err(|e| format!("Failed to create _themes directory: {e}"))?; fs::write(themes_dir.join("default.json"), crate::theme::DEFAULT_THEME) .map_err(|e| format!("Failed to write default theme: {e}"))?; fs::write(themes_dir.join("dark.json"), crate::theme::DARK_THEME) .map_err(|e| format!("Failed to write dark theme: {e}"))?; fs::write(themes_dir.join("minimal.json"), crate::theme::MINIMAL_THEME) .map_err(|e| format!("Failed to write minimal theme: {e}"))?; let theme_notes_dir = vault_dir.join("theme"); fs::create_dir_all(&theme_notes_dir) .map_err(|e| format!("Failed to create theme directory: {e}"))?; fs::write( theme_notes_dir.join("default.md"), crate::theme::DEFAULT_VAULT_THEME, ) .map_err(|e| format!("Failed to write default vault theme: {e}"))?; fs::write( theme_notes_dir.join("dark.md"), crate::theme::DARK_VAULT_THEME, ) .map_err(|e| format!("Failed to write dark vault theme: {e}"))?; fs::write( theme_notes_dir.join("minimal.md"), crate::theme::MINIMAL_VAULT_THEME, ) .map_err(|e| format!("Failed to write minimal vault theme: {e}"))?; crate::git::init_repo(target_path)?; Ok(vault_dir .canonicalize() .unwrap_or_else(|_| vault_dir.to_path_buf()) .to_string_lossy() .to_string()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_default_vault_path_is_in_documents() { let path = default_vault_path().unwrap(); let path_str = path.to_string_lossy(); assert!(path_str.contains("Documents")); assert!(path_str.ends_with("Getting Started")); } #[test] fn test_vault_exists_false_for_missing() { assert!(!vault_exists("/nonexistent/vault/path/abc123")); } #[test] fn test_create_getting_started_vault_creates_files() { let dir = tempfile::TempDir::new().unwrap(); let vault_path = dir.path().join("test-vault"); let result = create_getting_started_vault(vault_path.to_str().unwrap()); 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()); assert!(vault_path.join("note/using-properties.md").exists()); assert!(vault_path .join("note/wiki-links-and-relationships.md") .exists()); assert!(vault_path.join("project/sample-project.md").exists()); assert!(vault_path.join("person/sample-collaborator.md").exists()); assert!(vault_path.join("topic/getting-started.md").exists()); assert!(vault_path.join("type/project.md").exists()); 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] fn test_create_vault_rejects_non_empty_directory() { let dir = tempfile::TempDir::new().unwrap(); let vault_path = dir.path().join("non-empty"); fs::create_dir_all(&vault_path).unwrap(); fs::write(vault_path.join("existing.md"), "# Existing").unwrap(); let result = create_getting_started_vault(vault_path.to_str().unwrap()); assert!(result.is_err()); assert!(result.unwrap_err().contains("not empty")); } #[test] fn test_create_vault_allows_empty_directory() { let dir = tempfile::TempDir::new().unwrap(); let vault_path = dir.path().join("empty-dir"); fs::create_dir_all(&vault_path).unwrap(); let result = create_getting_started_vault(vault_path.to_str().unwrap()); assert!(result.is_ok()); } #[test] fn test_sample_files_have_valid_frontmatter() { let dir = tempfile::TempDir::new().unwrap(); let vault_path = dir.path().join("validation-vault"); create_getting_started_vault(vault_path.to_str().unwrap()).unwrap(); for sample in SAMPLE_FILES { let file_path = vault_path.join(sample.rel_path); let content = fs::read_to_string(&file_path).unwrap(); // Verify each file has frontmatter delimiters assert!( content.starts_with("---\n"), "{} should start with frontmatter", sample.rel_path ); assert!( content.matches("---").count() >= 2, "{} should have closing frontmatter delimiter", sample.rel_path ); } } #[test] fn test_sample_files_parseable_as_vault_entries() { let dir = tempfile::TempDir::new().unwrap(); let vault_path = dir.path().join("parse-vault"); create_getting_started_vault(vault_path.to_str().unwrap()).unwrap(); let entries = crate::vault::scan_vault(&vault_path).unwrap(); // SAMPLE_FILES + config/agents.md + AGENTS.md stub + 3 vault theme notes assert_eq!(entries.len(), SAMPLE_FILES.len() + 2 + 3); } #[test] 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("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("See config/agents.md"), "Root AGENTS.md should redirect to config/agents.md" ); assert!( !content.contains("## Structure"), "Root AGENTS.md should not contain full instructions" ); } #[test] 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("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] fn test_create_getting_started_vault_initializes_git() { let dir = tempfile::TempDir::new().unwrap(); let vault_path = dir.path().join("git-vault"); create_getting_started_vault(vault_path.to_str().unwrap()).unwrap(); assert!(vault_path.join(".git").exists()); let log = std::process::Command::new("git") .args(["log", "--oneline"]) .current_dir(&vault_path) .output() .unwrap(); let log_str = String::from_utf8_lossy(&log.stdout); assert!(log_str.contains("Initial vault setup")); } #[test] fn test_create_getting_started_vault_seeds_themes() { let dir = tempfile::TempDir::new().unwrap(); let vault_path = dir.path().join("theme-vault"); create_getting_started_vault(vault_path.to_str().unwrap()).unwrap(); // JSON legacy themes assert!(vault_path.join("_themes/default.json").exists()); assert!(vault_path.join("_themes/dark.json").exists()); assert!(vault_path.join("_themes/minimal.json").exists()); let themes = crate::theme::list_themes(vault_path.to_str().unwrap()).unwrap(); assert_eq!(themes.len(), 3); // Vault-based theme notes assert!(vault_path.join("theme/default.md").exists()); assert!(vault_path.join("theme/dark.md").exists()); assert!(vault_path.join("theme/minimal.md").exists()); // Theme type definition assert!(vault_path.join("type/theme.md").exists()); } #[test] fn test_create_getting_started_vault_no_untracked_files() { let dir = tempfile::TempDir::new().unwrap(); let vault_path = dir.path().join("clean-vault"); create_getting_started_vault(vault_path.to_str().unwrap()).unwrap(); let status = std::process::Command::new("git") .args(["status", "--porcelain"]) .current_dir(&vault_path) .output() .unwrap(); assert!( String::from_utf8_lossy(&status.stdout).trim().is_empty(), "All files should be committed, no untracked files" ); } }