use diesel::connection::SimpleConnection; use diesel::prelude::*; use diesel::sql_types::{BigInt, Double, Text}; use serde::Deserialize; use serde_json::Value; use sha2::{Digest, Sha256}; use std::collections::{BTreeSet, HashMap, HashSet}; use std::fs; use std::path::{Component, Path, PathBuf}; use std::sync::RwLock; use std::time::{SystemTime, UNIX_EPOCH}; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; use turbovault_parser::{LinkType, Parser, to_plain_text}; use crate::config::DevBrainConfig; use crate::database::Project; const CONTRACT_PAGES: [&str; 4] = ["purpose.md", "schema.md", "index.md", "log.md"]; const ROOT_PAGES: [&str; 5] = ["purpose.md", "schema.md", "index.md", "skills.md", "log.md"]; const TOPIC_DIRS: [&str; 7] = [ "projects", "subsystems", "concepts", "decisions", "invariants", "workflows", "skills", ]; // ponytail: one process-wide lock keeps publication invisible to all in-app // readers; use per-vault locks only if concurrent vault throughput matters. static VAULT_LOCK: RwLock<()> = RwLock::new(()); pub(crate) const PROMPT: &str = r#"# Dev Brain Dev Brain is a managed Obsidian wiki, not a project directory or generic memory. Call dev_brain_info to get both its real folder and the separate registered project folders. Use the Dev Brain folder only for wiki maintenance; use a registered project folder for cited source files and Git commands. Do not invent a .brain path or use bash for wiki maintenance. Read schema.md before maintaining pages and append material changes to log.md. Call dev_brain_validate after edits; it rebuilds index.md and skills.md and reports broken links as repairable warnings without discarding content. Fix warnings with ordinary edits or by creating the missing page. The system prompt lists verified skills by name, description, and Markdown path. When a task matches a skill, read that complete skill file before acting and follow its instructions."#; pub(crate) const TOOL_SCHEMAS: &str = r#"{"type":"function","function":{"name":"dev_brain_info","description":"Return the separate Dev Brain wiki folder and registered project source folders. Use ordinary file tools on the returned paths.","parameters":{"type":"object","properties":{}}}} {"type":"function","function":{"name":"dev_brain_search","description":"Search the validated Dev Brain index with freshness and project evidence. Use ordinary search for literal or regex file search.","parameters":{"type":"object","properties":{"query":{"type":"string"},"limit":{"type":"number"},"authoritative":{"type":"boolean"}},"required":["query"]}}} {"type":"function","function":{"name":"dev_brain_validate","description":"Validate managed pages after ordinary file edits, deterministically rebuild index.md and skills.md, refresh search, and report repairable link warnings.","parameters":{"type":"object","properties":{}}}}"#; const DEFAULT_PURPOSE: &str = "# Dev Brain purpose\n\n\ Dev Brain compiles durable, source-backed knowledge from the registered projects.\n\n\ ## Priorities\n\n\ - Architecture, behavior, decisions, invariants, workflows, and relationships.\n\ - Small topic pages that answer recurring development questions.\n\ - Visible uncertainty and exact project provenance.\n\n\ ## Recurring questions\n\n\ - Where does a behavior live, and what must remain invariant when it changes?\n\ - Which decisions constrain the current implementation?\n\ - Which project sources must be rechecked before relying on this page?\n"; const DEFAULT_INDEX: &str = "# Dev Brain index\n\nNo topic pages have been compiled yet.\n"; const DEFAULT_SKILLS: &str = "# Dev Brain skills\n\nNo verified skills are available.\n"; const DEFAULT_LOG: &str = "# Dev Brain log\n\n\n"; const DEFAULT_SCHEMA: &str = r#"# Dev Brain schema The registered projects are authoritative. This vault is a derived, human-readable wiki. ## Managed paths DS4Server manages `purpose.md`, `schema.md`, `index.md`, `skills.md`, `log.md`, and topic pages below `projects/`, `subsystems/`, `concepts/`, `decisions/`, `invariants/`, `workflows/`, and `skills/`. Topic pages must opt in with `dev_brain: true`. Other notes, hidden files, Obsidian settings, attachments, and trash are never modified. ## Topic frontmatter ```yaml --- dev_brain: true type: subsystem # project, subsystem, concept, decision, invariant, workflow, or skill project: Registered project name status: verified # verified, stale, or needs-review verified_at: 2026-07-27T12:00:00Z sources: - project: Registered project name path: src/example.rs symbol: optional_symbol revision: current-project-head-revision # Use `git rev-parse HEAD`, or a unique lowercase hex prefix of at least 7 characters, # when this source file matches HEAD. Use hash when this file differs from HEAD, # is untracked, or the registered project is not Git. --- ``` `project` names a registered project, not this Dev Brain vault; `dev_brain_info` lists the exact registered names and folders. Each source `path` is relative to that registered project's folder; never resolve it inside the vault. Cite only files that support the page's claims, not every dirty file in the project. Each source has exactly one evidence version: `revision` or a lowercase SHA-256 `hash`. A revision is the current project HEAD's full object ID or a unique lowercase hexadecimal prefix of at least 7 characters, and is valid when that specific source file matches HEAD even if unrelated files are dirty. Use a hash when that specific file differs from HEAD, is untracked, or its registered project is not Git. ## Skills Skills are on-demand instructions stored as Markdown below `skills/`. They use the same provenance and status fields as topic pages, set `type: skill`, and additionally require `name` and `description` frontmatter. Names contain 1–64 lowercase letters, digits, or hyphens, with no leading, trailing, or consecutive hyphens. Descriptions contain 1–1024 characters and explain both what the skill does and when to use it. Only skills whose status and current evidence are both `verified` appear in generated `skills.md` and the session system prompt. The prompt exposes each verified skill's name, description, and Markdown path; read the complete matching file on demand before following it. `index.md` lists all managed topic pages, while `skills.md` lists verified skills only. Do not hand-edit either generated index. ```yaml --- dev_brain: true type: skill name: review-release description: Verify a release candidate against the project checklist. Use before publishing a release. project: Registered project name status: verified verified_at: 2026-07-27T12:00:00Z sources: - project: Registered project name path: docs/releasing.md revision: full-or-unique-short-clean-git-revision --- ``` ## Compilation Read the purpose first. Examine high-signal manifests, documentation, schemas, entry points, public interfaces, and tests. Create the smallest coherent topic set that answers the purpose; do not mirror every source file. Use ordinary Obsidian wikilinks and embeds to connect topics. ## Querying Start with `index.md` or ranked search, then follow links and backlinks. A `verified` page is authoritative only while all recorded evidence is current. Treat `stale` pages as navigation leads and `needs-review` pages as explicit uncertainty; check project sources before making claims from either. ## Refresh and semantic validation When evidence changes, re-read the affected code, documentation, and tests. Update, split, merge, or retire dependent pages, keep `index.md` exact, and append a material update entry to `log.md` with source revisions. Only mark a page `verified` after its claims have been checked against its current evidence. Unsupported conclusions stay `needs-review`. ## Publication Call `dev_brain_info`, then maintain these pages with the ordinary file tools. Append material updates to `log.md`; `index.md` and `skills.md` are generated and should not be hand-maintained. Finish with `dev_brain_validate`. Structural or provenance errors must be repaired, while broken links are warnings so valid work remains editable instead of being discarded. "#; #[derive(Clone)] struct RegisteredProject { name: String, root: PathBuf, } #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields)] struct SourceRecord { project: String, path: String, #[serde(default)] symbol: Option, #[serde(default)] revision: Option, #[serde(default)] hash: Option, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct TopicFrontmatter { dev_brain: bool, #[serde(rename = "type")] page_type: String, project: String, status: String, verified_at: String, sources: Vec, #[serde(default)] name: Option, #[serde(default)] description: Option, } #[derive(Clone)] struct Page { path: String, title: String, page_type: String, status: String, verified_at: String, headings: Vec, tags: Vec, links: Vec, sources: Vec, body: String, content: String, skill_name: Option, skill_description: Option, } #[derive(Clone)] struct RawLink { target: String, kind: LinkType, line: usize, } #[derive(Clone, Eq, PartialEq)] struct Fingerprint { path: String, modified_nanos: u128, size: u64, hash: String, } #[derive(QueryableByName)] struct SearchRow { #[diesel(sql_type = Text)] path: String, #[diesel(sql_type = Text)] title: String, #[diesel(sql_type = Text)] page_type: String, #[diesel(sql_type = Text)] status: String, #[diesel(sql_type = Text)] excerpt: String, #[diesel(sql_type = Double)] rank: f64, } #[derive(QueryableByName)] struct PageRow { #[diesel(sql_type = Text)] path: String, #[diesel(sql_type = Text)] title: String, #[diesel(sql_type = Text)] page_type: String, #[diesel(sql_type = Text)] status: String, #[diesel(sql_type = Text)] verified_at: String, #[diesel(sql_type = Text)] content: String, } #[derive(QueryableByName)] struct EvidenceRow { #[diesel(sql_type = Text)] project: String, #[diesel(sql_type = Text)] source_path: String, #[diesel(sql_type = Text)] symbol: String, #[diesel(sql_type = Text)] version: String, } #[derive(QueryableByName)] struct PathRow { #[diesel(sql_type = Text)] path: String, } #[derive(QueryableByName)] struct SourceKeyRow { #[diesel(sql_type = Text)] project: String, #[diesel(sql_type = Text)] source_path: String, } pub(crate) struct DevBrain { vault: PathBuf, projects: Vec, connection: SqliteConnection, fingerprint: Vec, index_error: Option, } impl DevBrain { pub(crate) fn open(config: &DevBrainConfig, projects: &[Project]) -> Result { let _guard = VAULT_LOCK .write() .unwrap_or_else(|poisoned| poisoned.into_inner()); let vault = config.vault()?; ensure_contract(&vault)?; let projects = registered_projects(projects)?; let connection = SqliteConnection::establish(":memory:").map_err(|error| error.to_string())?; let mut brain = Self { vault, projects, connection, fingerprint: Vec::new(), index_error: None, }; brain.index_error = brain.rebuild().err(); Ok(brain) } pub(crate) fn folder(&self) -> &Path { &self.vault } pub(crate) fn info(&self) -> String { let mut info = format!( "Dev Brain wiki folder (not a project): {}\nManaged roots: purpose.md, schema.md, index.md, skills.md, log.md, {}/\nRegistered project folders (source and Git roots, separate from the wiki):\n", self.vault.display(), TOPIC_DIRS.join("/, ") ); for project in &self.projects { info.push_str(&format!("- {}: {}\n", project.name, project.root.display())); } info.push_str("Source paths are relative to the named registered project folder. Use ordinary file tools on these absolute folders. Topic pages need dev_brain: true frontmatter. Skills use type: skill with name and description. Run dev_brain_validate after changes; index.md and skills.md are generated.\n"); if let Some(error) = &self.index_error { info.push_str(&format!( "The current wiki needs repair before indexed search: {error}\n" )); } info } pub(crate) fn allows_tool_write(&self, path: &Path) -> bool { let Ok(relative) = path.strip_prefix(&self.vault) else { return false; }; let display = path_to_string(relative); if ROOT_PAGES.contains(&display.as_str()) { return true; } if normalize_managed_path(&display).is_err() { return false; } !path.exists() || fs::read_to_string(path) .is_ok_and(|content| is_managed_topic(&content) || content.contains("dev_brain:")) } pub(crate) fn validate_tool_content(&self, path: &Path, content: &str) -> Result<(), String> { let relative = path .strip_prefix(&self.vault) .map_err(|_| "Path is outside the Dev Brain folder.".to_owned())?; if is_topic_path(relative) && !is_managed_topic(content) { return Err(format!( "Dev Brain topic {} needs valid YAML frontmatter with dev_brain: true.", relative.display() )); } Ok(()) } pub(crate) fn search( &mut self, query: &str, limit: usize, authoritative: bool, ) -> Result { let _guard = VAULT_LOCK .read() .unwrap_or_else(|poisoned| poisoned.into_inner()); if let Some(error) = &self.index_error { return Err(format!( "Dev Brain needs repair before indexed search: {error}. Use ordinary file tools, then dev_brain_validate." )); } self.refresh()?; let query = fts_query(query); if query.is_empty() { return self.read_indexed("index.md", authoritative); } let sql = if authoritative { "SELECT p.path, p.title, p.page_type, p.status, \ snippet(page_fts, 2, '', '', ' … ', 18) AS excerpt, \ bm25(page_fts) AS rank FROM page_fts \ JOIN pages p ON p.path = page_fts.path \ WHERE page_fts MATCH ? AND p.status = 'verified' \ ORDER BY rank LIMIT ?" } else { "SELECT p.path, p.title, p.page_type, p.status, \ snippet(page_fts, 2, '', '', ' … ', 18) AS excerpt, \ bm25(page_fts) AS rank FROM page_fts \ JOIN pages p ON p.path = page_fts.path \ WHERE page_fts MATCH ? ORDER BY rank LIMIT ?" }; let rows = diesel::sql_query(sql) .bind::(&query) .bind::(i64::try_from(limit.clamp(1, 50)).unwrap_or(50)) .load::(&mut self.connection) .map_err(|error| format!("Could not search Dev Brain: {error}"))?; if rows.is_empty() { return Ok(if authoritative { "No verified Dev Brain pages matched. Search with authoritative=false for stale leads, then check project sources.\n".into() } else { "No Dev Brain pages matched.\n".into() }); } let mut output = String::new(); for row in &rows { output.push_str(&format!( "## {} ({})\npath: {} | status: {} | rank: {:.3}\n{}\n", row.title, row.page_type, row.path, row.status, row.rank, row.excerpt )); self.append_evidence(&row.path, &mut output)?; let related = self.related_paths(&row.path, authoritative)?; if !related.is_empty() { output.push_str(&format!("related: {}\n", related.join(", "))); } output.push('\n'); } Ok(output) } fn read_indexed(&mut self, value: &str, authoritative: bool) -> Result { let normalized = normalize_managed_path(value) .map(|path| path_to_string(&path)) .unwrap_or_else(|_| value.to_owned()); let sql = if authoritative { "SELECT path, title, page_type, status, verified_at, content FROM pages \ WHERE (path = ? OR lower(title) = lower(?)) AND status = 'verified' LIMIT 1" } else { "SELECT path, title, page_type, status, verified_at, content FROM pages \ WHERE path = ? OR lower(title) = lower(?) ORDER BY path LIMIT 1" }; let row = diesel::sql_query(sql) .bind::(&normalized) .bind::(value) .get_result::(&mut self.connection) .optional() .map_err(|error| format!("Could not read Dev Brain: {error}"))?; let Some(row) = row else { return Ok(if authoritative { format!( "{value} is missing, stale, or needs review and was excluded from authoritative reading. Read with authoritative=false only as a lead.\n" ) } else { format!("Dev Brain page not found: {value}\n") }); }; let mut output = format!( "# {} ({})\npath: {} | status: {} | verified_at: {}\n", row.title, row.page_type, row.path, row.status, row.verified_at ); self.append_evidence(&row.path, &mut output)?; output.push('\n'); output.push_str(&row.content); if !output.ends_with('\n') { output.push('\n'); } Ok(output) } pub(crate) fn validate(&mut self) -> Result { let _guard = VAULT_LOCK .write() .unwrap_or_else(|poisoned| poisoned.into_inner()); let pages = load_and_validate_pages(&self.vault, &self.vault, &self.projects, true)?; let index = render_index(&pages); let skills = render_skills(&pages); let index_path = self.vault.join("index.md"); let skills_path = self.vault.join("skills.md"); let index_changed = fs::read_to_string(&index_path).map_or(true, |current| current != index); let skills_changed = fs::read_to_string(&skills_path).map_or(true, |current| current != skills); let mut generated = Vec::new(); if index_changed { generated.push(("index.md", index.as_str())); } if skills_changed { generated.push(("skills.md", skills.as_str())); } if !generated.is_empty() { publish_files(&self.vault, "indexes", &generated)?; } let pages = load_and_validate_pages(&self.vault, &self.vault, &self.projects, true)?; let (_, warnings) = validate_graph(&pages, &self.vault, &self.vault); self.rebuild()?; self.index_error = None; let mut output = format!( "Dev Brain is structurally valid ({} managed pages); index.md {}, skills.md {}.\n", pages.len(), if index_changed { "updated" } else { "unchanged" }, if skills_changed { "updated" } else { "unchanged" } ); if warnings.is_empty() { output.push_str("No link warnings.\n"); } else { output.push_str("Repairable warnings (content was not discarded):\n- "); output.push_str(&warnings.join("\n- ")); output.push('\n'); } Ok(output) } fn refresh(&mut self) -> Result<(), String> { if self.current_fingerprint()? != self.fingerprint { self.rebuild()?; } Ok(()) } fn rebuild(&mut self) -> Result<(), String> { let pages = load_and_validate_pages(&self.vault, &self.vault, &self.projects, false)?; let (resolved, _) = validate_graph(&pages, &self.vault, &self.vault); self.connection .batch_execute( "DROP TABLE IF EXISTS page_fts; DROP TABLE IF EXISTS sources; DROP TABLE IF EXISTS links; DROP TABLE IF EXISTS tags; DROP TABLE IF EXISTS headings; DROP TABLE IF EXISTS pages; CREATE TABLE pages ( path TEXT PRIMARY KEY, title TEXT NOT NULL, page_type TEXT NOT NULL, status TEXT NOT NULL, verified_at TEXT NOT NULL, body TEXT NOT NULL, content TEXT NOT NULL ); CREATE TABLE headings (page_path TEXT NOT NULL, heading TEXT NOT NULL); CREATE TABLE tags (page_path TEXT NOT NULL, tag TEXT NOT NULL); CREATE TABLE links ( source_path TEXT NOT NULL, target_path TEXT NOT NULL, kind TEXT NOT NULL ); CREATE TABLE sources ( page_path TEXT NOT NULL, project TEXT NOT NULL, source_path TEXT NOT NULL, symbol TEXT NOT NULL, version TEXT NOT NULL ); CREATE VIRTUAL TABLE page_fts USING fts5( path UNINDEXED, title, body, tags, tokenize='unicode61' );", ) .map_err(|error| format!("Could not create Dev Brain index: {error}"))?; self.connection .transaction::<_, diesel::result::Error, _>(|connection| { for page in &pages { diesel::sql_query( "INSERT INTO pages \ (path, title, page_type, status, verified_at, body, content) \ VALUES (?, ?, ?, ?, ?, ?, ?)", ) .bind::(&page.path) .bind::(&page.title) .bind::(&page.page_type) .bind::(&page.status) .bind::(&page.verified_at) .bind::(&page.body) .bind::(&page.content) .execute(connection)?; diesel::sql_query( "INSERT INTO page_fts (path, title, body, tags) VALUES (?, ?, ?, ?)", ) .bind::(&page.path) .bind::(&page.title) .bind::(&page.body) .bind::(page.tags.join(" ")) .execute(connection)?; for heading in &page.headings { diesel::sql_query( "INSERT INTO headings (page_path, heading) VALUES (?, ?)", ) .bind::(&page.path) .bind::(heading) .execute(connection)?; } for tag in &page.tags { diesel::sql_query("INSERT INTO tags (page_path, tag) VALUES (?, ?)") .bind::(&page.path) .bind::(tag) .execute(connection)?; } for source in &page.sources { let version = source.revision.as_ref().map_or_else( || format!("sha256:{}", source.hash.as_deref().unwrap_or_default()), |revision| format!("git:{revision}"), ); diesel::sql_query( "INSERT INTO sources \ (page_path, project, source_path, symbol, version) \ VALUES (?, ?, ?, ?, ?)", ) .bind::(&page.path) .bind::(&source.project) .bind::(&source.path) .bind::(source.symbol.as_deref().unwrap_or("")) .bind::(version) .execute(connection)?; } } for (source, target, kind) in &resolved { diesel::sql_query( "INSERT INTO links (source_path, target_path, kind) VALUES (?, ?, ?)", ) .bind::(source) .bind::(target) .bind::(kind) .execute(connection)?; } Ok(()) }) .map_err(|error| format!("Could not build Dev Brain index: {error}"))?; self.fingerprint = self.current_fingerprint()?; Ok(()) } fn current_fingerprint(&mut self) -> Result, String> { let mut fingerprints = vault_fingerprint(&self.vault)?; let sources = diesel::sql_query( "SELECT DISTINCT project, source_path FROM sources ORDER BY project, source_path", ) .load::(&mut self.connection) .map_err(|error| error.to_string())?; let mut project_names = BTreeSet::new(); for source in sources { let project = self .projects .iter() .find(|project| project.name == source.project) .ok_or_else(|| format!("Unregistered Dev Brain project: {}", source.project))?; let full = project .root .join(normalize_source_path(&source.source_path)?); let metadata = full.metadata().map_err(|error| { format!( "Could not inspect Dev Brain source {}:{}: {error}", source.project, source.source_path ) })?; let modified_nanos = metadata .modified() .ok() .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) .map_or(0, |duration| duration.as_nanos()); fingerprints.push(Fingerprint { path: format!("source:{}:{}", source.project, source.source_path), modified_nanos, size: metadata.len(), hash: hash_file(&full)?, }); project_names.insert(source.project); } for name in project_names { let project = self .projects .iter() .find(|project| project.name == name) .unwrap(); let state = git_state(&project.root) .map(|(revision, clean)| format!("{revision}:{clean}")) .unwrap_or_default(); fingerprints.push(Fingerprint { path: format!("git:{name}"), modified_nanos: 0, size: 0, hash: state, }); } fingerprints.sort_by(|left, right| left.path.cmp(&right.path)); Ok(fingerprints) } fn append_evidence(&mut self, path: &str, output: &mut String) -> Result<(), String> { let rows = diesel::sql_query( "SELECT project, source_path, symbol, version FROM sources \ WHERE page_path = ? ORDER BY project, source_path, symbol", ) .bind::(path) .load::(&mut self.connection) .map_err(|error| error.to_string())?; for row in rows { output.push_str(&format!( "evidence: {}:{}{} @ {}\n", row.project, row.source_path, if row.symbol.is_empty() { String::new() } else { format!("#{}", row.symbol) }, row.version )); } Ok(()) } fn related_paths(&mut self, path: &str, authoritative: bool) -> Result, String> { let sql = if authoritative { "SELECT DISTINCT p.path AS path FROM pages p JOIN links l \ ON p.path = l.target_path OR p.path = l.source_path \ WHERE (l.source_path = ? OR l.target_path = ?) \ AND p.path != ? AND p.status = 'verified' ORDER BY p.path LIMIT 12" } else { "SELECT DISTINCT p.path AS path FROM pages p JOIN links l \ ON p.path = l.target_path OR p.path = l.source_path \ WHERE (l.source_path = ? OR l.target_path = ?) \ AND p.path != ? ORDER BY p.path LIMIT 12" }; diesel::sql_query(sql) .bind::(path) .bind::(path) .bind::(path) .load::(&mut self.connection) .map(|rows| rows.into_iter().map(|row| row.path).collect()) .map_err(|error| error.to_string()) } } pub(crate) fn skills_prompt( config: &DevBrainConfig, projects: &[Project], ) -> Result { let _guard = VAULT_LOCK .write() .unwrap_or_else(|poisoned| poisoned.into_inner()); let vault = config.vault()?; ensure_contract(&vault)?; let projects = registered_projects(projects)?; let pages = load_and_validate_pages(&vault, &vault, &projects, false)?; let skills = render_skills(&pages); if fs::read_to_string(vault.join("skills.md")).map_or(true, |current| current != skills) { publish_files(&vault, "skills", &[("skills.md", &skills)])?; } Ok(format!( "# Available Dev Brain skills\n\nThe following generated index contains verified skills only. Paths are relative to {}. When a task matches, read the complete Markdown file before acting.\n\n{}", vault.display(), skills.trim() )) } fn registered_projects(projects: &[Project]) -> Result, String> { let projects = projects .iter() .map(|project| { Ok(RegisteredProject { name: project.name.clone(), root: Path::new(&project.path).canonicalize().map_err(|error| { format!( "Could not open registered project {}: {error}", project.name ) })?, }) }) .collect::, String>>()?; let mut names = HashSet::new(); if let Some(duplicate) = projects .iter() .map(|project| project.name.as_str()) .find(|name| !names.insert((*name).to_owned())) { return Err(format!( "Registered project names must be unique for Dev Brain provenance: {duplicate}" )); } Ok(projects) } pub(crate) fn restore_default_guides(vault: &Path) -> Result<(), String> { let _guard = VAULT_LOCK .write() .unwrap_or_else(|poisoned| poisoned.into_inner()); let vault = validate_vault(vault)?; if !vault.join("index.md").is_file() || !vault.join("log.md").is_file() { return Err( "The selected vault has no Dev Brain index.md and log.md contract to restore.".into(), ); } publish_defaults( &vault, &[ ("purpose.md", DEFAULT_PURPOSE), ("schema.md", DEFAULT_SCHEMA), ], ) } fn ensure_contract(vault: &Path) -> Result<(), String> { validate_vault(vault)?; let present = CONTRACT_PAGES .iter() .filter(|path| vault.join(path).is_file()) .count(); if present != 0 && present != CONTRACT_PAGES.len() { return Err( "The selected vault has a partial Dev Brain contract. Provide all of purpose.md, schema.md, index.md, and log.md, or remove the conflicting files." .into(), ); } let skills_path = vault.join("skills.md"); if skills_path.is_file() && !fs::read_to_string(&skills_path).is_ok_and(|content| is_generated_skills(&content)) { return Err( "The selected vault already contains an unrelated skills.md. Move or rename it before DS4Server creates the generated Dev Brain skill index." .into(), ); } if present == 0 { publish_defaults( vault, &[ ("purpose.md", DEFAULT_PURPOSE), ("schema.md", DEFAULT_SCHEMA), ("index.md", DEFAULT_INDEX), ("skills.md", DEFAULT_SKILLS), ("log.md", DEFAULT_LOG), ], )?; } else if !skills_path.is_file() { publish_defaults(vault, &[("skills.md", DEFAULT_SKILLS)])?; } for directory in TOPIC_DIRS { fs::create_dir_all(vault.join(directory)).map_err(|error| error.to_string())?; } Ok(()) } fn is_generated_skills(content: &str) -> bool { content == DEFAULT_SKILLS || content.starts_with( "# Dev Brain skills\n\n\n", ) } fn publish_defaults(vault: &Path, files: &[(&str, &str)]) -> Result<(), String> { publish_files(vault, "contract", files) } fn publish_files(vault: &Path, label: &str, files: &[(&str, &str)]) -> Result<(), String> { let staging = temporary_sibling(vault, label)?; fs::create_dir(&staging).map_err(|error| error.to_string())?; let result = (|| { let mut changed = BTreeSet::new(); for (path, content) in files { fs::write(staging.join(path), content).map_err(|error| error.to_string())?; changed.insert(PathBuf::from(path)); } commit_batch(vault, &staging, &changed) })(); let _ = fs::remove_dir_all(staging); result } pub(crate) fn validate_vault(path: &Path) -> Result { let vault = path .canonicalize() .map_err(|error| format!("Could not open Dev Brain vault {}: {error}", path.display()))?; if !vault.is_dir() || !vault.join(".obsidian").is_dir() { return Err("Dev Brain vault must be an existing directory containing .obsidian.".into()); } Ok(vault) } fn load_and_validate_pages( content_root: &Path, vault_root: &Path, projects: &[RegisteredProject], publishing: bool, ) -> Result, String> { let parser = Parser::new(vault_root.to_owned()); let mut paths = ROOT_PAGES.iter().map(PathBuf::from).collect::>(); for directory in TOPIC_DIRS { collect_managed_topics(content_root, &content_root.join(directory), &mut paths)?; } paths.sort(); paths.dedup(); let mut pages = Vec::with_capacity(paths.len()); for path in paths { let full_path = content_root.join(&path); let content = fs::read_to_string(&full_path) .map_err(|error| format!("Could not read candidate {}: {error}", path.display()))?; let parsed = parser .parse_file(&path, &content) .map_err(|error| format!("Could not parse {}: {error}", path.display()))?; let headings = parsed .headings .iter() .map(|heading| heading.text.clone()) .collect::>(); let title = headings .first() .cloned() .or_else(|| { path.file_stem() .and_then(|stem| stem.to_str()) .map(str::to_owned) }) .ok_or_else(|| format!("Page has no valid identity: {}", path.display()))?; let links = parsed .links .iter() .filter(|link| { matches!( link.type_, LinkType::WikiLink | LinkType::Embed | LinkType::BlockRef | LinkType::HeadingRef | LinkType::Anchor ) }) .map(|link| RawLink { target: link.target.clone(), kind: link.type_, line: link.position.line, }) .collect(); let tags = parsed.tags.iter().map(|tag| tag.name.clone()).collect(); let relative = path_to_string(&path); if ROOT_PAGES.contains(&relative.as_str()) { pages.push(Page { path: relative, title, page_type: path.file_stem().unwrap().to_string_lossy().into_owned(), status: "verified".into(), verified_at: String::new(), headings, tags, links, sources: Vec::new(), body: to_plain_text(&content), content, skill_name: None, skill_description: None, }); continue; } let frontmatter = parsed .frontmatter .ok_or_else(|| format!("{relative} is missing YAML frontmatter."))?; let frontmatter = serde_json::from_value::(Value::Object( frontmatter.data.into_iter().collect(), )) .map_err(|error| format!("Invalid frontmatter in {relative}: {error}"))?; if !frontmatter.dev_brain { return Err(format!( "{relative} is not declared as a managed Dev Brain page." )); } validate_page_type(&path, &frontmatter.page_type)?; validate_skill_metadata(&frontmatter, &relative)?; if !matches!( frontmatter.status.as_str(), "verified" | "stale" | "needs-review" ) { return Err(format!( "Invalid status in {relative}: {}", frontmatter.status )); } OffsetDateTime::parse(&frontmatter.verified_at, &Rfc3339) .map_err(|_| format!("{relative} verified_at must be an RFC 3339 timestamp."))?; if frontmatter.sources.is_empty() { return Err(format!("{relative} must cite at least one project source.")); } if !projects .iter() .any(|project| project.name == frontmatter.project) { return Err(format!( "{relative} names an unregistered project: {}", frontmatter.project )); } if !frontmatter .sources .iter() .any(|source| source.project == frontmatter.project) { return Err(format!( "{relative} must cite evidence from its declared project {}.", frontmatter.project )); } let fresh = frontmatter .sources .iter() .map(|source| validate_source(source, projects)) .collect::, _>>()? .into_iter() .all(|fresh| fresh); if publishing && frontmatter.status == "verified" && !fresh { return Err(format!( "{relative} cannot be published as verified because its evidence has drifted." )); } let status = if frontmatter.status == "verified" && !fresh { "stale".into() } else { frontmatter.status }; pages.push(Page { path: relative, title, page_type: frontmatter.page_type, status, verified_at: frontmatter.verified_at, headings, tags, links, sources: frontmatter.sources, body: to_plain_text(&content), content, skill_name: frontmatter.name, skill_description: frontmatter.description, }); } let mut identities = HashMap::new(); for page in &pages { for identity in [ page.title.to_lowercase(), Path::new(&page.path) .file_stem() .unwrap_or_default() .to_string_lossy() .to_lowercase(), ] { if identities .insert(identity.clone(), page.path.clone()) .is_some_and(|existing| existing != page.path) { return Err(format!("Duplicate Dev Brain page identity: {identity}")); } } } let mut skills = HashSet::new(); if let Some(duplicate) = pages .iter() .filter_map(|page| page.skill_name.as_deref()) .find(|name| !skills.insert((*name).to_owned())) { return Err(format!("Duplicate Dev Brain skill name: {duplicate}")); } Ok(pages) } fn validate_graph( pages: &[Page], content_root: &Path, vault_root: &Path, ) -> (Vec<(String, String, String)>, Vec) { let by_path = pages .iter() .map(|page| (page.path.clone(), page)) .collect::>(); let mut identities: HashMap> = HashMap::new(); for page in pages { for identity in [ page.title.to_lowercase(), Path::new(&page.path) .file_stem() .unwrap_or_default() .to_string_lossy() .to_lowercase(), ] { identities.entry(identity).or_default().push(page); } } let mut edges = Vec::new(); let mut errors = Vec::new(); for page in pages { for link in &page.links { match resolve_link(page, link, &by_path, &identities, content_root, vault_root) { Err(error) => errors.push(error), Ok(Some(target)) => edges.push(( page.path.clone(), target, if link.kind == LinkType::Embed { "embed".into() } else { "wikilink".into() }, )), Ok(None) => errors.push(format!( "Broken link in {} at line {}: [[{}]]", page.path, link.line, link.target )), } } } (edges, errors) } fn render_index(pages: &[Page]) -> String { let topics = pages .iter() .filter(|page| is_topic_path(Path::new(&page.path))) .collect::>(); if topics.is_empty() { return DEFAULT_INDEX.to_owned(); } let mut output = String::from( "# Dev Brain index\n\n\n", ); for directory in TOPIC_DIRS { let mut group = topics .iter() .filter(|page| page.path.starts_with(&format!("{directory}/"))) .copied() .collect::>(); if group.is_empty() { continue; } group.sort_by(|left, right| left.title.cmp(&right.title)); output.push_str(&format!("\n## {}\n\n", title_case(directory))); for page in group { output.push_str(&format!( "- [[{}|{}]] — {}\n", page.path, page.title.replace('|', "-"), page.status )); } } output } fn render_skills(pages: &[Page]) -> String { let mut skills = pages .iter() .filter(|page| page.page_type == "skill" && page.status == "verified") .collect::>(); if skills.is_empty() { return DEFAULT_SKILLS.to_owned(); } skills.sort_by(|left, right| left.skill_name.cmp(&right.skill_name)); let mut output = String::from( "# Dev Brain skills\n\n\n", ); for skill in skills { output.push_str(&format!( "\n- [[{}|{}]] — {}\n", skill.path, skill.skill_name.as_deref().unwrap_or_default(), skill .skill_description .as_deref() .unwrap_or_default() .split_whitespace() .collect::>() .join(" ") )); } output } fn title_case(value: &str) -> String { let mut characters = value.chars(); characters .next() .map(|first| first.to_uppercase().collect::() + characters.as_str()) .unwrap_or_default() } fn resolve_link( source: &Page, link: &RawLink, by_path: &HashMap, identities: &HashMap>, content_root: &Path, vault_root: &Path, ) -> Result, String> { let (target, heading) = link .target .split_once('#') .map_or((link.target.as_str(), None), |(target, heading)| { (target, Some(heading)) }); if target.is_empty() { if let Some(heading) = heading && !heading.starts_with('^') && !source .headings .iter() .any(|candidate| candidate.eq_ignore_ascii_case(heading)) { return Ok(None); } return Ok(Some(source.path.clone())); } if Path::new(target) .extension() .is_some_and(|extension| extension != "md") { let path = normalize_link_path(Path::new(&source.path), target)?; for root in [content_root, vault_root] { let full = root.join(&path); if full.is_file() && full .canonicalize() .is_ok_and(|resolved| resolved.starts_with(vault_root)) { return Ok(Some(path_to_string(&path))); } } return Ok(None); } let mut candidates = Vec::new(); if target.contains('/') || target.ends_with(".md") { for path in [ normalize_link_path(Path::new("index.md"), target)?, normalize_link_path(Path::new(&source.path), target)?, ] { let path = path_to_string(&path); if let Some(page) = by_path.get(&path) && !candidates .iter() .any(|candidate: &&Page| candidate.path == page.path) { candidates.push(*page); } } } else if let Some(matches) = identities.get(&target.to_lowercase()) { candidates.extend(matches.iter().copied()); } if candidates.len() != 1 { return Ok(None); } let target = candidates[0]; if let Some(heading) = heading && !heading.starts_with('^') && !target .headings .iter() .any(|candidate| candidate.eq_ignore_ascii_case(heading)) { return Ok(None); } Ok(Some(target.path.clone())) } fn validate_source(source: &SourceRecord, projects: &[RegisteredProject]) -> Result { let project = projects .iter() .find(|project| project.name == source.project) .ok_or_else(|| format!("Source names an unregistered project: {}", source.project))?; let relative = normalize_source_path(&source.path)?; let full = project.root.join(&relative); let resolved = full.canonicalize().map_err(|error| { format!( "Source does not exist {}:{}: {error}", source.project, source.path ) })?; if !resolved.starts_with(&project.root) || !resolved.is_file() { return Err(format!( "Source escapes its registered project: {}:{}", source.project, source.path )); } if source.revision.is_some() == source.hash.is_some() { return Err(format!( "Source must contain exactly one revision or hash: {}:{}", source.project, source.path )); } if let Some(revision) = &source.revision { return Ok( git_source_state(&project.root, &resolved).is_some_and(|(current, clean)| { clean && git_revision_matches(&project.root, revision, ¤t) }), ); } let expected = source.hash.as_deref().unwrap(); if expected.len() != 64 || !expected .bytes() .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) { return Err(format!( "Source hash must be lowercase SHA-256: {}:{}", source.project, source.path )); } Ok(hash_file(&resolved)? == expected && !git_source_state(&project.root, &resolved).is_some_and(|(_, clean)| clean)) } fn git_source_state(root: &Path, source: &Path) -> Option<(String, bool)> { let repository = git2::Repository::discover(root).ok()?; let relative = source.strip_prefix(repository.workdir()?).ok()?; let clean = repository.status_file(relative).ok()? == git2::Status::CURRENT; let revision = repository .head() .ok()? .peel_to_commit() .ok()? .id() .to_string(); Some((revision, clean)) } fn git_revision_matches(root: &Path, revision: &str, current: &str) -> bool { if revision == current { return true; } if revision.len() < 7 || revision.len() >= current.len() || !revision .bytes() .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) { return false; } git2::Repository::discover(root) .ok() .is_some_and(|repository| { repository .revparse_single(revision) .ok() .and_then(|object| object.peel_to_commit().ok()) .is_some_and(|commit| commit.id().to_string() == current) }) } fn git_state(root: &Path) -> Option<(String, bool)> { let repository = git2::Repository::discover(root).ok()?; let revision = repository .head() .ok()? .peel_to_commit() .ok()? .id() .to_string(); let mut options = git2::StatusOptions::new(); options .include_untracked(true) .recurse_untracked_dirs(true) .include_ignored(false); Some(( revision, repository.statuses(Some(&mut options)).ok()?.is_empty(), )) } fn collect_managed_topics( root: &Path, directory: &Path, paths: &mut Vec, ) -> Result<(), String> { let entries = match fs::read_dir(directory) { Ok(entries) => entries, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), Err(error) => return Err(error.to_string()), }; for entry in entries { let entry = entry.map_err(|error| error.to_string())?; let metadata = fs::symlink_metadata(entry.path()).map_err(|error| error.to_string())?; if metadata.file_type().is_symlink() || entry.file_name().to_string_lossy().starts_with('.') { continue; } if metadata.is_dir() { collect_managed_topics(root, &entry.path(), paths)?; } else if entry .path() .extension() .is_some_and(|extension| extension == "md") { let content = fs::read_to_string(entry.path()).map_err(|error| error.to_string())?; if is_managed_topic(&content) { paths.push( entry .path() .strip_prefix(root) .map_err(|error| error.to_string())? .to_owned(), ); } } } Ok(()) } fn managed_files(vault: &Path) -> Result, String> { let mut paths = ROOT_PAGES.iter().map(PathBuf::from).collect::>(); for directory in TOPIC_DIRS { collect_managed_topics(vault, &vault.join(directory), &mut paths)?; } Ok(paths.into_iter().collect()) } fn is_managed_topic(content: &str) -> bool { Parser::new(PathBuf::new()) .parse_file(Path::new("topic.md"), content) .ok() .and_then(|parsed| parsed.frontmatter) .and_then(|frontmatter| frontmatter.data.get("dev_brain").and_then(Value::as_bool)) == Some(true) } fn validate_page_type(path: &Path, page_type: &str) -> Result<(), String> { let expected = match path.components().next() { Some(Component::Normal(directory)) => directory.to_string_lossy(), _ => return Err(format!("Invalid managed page path: {}", path.display())), }; let valid = match page_type { "project" => expected == "projects", "subsystem" => expected == "subsystems", "concept" => expected == "concepts", "decision" => expected == "decisions", "invariant" => expected == "invariants", "workflow" => expected == "workflows", "skill" => expected == "skills", _ => false, }; valid.then_some(()).ok_or_else(|| { format!( "Page type {page_type} does not match managed path {}.", path.display() ) }) } fn validate_skill_metadata(frontmatter: &TopicFrontmatter, path: &str) -> Result<(), String> { if frontmatter.page_type != "skill" { if frontmatter.name.is_some() || frontmatter.description.is_some() { return Err(format!( "{path} may use name and description only when type is skill." )); } return Ok(()); } let name = frontmatter .name .as_deref() .ok_or_else(|| format!("{path} skill frontmatter requires name."))?; let valid_name = (1..=64).contains(&name.len()) && !name.starts_with('-') && !name.ends_with('-') && !name.contains("--") && name .bytes() .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'); if !valid_name { return Err(format!( "{path} skill name must contain 1-64 lowercase letters, digits, or hyphens without leading, trailing, or consecutive hyphens." )); } let description = frontmatter .description .as_deref() .ok_or_else(|| format!("{path} skill frontmatter requires description."))?; if description.trim().is_empty() || description.chars().count() > 1024 { return Err(format!( "{path} skill description must contain 1-1024 characters." )); } Ok(()) } fn normalize_managed_path(value: &str) -> Result { let path = normalize_relative_path(value)?; if path.extension().is_none_or(|extension| extension != "md") { return Err(format!("Dev Brain manages Markdown files only: {value}")); } if path .components() .any(|component| component.as_os_str().to_string_lossy().starts_with('.')) { return Err(format!( "Hidden paths are outside Dev Brain ownership: {value}" )); } let display = path_to_string(&path); if ROOT_PAGES.contains(&display.as_str()) || is_topic_path(&path) { Ok(path) } else { Err(format!( "Path is outside the managed Dev Brain boundary: {value}" )) } } fn normalize_source_path(value: &str) -> Result { normalize_relative_path(value) .map_err(|_| format!("Invalid project-relative source path: {value}")) } fn normalize_relative_path(value: &str) -> Result { let path = Path::new(value); if path.as_os_str().is_empty() || path.is_absolute() { return Err(format!("Path must be relative: {value}")); } let mut normalized = PathBuf::new(); for component in path.components() { match component { Component::Normal(part) => normalized.push(part), Component::CurDir => {} _ => return Err(format!("Path contains traversal: {value}")), } } if normalized.as_os_str().is_empty() { Err(format!("Path must not be empty: {value}")) } else { Ok(normalized) } } fn normalize_link_path(source: &Path, target: &str) -> Result { let mut path = if target.contains('/') { normalize_relative_path(target)? } else { source .parent() .unwrap_or_else(|| Path::new("")) .join(normalize_relative_path(target)?) }; if path.extension().is_none() { path.set_extension("md"); } Ok(path) } fn is_topic_path(path: &Path) -> bool { path.components().next().is_some_and(|component| { let directory = component.as_os_str().to_string_lossy(); TOPIC_DIRS.contains(&directory.as_ref()) }) } fn path_to_string(path: &Path) -> String { path.to_string_lossy().replace('\\', "/") } fn temporary_sibling(vault: &Path, label: &str) -> Result { let parent = vault .parent() .ok_or_else(|| "Dev Brain vault has no parent directory.".to_owned())?; let stamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos(); Ok(parent.join(format!( ".ds4-dev-brain-{label}-{}-{stamp}", std::process::id() ))) } fn commit_batch(vault: &Path, staging: &Path, changed: &BTreeSet) -> Result<(), String> { for path in changed { validate_destination(vault, path)?; } let backup = temporary_sibling(vault, "backup")?; fs::create_dir(&backup).map_err(|error| error.to_string())?; let mut applied = Vec::new(); let result = (|| { for path in changed { let live = vault.join(path); let old = backup.join(path); let candidate = staging.join(path); if live.exists() { if let Some(parent) = old.parent() { fs::create_dir_all(parent).map_err(|error| error.to_string())?; } fs::rename(&live, &old).map_err(|error| error.to_string())?; } if candidate.exists() { if let Some(parent) = live.parent() { fs::create_dir_all(parent).map_err(|error| error.to_string())?; } if let Err(error) = fs::rename(&candidate, &live) { if old.exists() { let _ = fs::rename(&old, &live); } return Err(error.to_string()); } } applied.push(path.clone()); } Ok(()) })(); if let Err(error) = result { for path in applied.into_iter().rev() { let live = vault.join(&path); let old = backup.join(&path); if live.exists() { let _ = fs::remove_file(&live); } if old.exists() { if let Some(parent) = live.parent() { let _ = fs::create_dir_all(parent); } let _ = fs::rename(old, live); } } let _ = fs::remove_dir_all(&backup); return Err(format!( "Dev Brain publication failed; the previous managed files were restored: {error}" )); } let _ = fs::remove_dir_all(&backup); Ok(()) } fn validate_destination(vault: &Path, path: &Path) -> Result<(), String> { let vault = vault .canonicalize() .map_err(|error| format!("Could not resolve Dev Brain vault: {error}"))?; let mut current = vault.clone(); for component in path.components() { let Component::Normal(component) = component else { return Err(format!("Invalid managed path: {}", path.display())); }; current.push(component); match fs::symlink_metadata(¤t) { Ok(metadata) if metadata.file_type().is_symlink() => { return Err(format!( "Managed Dev Brain path crosses a symbolic link: {}", path.display() )); } Ok(_) => { let resolved = current.canonicalize().map_err(|error| error.to_string())?; if !resolved.starts_with(&vault) { return Err(format!( "Managed Dev Brain path escapes the vault: {}", path.display() )); } } Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(error.to_string()), } } Ok(()) } fn vault_fingerprint(vault: &Path) -> Result, String> { let mut fingerprints = managed_files(vault)? .into_iter() .map(|path| { let full = vault.join(&path); let metadata = full .metadata() .map_err(|error| format!("Could not inspect {}: {error}", path.display()))?; let modified_nanos = metadata .modified() .ok() .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) .map_or(0, |duration| duration.as_nanos()); Ok(Fingerprint { path: path_to_string(&path), modified_nanos, size: metadata.len(), hash: hash_file(&full)?, }) }) .collect::, String>>()?; fingerprints.sort_by(|left, right| left.path.cmp(&right.path)); Ok(fingerprints) } fn hash_file(path: &Path) -> Result { let data = fs::read(path).map_err(|error| format!("Could not hash {}: {error}", path.display()))?; Ok(hash_bytes(&data)) } fn hash_bytes(data: &[u8]) -> String { let digest = Sha256::digest(data); digest.iter().map(|byte| format!("{byte:02x}")).collect() } fn fts_query(query: &str) -> String { query .split_whitespace() .filter(|term| !term.is_empty()) .map(|term| format!("\"{}\"", term.replace('"', "\"\""))) .collect::>() .join(" OR ") } #[cfg(test)] mod tests { use super::*; use std::sync::atomic::{AtomicU64, Ordering}; static FIXTURE_ID: AtomicU64 = AtomicU64::new(1); struct Fixture { root: PathBuf, vault: PathBuf, project: PathBuf, } impl Fixture { fn new() -> Self { let root = std::env::temp_dir().join(format!( "ds4-dev-brain-{}-{}-{}", std::process::id(), SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(), FIXTURE_ID.fetch_add(1, Ordering::Relaxed) )); let vault = root.join("vault"); let project = root.join("project"); fs::create_dir_all(vault.join(".obsidian")).unwrap(); fs::create_dir_all(&project).unwrap(); fs::write(project.join("source.rs"), "pub fn answer() -> u8 { 42 }\n").unwrap(); Self { root, vault, project, } } fn config(&self) -> DevBrainConfig { DevBrainConfig { enabled: true, vault_path: Some(self.vault.to_string_lossy().into_owned()), } } fn projects(&self) -> Vec { vec![Project { id: 1, name: "Fixture".into(), path: self.project.to_string_lossy().into_owned(), collapsed: false, }] } fn brain(&self) -> DevBrain { DevBrain::open(&self.config(), &self.projects()).unwrap() } fn topic(&self, status: &str, hash: &str, body: &str) -> String { format!( "---\ndev_brain: true\ntype: concept\nproject: Fixture\nstatus: {status}\nverified_at: 2026-07-27T12:00:00Z\nsources:\n - project: Fixture\n path: source.rs\n hash: {hash}\n---\n\n# Answer\n\n{body}\n" ) } fn skill( &self, status: &str, hash: &str, name: &str, description: &str, title: &str, ) -> String { format!( "---\ndev_brain: true\ntype: skill\nname: {name}\ndescription: {description}\nproject: Fixture\nstatus: {status}\nverified_at: 2026-07-27T12:00:00Z\nsources:\n - project: Fixture\n path: source.rs\n hash: {hash}\n---\n\n# {title}\n\nRead the source and follow its release procedure.\n" ) } } impl Drop for Fixture { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.root); } } fn source_hash(fixture: &Fixture) -> String { hash_file(&fixture.project.join("source.rs")).unwrap() } fn write_topic(fixture: &Fixture, brain: &mut DevBrain, topic: &str) -> Result { fs::write(fixture.vault.join("concepts/answer.md"), topic).unwrap(); brain.validate() } #[test] fn path_confinement_and_unrelated_notes_are_preserved() { let fixture = Fixture::new(); let brain = fixture.brain(); let info = brain.info(); assert!(info.contains("Dev Brain wiki folder (not a project)")); assert!(info.contains(&format!( "- Fixture: {}", fixture.project.canonicalize().unwrap().display() ))); fs::write(fixture.vault.join("concepts/private.md"), "# Private\n").unwrap(); assert!(!brain.allows_tool_write(&fixture.vault.join("concepts/private.md"))); assert!(brain.allows_tool_write(&brain.folder().join("concepts/new.md"))); assert!(brain.allows_tool_write(&brain.folder().join("skills/new.md"))); assert!(!brain.allows_tool_write(&fixture.root.join("outside.md"))); } #[test] fn skill_index_migration_does_not_overwrite_an_unrelated_root_note() { let fixture = Fixture::new(); for (path, content) in [ ("purpose.md", DEFAULT_PURPOSE), ("schema.md", DEFAULT_SCHEMA), ("index.md", DEFAULT_INDEX), ("log.md", DEFAULT_LOG), ] { fs::write(fixture.vault.join(path), content).unwrap(); } fs::write(fixture.vault.join("skills.md"), "# Personal skills\n").unwrap(); let error = DevBrain::open(&fixture.config(), &fixture.projects()) .err() .unwrap(); assert!(error.contains("unrelated skills.md")); assert_eq!( fs::read_to_string(fixture.vault.join("skills.md")).unwrap(), "# Personal skills\n" ); } #[test] fn verified_skills_are_indexed_and_injected_on_demand() { let fixture = Fixture::new(); let brain = fixture.brain(); assert!(fixture.vault.join("skills").is_dir()); assert_eq!( fs::read_to_string(fixture.vault.join("skills.md")).unwrap(), DEFAULT_SKILLS ); drop(brain); fs::remove_file(fixture.vault.join("skills.md")).unwrap(); let mut brain = fixture.brain(); assert_eq!( fs::read_to_string(fixture.vault.join("skills.md")).unwrap(), DEFAULT_SKILLS ); let hash = source_hash(&fixture); fs::write( fixture.vault.join("skills/release.md"), fixture.skill( "verified", &hash, "review-release", "Verify a release candidate. Use before publishing a release.", "Review release", ), ) .unwrap(); fs::write( fixture.vault.join("skills/draft.md"), fixture.skill( "needs-review", &hash, "draft-release", "Draft release notes. Use when preparing a release.", "Draft release", ), ) .unwrap(); brain.validate().unwrap(); let index = fs::read_to_string(fixture.vault.join("skills.md")).unwrap(); assert!(index.contains("[[skills/release.md|review-release]]")); assert!(index.contains("Verify a release candidate")); assert!(!index.contains("draft-release")); let prompt = skills_prompt(&fixture.config(), &fixture.projects()).unwrap(); assert!(prompt.contains(&fixture.vault.to_string_lossy().to_string())); assert!(prompt.contains("[[skills/release.md|review-release]]")); fs::write(fixture.project.join("source.rs"), "changed\n").unwrap(); let prompt = skills_prompt(&fixture.config(), &fixture.projects()).unwrap(); assert!(!prompt.contains("review-release")); assert_eq!( fs::read_to_string(fixture.vault.join("skills.md")).unwrap(), DEFAULT_SKILLS ); } #[test] fn skill_validation_and_default_guide_restore_are_strict() { let fixture = Fixture::new(); let mut brain = fixture.brain(); let hash = source_hash(&fixture); fs::write( fixture.vault.join("skills/invalid.md"), fixture.skill( "verified", &hash, "Invalid_Name", "Invalid name should be rejected.", "Invalid", ), ) .unwrap(); assert!(brain.validate().unwrap_err().contains("skill name")); fs::remove_file(fixture.vault.join("skills/invalid.md")).unwrap(); fs::write(fixture.vault.join("purpose.md"), "custom purpose\n").unwrap(); fs::write(fixture.vault.join("schema.md"), "custom schema\n").unwrap(); fs::write(fixture.vault.join("log.md"), "preserved log\n").unwrap(); fs::write(fixture.vault.join("index.md"), "preserved index\n").unwrap(); restore_default_guides(&fixture.vault).unwrap(); assert_eq!( fs::read_to_string(fixture.vault.join("purpose.md")).unwrap(), DEFAULT_PURPOSE ); assert_eq!( fs::read_to_string(fixture.vault.join("schema.md")).unwrap(), DEFAULT_SCHEMA ); assert_eq!( fs::read_to_string(fixture.vault.join("log.md")).unwrap(), "preserved log\n" ); assert_eq!( fs::read_to_string(fixture.vault.join("index.md")).unwrap(), "preserved index\n" ); } #[test] fn parser_and_index_rebuild_ignore_links_in_code() { let fixture = Fixture::new(); let mut brain = fixture.brain(); let topic = fixture.topic( "verified", &source_hash(&fixture), "The durable answer is searchable.\n\n`[[Missing]]`\n\n```md\n[[Also Missing]]\n```", ); write_topic(&fixture, &mut brain, &topic).unwrap(); let result = brain.search("durable searchable", 10, true).unwrap(); assert!(result.contains("concepts/answer.md")); assert!(result.contains("Fixture:source.rs")); } #[test] fn source_hash_drift_excludes_page_from_authoritative_queries() { let fixture = Fixture::new(); let mut brain = fixture.brain(); write_topic( &fixture, &mut brain, &fixture.topic( "verified", &source_hash(&fixture), "Unique drift knowledge.", ), ) .unwrap(); assert!( brain .search("Unique drift knowledge", 10, true) .unwrap() .contains("concepts/answer.md") ); fs::write(fixture.project.join("source.rs"), "changed\n").unwrap(); assert!( !brain .search("Unique drift knowledge", 10, true) .unwrap() .contains("concepts/answer.md") ); assert!( brain .search("Unique drift knowledge", 10, false) .unwrap() .contains("status: stale") ); } #[test] fn source_versions_follow_that_file_instead_of_the_whole_worktree() { let fixture = Fixture::new(); fs::write(fixture.project.join("unrelated.rs"), "unrelated\n").unwrap(); let repository = git2::Repository::init(&fixture.project).unwrap(); let mut index = repository.index().unwrap(); index.add_path(Path::new("source.rs")).unwrap(); index.add_path(Path::new("unrelated.rs")).unwrap(); let tree_id = index.write_tree().unwrap(); index.write().unwrap(); let tree = repository.find_tree(tree_id).unwrap(); let signature = git2::Signature::now("DS4Server", "ds4@example.invalid").unwrap(); repository .commit(Some("HEAD"), &signature, &signature, "Initial", &tree, &[]) .unwrap(); drop(tree); drop(repository); let revision = git_state(&fixture.project).unwrap().0; assert!(git_revision_matches( &fixture.project, &revision[..7], &revision )); assert!(!git_revision_matches( &fixture.project, &revision[..6], &revision )); let topic = fixture .topic("verified", &"0".repeat(64), "Revision-backed knowledge.") .replace( &format!("hash: {}", "0".repeat(64)), &format!("revision: {}", &revision[..7]), ); let mut brain = fixture.brain(); write_topic(&fixture, &mut brain, &topic).unwrap(); assert!( brain .search("Revision-backed knowledge", 10, true) .unwrap() .contains("concepts/answer.md") ); fs::write(fixture.project.join("unrelated.rs"), "dirty elsewhere\n").unwrap(); assert!( brain .search("Revision-backed knowledge", 10, true) .unwrap() .contains("concepts/answer.md") ); fs::write(fixture.project.join("source.rs"), "dirty\n").unwrap(); assert!( brain .search("Revision-backed knowledge", 10, false) .unwrap() .contains("status: stale") ); let topic = fixture.topic( "verified", &source_hash(&fixture), "Hash-backed dirty knowledge.", ); write_topic(&fixture, &mut brain, &topic).unwrap(); assert!( brain .search("Hash-backed dirty knowledge", 10, true) .unwrap() .contains("concepts/answer.md") ); } #[test] fn validation_keeps_broken_links_editable_and_reports_provenance_errors() { let fixture = Fixture::new(); let mut brain = fixture.brain(); let bad_topic = fixture.topic( "verified", &source_hash(&fixture), "This has a [[Missing Page]].", ); let result = write_topic(&fixture, &mut brain, &bad_topic).unwrap(); assert!(result.contains("Broken link")); assert!(fixture.vault.join("concepts/answer.md").exists()); assert!( fs::read_to_string(fixture.vault.join("index.md")) .unwrap() .contains("[[concepts/answer.md|Answer]]") ); let missing = fixture .topic("verified", &source_hash(&fixture), "The linked detail.") .replace("# Answer", "# Missing Page"); fs::write(fixture.vault.join("concepts/missing.md"), missing).unwrap(); assert!(brain.validate().unwrap().contains("No link warnings")); let invalid = fixture.topic("verified", &"0".repeat(64), "Wrong evidence."); fs::write(fixture.vault.join("concepts/answer.md"), invalid).unwrap(); assert!(brain.validate().is_err()); let malformed = fixture .topic("verified", &source_hash(&fixture), "Malformed.") .replace("verified_at: 2026-07-27T12:00:00Z\n", ""); fs::write(fixture.vault.join("concepts/answer.md"), malformed).unwrap(); drop(brain); let mut brain = fixture.brain(); assert!(brain.info().contains("needs repair")); fs::write( fixture.vault.join("concepts/answer.md"), fixture.topic("verified", &source_hash(&fixture), "Repaired evidence."), ) .unwrap(); assert!(brain.validate().unwrap().contains("structurally valid")); } }