Files
DS4Server/src/dev_brain.rs
2026-08-29 20:43:56 +02:00

2189 lines
80 KiB
Rust

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. Validation is semantic freshness work, not revision bookkeeping: for each reported drifted source, re-read that file, check whether its changes alter the page's documented findings, update the page when needed, then update only that source record to the newest commit that changed that file. For a large revision-backed source, use `git diff <recorded-revision> -- path` to focus on what changed before reading the necessary current context. Also inspect the commits affecting the file since its recorded revision. If code disappeared, do not assume its behavior was deleted: inspect the full change commits and search the current project, callers, and tests for a rename, replacement, or move to another file; update the page's source list when evidence moved. Never copy the repository's overall HEAD into every source revision. Repeat for every drifted source, then call dev_brain_validate again. 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."#;
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<!-- Append material wiki updates and their source revisions below. -->\n";
const INCLUDED_SKILLS: [&str; 1] = ["skills/create-dev-brain-skill.md"];
#[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<String>,
#[serde(default)]
revision: Option<String>,
#[serde(default)]
hash: Option<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct TopicFrontmatter {
dev_brain: bool,
#[serde(rename = "type")]
page_type: String,
#[serde(default)]
built_in: bool,
project: String,
status: String,
verified_at: String,
sources: Vec<SourceRecord>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
}
#[derive(Clone)]
struct Page {
path: String,
title: String,
page_type: String,
status: String,
verified_at: String,
headings: Vec<String>,
tags: Vec<String>,
links: Vec<RawLink>,
sources: Vec<SourceRecord>,
body: String,
content: String,
skill_name: Option<String>,
skill_description: Option<String>,
}
#[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<RegisteredProject>,
connection: SqliteConnection,
fingerprint: Vec<Fingerprint>,
index_error: Option<String>,
}
impl DevBrain {
pub(crate) fn open(config: &DevBrainConfig, projects: &[Project]) -> Result<Self, String> {
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<String, String> {
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::<Text, _>(&query)
.bind::<BigInt, _>(i64::try_from(limit.clamp(1, 50)).unwrap_or(50))
.load::<SearchRow>(&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<String, String> {
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::<Text, _>(&normalized)
.bind::<Text, _>(value)
.get_result::<PageRow>(&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<String, String> {
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::<Text, _>(&page.path)
.bind::<Text, _>(&page.title)
.bind::<Text, _>(&page.page_type)
.bind::<Text, _>(&page.status)
.bind::<Text, _>(&page.verified_at)
.bind::<Text, _>(&page.body)
.bind::<Text, _>(&page.content)
.execute(connection)?;
diesel::sql_query(
"INSERT INTO page_fts (path, title, body, tags) VALUES (?, ?, ?, ?)",
)
.bind::<Text, _>(&page.path)
.bind::<Text, _>(&page.title)
.bind::<Text, _>(&page.body)
.bind::<Text, _>(page.tags.join(" "))
.execute(connection)?;
for heading in &page.headings {
diesel::sql_query(
"INSERT INTO headings (page_path, heading) VALUES (?, ?)",
)
.bind::<Text, _>(&page.path)
.bind::<Text, _>(heading)
.execute(connection)?;
}
for tag in &page.tags {
diesel::sql_query("INSERT INTO tags (page_path, tag) VALUES (?, ?)")
.bind::<Text, _>(&page.path)
.bind::<Text, _>(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::<Text, _>(&page.path)
.bind::<Text, _>(&source.project)
.bind::<Text, _>(&source.path)
.bind::<Text, _>(source.symbol.as_deref().unwrap_or(""))
.bind::<Text, _>(version)
.execute(connection)?;
}
}
for (source, target, kind) in &resolved {
diesel::sql_query(
"INSERT INTO links (source_path, target_path, kind) VALUES (?, ?, ?)",
)
.bind::<Text, _>(source)
.bind::<Text, _>(target)
.bind::<Text, _>(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<Vec<Fingerprint>, 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::<SourceKeyRow>(&mut self.connection)
.map_err(|error| error.to_string())?;
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: format!(
"{}:{:?}",
hash_file(&full)?,
git_source_is_clean(&project.root, &full)
),
});
}
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::<Text, _>(path)
.load::<EvidenceRow>(&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<Vec<String>, 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::<Text, _>(path)
.bind::<Text, _>(path)
.bind::<Text, _>(path)
.load::<PathRow>(&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<String, String> {
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 skills = publish_skills_index(&vault, &projects)?;
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<Vec<RegisteredProject>, 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::<Result<Vec<_>, 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, projects: &[Project]) -> 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(),
);
}
let purpose = read_default_asset("purpose.md")?;
let schema = read_default_asset("schema.md")?;
let skills = read_included_skills()?;
let mut files = vec![
("purpose.md", purpose.as_str()),
("schema.md", schema.as_str()),
];
files.extend(
skills
.iter()
.map(|(path, content)| (*path, content.as_str())),
);
publish_defaults(&vault, &files)?;
let projects = registered_projects(projects)?;
publish_skills_index(&vault, &projects)?;
Ok(())
}
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 {
let purpose = read_default_asset("purpose.md")?;
let schema = read_default_asset("schema.md")?;
let skills = read_included_skills()?;
let mut files = vec![
("purpose.md", purpose.as_str()),
("schema.md", schema.as_str()),
("index.md", DEFAULT_INDEX),
("skills.md", DEFAULT_SKILLS),
("log.md", DEFAULT_LOG),
];
files.extend(
skills
.iter()
.map(|(path, content)| (*path, content.as_str())),
);
publish_defaults(vault, &files)?;
} 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<!-- Generated by dev_brain_validate; edit skill pages, not this list. -->\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 {
let destination = staging.join(path);
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
}
fs::write(destination, content).map_err(|error| error.to_string())?;
changed.insert(PathBuf::from(path));
}
commit_batch(vault, &staging, &changed)
})();
let _ = fs::remove_dir_all(staging);
result
}
fn read_default_asset(path: &str) -> Result<String, String> {
let bundled = std::env::current_exe().ok().and_then(|path| {
path.parent()?
.parent()
.map(|path| path.join("Resources/dev-brain"))
});
let checkout = Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/dev-brain");
let directory = bundled
.filter(|path| path.join("purpose.md").is_file())
.unwrap_or(checkout);
let asset = directory.join(path);
fs::read_to_string(&asset).map_err(|error| {
format!(
"Could not read bundled Dev Brain asset {}: {error}",
asset.display()
)
})
}
fn read_included_skills() -> Result<Vec<(&'static str, String)>, String> {
INCLUDED_SKILLS
.iter()
.map(|path| read_default_asset(path).map(|content| (*path, content)))
.collect()
}
fn publish_skills_index(vault: &Path, projects: &[RegisteredProject]) -> Result<String, String> {
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(skills)
}
pub(crate) fn validate_vault(path: &Path) -> Result<PathBuf, String> {
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<Vec<Page>, String> {
let parser = Parser::new(vault_root.to_owned());
let mut paths = ROOT_PAGES.iter().map(PathBuf::from).collect::<Vec<_>>();
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::<Vec<_>>();
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::<TopicFrontmatter>(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)?;
validate_built_in_skill(&frontmatter, &relative, &content)?;
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."))?;
let drifted = if frontmatter.built_in {
Vec::new()
} else {
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
));
}
frontmatter
.sources
.iter()
.map(|source| {
validate_source(source, projects).map(|fresh| {
(!fresh).then(|| format!("{}:{}", source.project, source.path))
})
})
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.collect::<Vec<_>>()
};
if publishing && frontmatter.status == "verified" && !drifted.is_empty() {
return Err(format!(
"{relative} cannot be published as verified because its evidence has drifted:\n- {}\nRe-read each listed source and check whether its changes alter the page's documented findings. For a large revision-backed source, use `git diff <recorded-revision> -- path` to focus on what changed, then inspect the necessary current context and the commits affecting that path since the recorded revision. If code disappeared, inspect the full change commits and search the current project, callers, and tests for a rename, replacement, or move to another file before concluding the behavior was removed; update the cited source list when evidence moved. Update the knowledge when needed, then set only that source's revision to `git log -1 --format=%H -- path` (not the repository's overall HEAD), or its hash when required. Re-run dev_brain_validate after every listed source is current.",
drifted.join("\n- ")
));
}
let status = if frontmatter.status == "verified" && !drifted.is_empty() {
"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<String>) {
let by_path = pages
.iter()
.map(|page| (page.path.clone(), page))
.collect::<HashMap<_, _>>();
let mut identities: HashMap<String, Vec<&Page>> = 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::<Vec<_>>();
if topics.is_empty() {
return DEFAULT_INDEX.to_owned();
}
let mut output = String::from(
"# Dev Brain index\n\n<!-- Generated by dev_brain_validate; edit topic pages, not this list. -->\n",
);
for directory in TOPIC_DIRS {
let mut group = topics
.iter()
.filter(|page| page.path.starts_with(&format!("{directory}/")))
.copied()
.collect::<Vec<_>>();
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::<Vec<_>>();
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<!-- Generated by dev_brain_validate; edit skill pages, not this list. -->\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::<Vec<_>>()
.join(" ")
));
}
output
}
fn title_case(value: &str) -> String {
let mut characters = value.chars();
characters
.next()
.map(|first| first.to_uppercase().collect::<String>() + characters.as_str())
.unwrap_or_default()
}
fn resolve_link(
source: &Page,
link: &RawLink,
by_path: &HashMap<String, &Page>,
identities: &HashMap<String, Vec<&Page>>,
content_root: &Path,
vault_root: &Path,
) -> Result<Option<String>, 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<bool, String> {
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_is_clean(&project.root, &resolved) == Some(true)
&& git_revision_matches_source(&project.root, &relative, &resolved, revision));
}
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_is_clean(&project.root, &resolved) != Some(true))
}
fn git_source_is_clean(root: &Path, source: &Path) -> Option<bool> {
let repository = git2::Repository::discover(root).ok()?;
let workdir = repository.workdir()?.canonicalize().ok()?;
let relative = source.strip_prefix(workdir).ok()?;
Some(repository.status_file(relative).ok()? == git2::Status::CURRENT)
}
fn git_revision_matches_source(
root: &Path,
relative: &Path,
source: &Path,
revision: &str,
) -> bool {
if !(7..=40).contains(&revision.len())
|| !revision
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return false;
}
let Some(matches) = (|| {
let repository = git2::Repository::discover(root).ok()?;
let workdir = repository.workdir()?.canonicalize().ok()?;
let project = root.canonicalize().ok()?;
let path = project.strip_prefix(workdir).ok()?.join(relative);
let commit = repository
.find_commit(latest_source_revision(&repository, &path)?)
.ok()?;
if !commit.id().to_string().starts_with(revision) {
return Some(false);
}
let entry = commit.tree().ok()?.get_path(&path).ok()?;
let blob = repository.find_blob(entry.id()).ok()?;
Some(fs::read(source).ok()?.as_slice() == blob.content())
})() else {
return false;
};
matches
}
fn latest_source_revision(repository: &git2::Repository, path: &Path) -> Option<git2::Oid> {
let mut revisions = repository.revwalk().ok()?;
revisions.push_head().ok()?;
revisions
.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)
.ok()?;
for revision in revisions.flatten() {
let commit = repository.find_commit(revision).ok()?;
let current = commit
.tree()
.ok()?
.get_path(path)
.ok()
.map(|entry| entry.id());
let changed = if commit.parent_count() == 0 {
current.is_some()
} else {
commit.parents().all(|parent| {
parent
.tree()
.ok()
.and_then(|tree| tree.get_path(path).ok().map(|entry| entry.id()))
!= current
})
};
if changed {
return Some(revision);
}
}
None
}
fn collect_managed_topics(
root: &Path,
directory: &Path,
paths: &mut Vec<PathBuf>,
) -> 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<BTreeSet<PathBuf>, String> {
let mut paths = ROOT_PAGES.iter().map(PathBuf::from).collect::<Vec<_>>();
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.built_in || frontmatter.name.is_some() || frontmatter.description.is_some() {
return Err(format!(
"{path} may use built_in, 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 validate_built_in_skill(
frontmatter: &TopicFrontmatter,
path: &str,
content: &str,
) -> Result<(), String> {
if !frontmatter.built_in {
return Ok(());
}
if !INCLUDED_SKILLS.contains(&path) {
return Err(format!("{path} is not an included Dev Brain skill."));
}
if read_default_asset(path)? != content {
return Err(format!(
"{path} differs from the included Dev Brain skill. Restore the built-in guidance from Preferences."
));
}
Ok(())
}
fn normalize_managed_path(value: &str) -> Result<PathBuf, String> {
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<PathBuf, String> {
normalize_relative_path(value)
.map_err(|_| format!("Invalid project-relative source path: {value}"))
}
fn normalize_relative_path(value: &str) -> Result<PathBuf, String> {
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<PathBuf, String> {
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<PathBuf, String> {
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<PathBuf>) -> 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(&current) {
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<Vec<Fingerprint>, 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::<Result<Vec<_>, String>>()?;
fingerprints.sort_by(|left, right| left.path.cmp(&right.path));
Ok(fingerprints)
}
fn hash_file(path: &Path) -> Result<String, String> {
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::<Vec<_>>()
.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<Project> {
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<String, String> {
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();
let purpose = read_default_asset("purpose.md").unwrap();
let schema = read_default_asset("schema.md").unwrap();
for (path, content) in [
("purpose.md", purpose.as_str()),
("schema.md", schema.as_str()),
("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
);
assert_eq!(
fs::read_to_string(fixture.vault.join(INCLUDED_SKILLS[0])).unwrap(),
read_default_asset(INCLUDED_SKILLS[0]).unwrap()
);
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!(prompt.contains("create-dev-brain-skill"));
assert!(
fs::read_to_string(fixture.vault.join("skills.md"))
.unwrap()
.contains("create-dev-brain-skill")
);
}
#[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(INCLUDED_SKILLS[0]),
format!("{}\n", read_default_asset(INCLUDED_SKILLS[0]).unwrap()),
)
.unwrap();
assert!(
brain
.validate()
.unwrap_err()
.contains("differs from the included Dev Brain skill")
);
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(INCLUDED_SKILLS[0]),
"custom included skill\n",
)
.unwrap();
let custom_skill = fixture.skill(
"verified",
&hash,
"custom-skill",
"Keep custom instructions. Use for custom work.",
"Custom skill",
);
fs::write(fixture.vault.join("skills/custom.md"), &custom_skill).unwrap();
fs::write(fixture.vault.join("log.md"), "preserved log\n").unwrap();
fs::write(fixture.vault.join("index.md"), "preserved index\n").unwrap();
fs::write(fixture.vault.join("skills.md"), "stale skill index\n").unwrap();
restore_default_guides(&fixture.vault, &fixture.projects()).unwrap();
assert_eq!(
fs::read_to_string(fixture.vault.join("purpose.md")).unwrap(),
read_default_asset("purpose.md").unwrap()
);
assert_eq!(
fs::read_to_string(fixture.vault.join("schema.md")).unwrap(),
read_default_asset("schema.md").unwrap()
);
assert_eq!(
fs::read_to_string(fixture.vault.join(INCLUDED_SKILLS[0])).unwrap(),
read_default_asset(INCLUDED_SKILLS[0]).unwrap()
);
assert_eq!(
fs::read_to_string(fixture.vault.join("skills/custom.md")).unwrap(),
custom_skill
);
let skills = fs::read_to_string(fixture.vault.join("skills.md")).unwrap();
assert!(skills.contains("create-dev-brain-skill"));
assert!(skills.contains("custom-skill"));
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 validation_names_every_drifted_source_and_requires_semantic_refresh() {
let fixture = Fixture::new();
fs::write(fixture.project.join("second.rs"), "second\n").unwrap();
let source_hash = source_hash(&fixture);
let second_hash = hash_file(&fixture.project.join("second.rs")).unwrap();
let topic = fixture
.topic("verified", &source_hash, "Source-backed findings.")
.replace(
&format!(" hash: {source_hash}\n"),
&format!(
" hash: {source_hash}\n - project: Fixture\n path: second.rs\n hash: {second_hash}\n"
),
);
let mut brain = fixture.brain();
write_topic(&fixture, &mut brain, &topic).unwrap();
fs::write(fixture.project.join("source.rs"), "changed\n").unwrap();
fs::write(fixture.project.join("second.rs"), "also changed\n").unwrap();
let error = brain.validate().unwrap_err();
assert!(error.contains("- Fixture:source.rs\n- Fixture:second.rs"));
assert!(error.contains("check whether its changes alter the page's documented findings"));
assert!(error.contains("git diff <recorded-revision> -- path"));
assert!(error.contains("search the current project, callers, and tests"));
assert!(error.contains("not the repository's overall HEAD"));
}
#[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();
let revision = repository
.commit(Some("HEAD"), &signature, &signature, "Initial", &tree, &[])
.unwrap()
.to_string();
drop(tree);
drop(repository);
assert!(git_revision_matches_source(
&fixture.project,
Path::new("source.rs"),
&fixture.project.join("source.rs"),
&revision[..7]
));
assert!(!git_revision_matches_source(
&fixture.project,
Path::new("source.rs"),
&fixture.project.join("source.rs"),
&revision[..6]
));
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")
);
let fingerprint = brain.current_fingerprint().unwrap();
fs::write(fixture.project.join("unrelated.rs"), "dirty elsewhere\n").unwrap();
let repository = git2::Repository::open(&fixture.project).unwrap();
let mut index = repository.index().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 parent = repository.head().unwrap().peel_to_commit().unwrap();
let unrelated_revision = repository
.commit(
Some("HEAD"),
&signature,
&signature,
"Unrelated",
&tree,
&[&parent],
)
.unwrap()
.to_string();
drop(parent);
drop(tree);
drop(repository);
assert!(!git_revision_matches_source(
&fixture.project,
Path::new("source.rs"),
&fixture.project.join("source.rs"),
&unrelated_revision
));
assert!(brain.current_fingerprint().unwrap() == fingerprint);
brain.validate().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 repository = git2::Repository::open(&fixture.project).unwrap();
let mut index = repository.index().unwrap();
index.add_path(Path::new("source.rs")).unwrap();
let tree_id = index.write_tree().unwrap();
index.write().unwrap();
let tree = repository.find_tree(tree_id).unwrap();
let parent = repository.head().unwrap().peel_to_commit().unwrap();
repository
.commit(
Some("HEAD"),
&signature,
&signature,
"Source changed",
&tree,
&[&parent],
)
.unwrap();
drop(parent);
drop(tree);
drop(repository);
assert!(
brain
.validate()
.unwrap_err()
.contains("evidence has drifted")
);
fs::write(fixture.project.join("source.rs"), "dirty again\n").unwrap();
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"));
}
}