use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::fs;
use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
use anyhow::{Context, bail};
use serde_json::{Map, Value};
use crate::{adr, fsutil, governance, install, memory, policy, root, standard};
const BOOT_REL: &str = ".doctrine/state/boot.md";
const BOOT_HEADER: &str =
"<!-- Generated by `doctrine boot` — do not edit; regenerated each session -->";
const GOVERNANCE_REL: &str = ".doctrine/governance.md";
struct Section {
heading: String,
body: String,
}
enum SourceKind {
ExecPath,
Static(&'static str),
Governance,
GovRows(&'static governance::GovKind, &'static [&'static str]),
Memories,
}
fn boot_sequence() -> Vec<(&'static str, SourceKind)> {
vec![
(
"Routing & Process",
SourceKind::Static("routing-process.md"),
),
("Governance (project)", SourceKind::Governance),
(
"Accepted ADRs",
SourceKind::GovRows(&adr::ADR_KIND, &["accepted"]),
),
(
"Active Policies",
SourceKind::GovRows(&policy::POLICY_KIND, &["required"]),
),
(
"Active Standards",
SourceKind::GovRows(&standard::STANDARD_KIND, &["default", "required"]),
),
("Memory", SourceKind::Memories),
("Invoking doctrine", SourceKind::ExecPath),
]
}
fn render_boot(sections: &[Section]) -> String {
let mut out = String::new();
out.push_str(BOOT_HEADER);
out.push('\n');
out.push_str("# Doctrine Boot Context\n");
for section in sections {
out.push_str("\n## ");
out.push_str(§ion.heading);
out.push('\n');
out.push_str(§ion.body);
out.push('\n');
}
out
}
fn marker(label: &str) -> String {
format!("<!-- {label}: not yet populated -->")
}
fn gov_nudge(heading: &str) -> String {
let kind = heading
.strip_prefix("Active ")
.unwrap_or(heading)
.to_lowercase();
format!("<!-- No active {kind} yet. See mem.signpost.doctrine.policies-standards -->")
}
fn produce(heading: &str, kind: &SourceKind, root: &Path, exec: &Path) -> Section {
let body = match kind {
SourceKind::ExecPath => exec.display().to_string(),
SourceKind::GovRows(kind, set) => {
let produced = governance::list_rows(
kind,
root,
crate::listing::ListArgs {
status: set.iter().map(|s| (*s).to_string()).collect(),
..Default::default()
},
);
match (&produced, heading) {
(Ok(rows), "Active Policies" | "Active Standards") if rows.trim().is_empty() => {
gov_nudge(heading)
}
_ => section_or_marker(heading, produced),
}
}
SourceKind::Memories => section_or_marker(
heading,
memory::list_rows(
root,
Some(memory::MemoryType::Signpost),
crate::listing::ListArgs {
status: vec!["active".to_string()],
..Default::default()
},
),
),
SourceKind::Static(name) => section_or_marker(heading, install::asset_text(name)),
SourceKind::Governance => section_or_marker(
heading,
fs::read_to_string(root.join(GOVERNANCE_REL)).map_err(anyhow::Error::from),
),
};
Section {
heading: heading.to_string(),
body,
}
}
fn section_or_marker(heading: &str, produced: anyhow::Result<String>) -> String {
match produced {
Ok(rows) if !rows.trim().is_empty() => rows.trim_end().to_string(),
_ => marker(heading),
}
}
fn write_if_changed(path: &Path, content: &str) -> anyhow::Result<bool> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
if fs::read_to_string(path).ok().as_deref() == Some(content) {
return Ok(false);
}
fsutil::write_atomic(path, content.as_bytes())?;
Ok(true)
}
fn build_sections(root: &Path, exec: &Path) -> Vec<Section> {
boot_sequence()
.iter()
.map(|(heading, kind)| produce(heading, kind, root, exec))
.collect()
}
fn regenerate(root: &Path, exec: &Path) -> anyhow::Result<bool> {
let content = render_boot(&build_sections(root, exec));
write_if_changed(&root.join(BOOT_REL), &content)
}
fn is_marker(section: &Section) -> bool {
section.body == marker(§ion.heading) || section.body == gov_nudge(§ion.heading)
}
#[derive(Debug, PartialEq, Eq)]
struct CheckReport {
stale: bool,
marker_sections: Vec<String>,
}
impl CheckReport {
fn is_clean(&self) -> bool {
!self.stale && self.marker_sections.is_empty()
}
}
fn boot_check(root: &Path, exec: &Path) -> CheckReport {
let sections = build_sections(root, exec);
let recomputed = render_boot(§ions);
let on_disk = fs::read_to_string(root.join(BOOT_REL)).ok();
CheckReport {
stale: on_disk.as_deref() != Some(recomputed.as_str()),
marker_sections: sections
.iter()
.filter(|s| is_marker(s))
.map(|s| s.heading.clone())
.collect(),
}
}
pub(crate) fn run(path: Option<PathBuf>) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let exec = std::env::current_exe().context("Failed to resolve the doctrine executable path")?;
let dest = root.join(BOOT_REL);
let verb = if regenerate(&root, &exec)? {
"Wrote"
} else {
"Unchanged"
};
writeln!(io::stdout(), "{verb} {}", dest.display())?;
Ok(())
}
pub(crate) fn run_check(path: Option<PathBuf>) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let exec = std::env::current_exe().context("Failed to resolve the doctrine executable path")?;
let report = boot_check(&root, &exec);
let dest = root.join(BOOT_REL);
let mut out = io::stdout();
if report.is_clean() {
writeln!(out, "clean {} — on-disk snapshot in sync", dest.display())?;
return Ok(());
}
if report.stale {
writeln!(
out,
"stale {} — differs from current governance; run `doctrine boot`, then `/clear` or restart",
dest.display()
)?;
}
if !report.marker_sections.is_empty() {
writeln!(
out,
"unpopulated sections: {}",
report.marker_sections.join(", ")
)?;
}
Ok(())
}
const SETTINGS_REL: &str = ".claude/settings.local.json";
const SESSION_MATCHER: &str = "startup|clear";
#[derive(Debug, PartialEq, Eq)]
enum Harness {
Claude,
Codex,
}
fn parse_harness(s: &str) -> anyhow::Result<Harness> {
if s.eq_ignore_ascii_case("claude") {
Ok(Harness::Claude)
} else if s.eq_ignore_ascii_case("codex") {
Ok(Harness::Codex)
} else {
bail!("Unknown harness '{s}'. Known harnesses: claude, codex.")
}
}
fn harness_label(h: &Harness) -> &'static str {
match h {
Harness::Claude => "claude",
Harness::Codex => "codex",
}
}
fn import_targets(h: &Harness, root: &Path) -> Vec<PathBuf> {
match h {
Harness::Claude => vec![root.join("CLAUDE.md")],
Harness::Codex => vec![root.join("AGENTS.md")],
}
}
fn resolve_harnesses(explicit: &[String], root: &Path) -> anyhow::Result<Vec<Harness>> {
if !explicit.is_empty() {
return explicit.iter().map(|s| parse_harness(s)).collect();
}
let claude = root.join(".claude").exists();
let mut found = Vec::new();
if claude {
found.push(Harness::Claude);
}
let agents = root.join("AGENTS.md");
let agents_is_claude_alias = claude
&& agents.exists()
&& resolve_target(&agents) == resolve_target(&root.join("CLAUDE.md"));
if root.join(".codex").exists() || (agents.exists() && !agents_is_claude_alias) {
found.push(Harness::Codex);
}
if found.is_empty() {
bail!(
"No --agent given and no .claude/ or .codex/ (or AGENTS.md) found. \
Pass --agent <claude|codex>."
);
}
Ok(found)
}
#[derive(Debug, PartialEq, Eq)]
enum RefOutcome {
Created(PathBuf),
Added(PathBuf),
Present(PathBuf),
}
fn plan_boot_import(existing: Option<&str>, reference: &str) -> (RefAction, Option<String>) {
match existing {
None => (RefAction::Create, Some(format!("{reference}\n"))),
Some(content) if content.lines().any(|l| l.trim() == reference) => {
(RefAction::Present, None)
}
Some(content) => (RefAction::Add, Some(format!("{reference}\n\n{content}"))),
}
}
enum RefAction {
Create,
Add,
Present,
}
fn resolve_target(target: &Path) -> PathBuf {
fs::canonicalize(target).unwrap_or_else(|_| target.to_path_buf())
}
fn ensure_boot_import(
targets: &[PathBuf],
reference: &str,
dry_run: bool,
) -> anyhow::Result<Vec<RefOutcome>> {
let mut seen = BTreeSet::new();
let mut outcomes = Vec::new();
for target in targets {
let resolved = resolve_target(target);
if !seen.insert(resolved.clone()) {
continue;
}
let existing = fs::read_to_string(&resolved).ok();
let (action, new_body) = plan_boot_import(existing.as_deref(), reference);
if let (Some(body), false) = (&new_body, dry_run) {
if let Some(parent) = resolved.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
fsutil::write_atomic(&resolved, body.as_bytes())?;
}
outcomes.push(match action {
RefAction::Create => RefOutcome::Created(resolved),
RefAction::Add => RefOutcome::Added(resolved),
RefAction::Present => RefOutcome::Present(resolved),
});
}
Ok(outcomes)
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum RefreshOutcome {
Wired(String),
Refreshed(String),
PrintedFallback,
None,
}
struct HookPlan {
outcome: RefreshOutcome,
new_json: Option<String>,
}
fn printed_fallback() -> HookPlan {
HookPlan {
outcome: RefreshOutcome::PrintedFallback,
new_json: None,
}
}
fn boot_command(exec: &Path) -> String {
format!("{} boot", exec.display())
}
fn desired_entry(spec: &HookSpec) -> Value {
serde_json::json!({
"matcher": spec.matcher,
"hooks": [ { "type": "command", "command": spec.command } ],
})
}
fn is_doctrine_boot_command(cmd: &str) -> bool {
let Some((program, arg)) = cmd.trim().rsplit_once(char::is_whitespace) else {
return false;
};
arg == "boot" && Path::new(program.trim_end()).file_name() == Some(OsStr::new("doctrine"))
}
fn sync_command(exec: &Path) -> String {
format!("{} memory sync", exec.display())
}
fn is_doctrine_sync_command(cmd: &str) -> bool {
let Some(program) = cmd.trim().strip_suffix(" memory sync") else {
return false;
};
Path::new(program.trim_end()).file_name() == Some(OsStr::new("doctrine"))
}
fn stamp_subagent_command(exec: &Path) -> String {
format!("{} worktree marker --stamp-subagent", exec.display())
}
fn is_doctrine_stamp_command(cmd: &str) -> bool {
let Some(program) = cmd.trim().strip_suffix(" worktree marker --stamp-subagent") else {
return false;
};
Path::new(program.trim_end()).file_name() == Some(OsStr::new("doctrine"))
}
pub(crate) struct HookSpec {
command: String,
is_ours: fn(&str) -> bool,
event: &'static str,
matcher: &'static str,
}
impl HookSpec {
fn boot(exec: &Path) -> Self {
Self {
command: boot_command(exec),
is_ours: is_doctrine_boot_command,
event: "SessionStart",
matcher: SESSION_MATCHER,
}
}
pub(crate) fn sync(exec: &Path) -> Self {
Self {
command: sync_command(exec),
is_ours: is_doctrine_sync_command,
event: "SessionStart",
matcher: SESSION_MATCHER,
}
}
pub(crate) fn stamp_subagent(exec: &Path) -> Self {
Self {
command: stamp_subagent_command(exec),
is_ours: is_doctrine_stamp_command,
event: "SubagentStart",
matcher: crate::worktree::DISPATCH_WORKER_AGENT_TYPE,
}
}
}
enum Owned {
Current,
Stale(usize, usize),
Absent,
}
fn find_owned(arr: &[Value], desired_cmd: &str, is_ours: fn(&str) -> bool) -> Owned {
for (ei, entry) in arr.iter().enumerate() {
let Some(inner) = entry.get("hooks").and_then(Value::as_array) else {
continue;
};
for (hi, hook) in inner.iter().enumerate() {
let Some(cmd) = hook.get("command").and_then(Value::as_str) else {
continue;
};
if is_ours(cmd) {
return if cmd == desired_cmd {
Owned::Current
} else {
Owned::Stale(ei, hi)
};
}
}
}
Owned::Absent
}
fn hook_array_mut<'a>(value: &'a mut Value, event: &str) -> Option<&'a mut Vec<Value>> {
let obj = value.as_object_mut()?;
let hooks = obj
.entry("hooks")
.or_insert_with(|| Value::Object(Map::new()));
let entries = hooks
.as_object_mut()?
.entry(event)
.or_insert_with(|| Value::Array(Vec::new()));
entries.as_array_mut()
}
fn set_command(arr: &mut [Value], ei: usize, hi: usize, command: &str) -> Option<()> {
let hook = arr
.get_mut(ei)?
.get_mut("hooks")?
.as_array_mut()?
.get_mut(hi)?
.as_object_mut()?;
hook.insert("command".to_string(), Value::String(command.to_string()));
Some(())
}
#[cfg(test)]
fn plan_session_hook(existing_json: Option<&str>, exec: &Path) -> HookPlan {
plan_hook(existing_json, &HookSpec::boot(exec))
}
fn plan_hook(existing_json: Option<&str>, spec: &HookSpec) -> HookPlan {
let command = spec.command.clone();
let mut value: Value = match existing_json.map(str::trim) {
None | Some("") => Value::Object(Map::new()),
Some(text) => match serde_json::from_str(text) {
Ok(parsed) => parsed,
Err(_) => return printed_fallback(),
},
};
let Some(arr) = hook_array_mut(&mut value, spec.event) else {
return printed_fallback();
};
let outcome = match find_owned(arr, &command, spec.is_ours) {
Owned::Current => {
return HookPlan {
outcome: RefreshOutcome::None,
new_json: None,
};
}
Owned::Stale(ei, hi) => {
if set_command(arr, ei, hi, &command).is_none() {
return printed_fallback();
}
RefreshOutcome::Refreshed(command.clone())
}
Owned::Absent => {
arr.push(desired_entry(spec));
RefreshOutcome::Wired(command.clone())
}
};
match serde_json::to_string_pretty(&value) {
Ok(json) => HookPlan {
outcome,
new_json: Some(json),
},
Err(_) => printed_fallback(),
}
}
pub(crate) fn fallback_for(spec: &HookSpec) -> String {
let entry = desired_entry(spec);
serde_json::to_string_pretty(&entry).unwrap_or_else(|_| spec.command.clone())
}
fn fallback_snippet(exec: &Path) -> String {
fallback_for(&HookSpec::boot(exec))
}
fn install_refresh(
h: &Harness,
root: &Path,
exec: &Path,
dry_run: bool,
) -> anyhow::Result<RefreshReport> {
match h {
Harness::Codex => Ok(RefreshReport {
hook: RefreshOutcome::None,
baseref: BaseRefOutcome::NotApplicable,
}),
Harness::Claude => {
let hook = install_claude_hook(root, &HookSpec::boot(exec), dry_run)?;
let baseref = install_baseref(root, dry_run)?;
Ok(RefreshReport { hook, baseref })
}
}
}
struct RefreshReport {
hook: RefreshOutcome,
baseref: BaseRefOutcome,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum BaseRefOutcome {
Set,
AlreadyHead,
Conflict(String),
PrintedFallback,
NotApplicable,
}
struct BaseRefPlan {
outcome: BaseRefOutcome,
new_json: Option<String>,
}
fn plan_baseref(existing_json: Option<&str>) -> BaseRefPlan {
let mut value: Value = match existing_json.map(str::trim) {
None | Some("") => Value::Object(Map::new()),
Some(text) => match serde_json::from_str(text) {
Ok(parsed) => parsed,
Err(_) => {
return BaseRefPlan {
outcome: BaseRefOutcome::PrintedFallback,
new_json: None,
};
}
},
};
let Some(obj) = value.as_object_mut() else {
return BaseRefPlan {
outcome: BaseRefOutcome::PrintedFallback,
new_json: None,
};
};
let worktree = obj
.entry("worktree")
.or_insert_with(|| Value::Object(Map::new()));
let Some(wt) = worktree.as_object_mut() else {
return BaseRefPlan {
outcome: BaseRefOutcome::PrintedFallback,
new_json: None,
};
};
match wt.get("baseRef") {
Some(Value::String(s)) if s == "head" => {
return BaseRefPlan {
outcome: BaseRefOutcome::AlreadyHead,
new_json: None,
};
}
Some(Value::String(s)) => {
return BaseRefPlan {
outcome: BaseRefOutcome::Conflict(s.clone()),
new_json: None,
};
}
Some(_non_string) => {
return BaseRefPlan {
outcome: BaseRefOutcome::Conflict("(non-string)".to_string()),
new_json: None,
};
}
None => {}
}
wt.insert("baseRef".to_string(), Value::String("head".to_string()));
match serde_json::to_string_pretty(&value) {
Ok(json) => BaseRefPlan {
outcome: BaseRefOutcome::Set,
new_json: Some(json),
},
Err(_) => BaseRefPlan {
outcome: BaseRefOutcome::PrintedFallback,
new_json: None,
},
}
}
fn install_baseref(root: &Path, dry_run: bool) -> anyhow::Result<BaseRefOutcome> {
let path = root.join(SETTINGS_REL);
let existing = fs::read_to_string(&path).ok();
let plan = plan_baseref(existing.as_deref());
if let (Some(json), false) = (&plan.new_json, dry_run) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
fsutil::write_atomic(&path, json.as_bytes())?;
}
Ok(plan.outcome)
}
pub(crate) fn install_claude_hook(
root: &Path,
spec: &HookSpec,
dry_run: bool,
) -> anyhow::Result<RefreshOutcome> {
let path = root.join(SETTINGS_REL);
let existing = fs::read_to_string(&path).ok();
let plan = plan_hook(existing.as_deref(), spec);
if let (Some(json), false) = (&plan.new_json, dry_run) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
fsutil::write_atomic(&path, json.as_bytes())?;
}
Ok(plan.outcome)
}
pub(crate) fn run_install(
path: Option<PathBuf>,
agents: &[String],
dry_run: bool,
yes: bool,
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let exec = std::env::current_exe().context("Failed to resolve the doctrine executable path")?;
let harnesses = resolve_harnesses(agents, &root)?;
if !yes && !dry_run {
let label: Vec<&str> = harnesses.iter().map(harness_label).collect();
let proceed = install::prompt_confirm(&format!(
"Wire doctrine boot ({}) into {}? [y/N] ",
label.join(", "),
root.display()
))?;
if !proceed {
writeln!(io::stdout(), "Aborted.")?;
return Ok(());
}
}
wire(&root, &exec, &harnesses, dry_run)
}
fn wire(root: &Path, exec: &Path, harnesses: &[Harness], dry_run: bool) -> anyhow::Result<()> {
let reference = format!("@{BOOT_REL}");
let targets: Vec<PathBuf> = harnesses
.iter()
.flat_map(|h| import_targets(h, root))
.collect();
let mut stdout = io::stdout();
let tag = if dry_run { "[dry-run] " } else { "" };
for outcome in ensure_boot_import(&targets, &reference, dry_run)? {
let (verb, target) = match &outcome {
RefOutcome::Created(p) => ("created", p),
RefOutcome::Added(p) => ("added ref", p),
RefOutcome::Present(p) => ("ref present", p),
};
writeln!(stdout, " {tag}{verb}: {}", target.display())?;
}
for h in harnesses {
match install_refresh(h, root, exec, dry_run) {
Ok(report) => {
match report.hook {
RefreshOutcome::Wired(cmd) => {
writeln!(stdout, " {tag}{}: wired hook: {cmd}", harness_label(h))?;
}
RefreshOutcome::Refreshed(cmd) => {
writeln!(stdout, " {tag}{}: refreshed hook: {cmd}", harness_label(h))?;
}
RefreshOutcome::PrintedFallback => {
writeln!(
stdout,
" {}: {SETTINGS_REL} is malformed — add this hook manually:",
harness_label(h)
)?;
writeln!(stdout, "{}", fallback_snippet(exec))?;
}
RefreshOutcome::None => {}
}
match report.baseref {
BaseRefOutcome::Set => {
writeln!(
stdout,
" {tag}{}: set worktree.baseRef=head",
harness_label(h)
)?;
}
BaseRefOutcome::Conflict(value) => {
writeln!(
stdout,
" {}: worktree.baseRef is '{value}', not 'head' — left as-is (claude isolated-worker base==B needs 'head'; set it manually if intended)",
harness_label(h)
)?;
}
BaseRefOutcome::AlreadyHead
| BaseRefOutcome::PrintedFallback
| BaseRefOutcome::NotApplicable => {}
}
}
Err(e) => writeln!(stdout, " {}: refresh failed: {e:#}", harness_label(h))?,
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn headings() -> Vec<&'static str> {
boot_sequence().iter().map(|(h, _)| *h).collect()
}
#[test]
fn boot_sequence_orders_exec_path_last() {
let seq = boot_sequence();
let last = seq.last().expect("non-empty sequence");
assert!(
matches!(last.1, SourceKind::ExecPath),
"ExecPath must be last"
);
assert!(
seq[..seq.len() - 1]
.iter()
.all(|(_, k)| !matches!(k, SourceKind::ExecPath)),
"ExecPath must appear exactly once, at the tail",
);
}
#[test]
fn boot_sequence_orders_active_policies_after_accepted_adrs() {
let h = headings();
let adrs = h
.iter()
.position(|h| *h == "Accepted ADRs")
.expect("Accepted ADRs present");
let policies = h
.iter()
.position(|h| *h == "Active Policies")
.expect("Active Policies present");
let memory = h
.iter()
.position(|h| *h == "Memory")
.expect("Memory present");
assert_eq!(
policies,
adrs + 1,
"Active Policies must sit immediately after Accepted ADRs"
);
assert!(policies < memory, "Active Policies must precede Memory");
}
#[test]
fn boot_sequence_orders_active_standards_after_active_policies() {
let h = headings();
let pos = |needle: &str| {
h.iter()
.position(|x| *x == needle)
.unwrap_or_else(|| panic!("{needle} present"))
};
let adrs = pos("Accepted ADRs");
let policies = pos("Active Policies");
let standards = pos("Active Standards");
let memory = pos("Memory");
assert!(adrs < policies, "ADRs precede Policies");
assert_eq!(
standards,
policies + 1,
"Active Standards must sit immediately after Active Policies"
);
assert!(standards < memory, "Active Standards must precede Memory");
}
#[test]
fn render_boot_is_byte_deterministic_and_structured() {
let sections = vec![
Section {
heading: "Alpha".into(),
body: "one".into(),
},
Section {
heading: "Beta".into(),
body: "two".into(),
},
];
let once = render_boot(§ions);
assert_eq!(
once,
render_boot(§ions),
"repeated render must be byte-identical"
);
assert_eq!(
once,
format!("{BOOT_HEADER}\n# Doctrine Boot Context\n\n## Alpha\none\n\n## Beta\ntwo\n"),
);
}
#[test]
fn produce_markers_a_non_exec_source_and_carries_the_exec_path() {
let root = Path::new("/r");
let exec = Path::new("/abs/target/debug/doctrine");
let memo = produce("Memory", &SourceKind::Memories, root, exec);
assert_eq!(memo.body, "<!-- Memory: not yet populated -->");
let invoke = produce("Invoking doctrine", &SourceKind::ExecPath, root, exec);
assert_eq!(invoke.body, "/abs/target/debug/doctrine");
}
#[test]
fn produce_static_reads_the_embed_and_markers_a_missing_asset() {
let root = Path::new("/r");
let exec = Path::new("/abs/target/debug/doctrine");
let digest = produce(
"Routing & Process",
&SourceKind::Static("routing-process.md"),
root,
exec,
);
assert!(
digest.body.contains("Route before you act"),
"embedded digest projected:\n{}",
digest.body
);
let missing = produce(
"Routing & Process",
&SourceKind::Static("no-such-asset.md"),
root,
exec,
);
assert_eq!(
missing.body, "<!-- Routing & Process: not yet populated -->",
"a missing embed asset is a marker, never a crash",
);
}
#[test]
fn produce_governance_reads_disk_or_markers_when_absent() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let exec = Path::new("/abs/target/debug/doctrine");
let absent = produce("Governance (project)", &SourceKind::Governance, root, exec);
assert_eq!(
absent.body, "<!-- Governance (project): not yet populated -->",
"absent governance.md → marker",
);
let gov = root.join(GOVERNANCE_REL);
fs::create_dir_all(gov.parent().unwrap()).unwrap();
fs::write(
&gov,
"# Governance (project)\n\nlive once, in the prefix.\n",
)
.unwrap();
let present = produce("Governance (project)", &SourceKind::Governance, root, exec);
assert!(
present.body.contains("live once, in the prefix."),
"governance.md body projected:\n{}",
present.body
);
}
#[test]
fn write_if_changed_writes_on_new_and_change_noops_on_equal() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".doctrine/state/boot.md");
assert!(write_if_changed(&path, "v1").unwrap(), "new file → wrote");
assert_eq!(fs::read_to_string(&path).unwrap(), "v1");
assert!(
!write_if_changed(&path, "v1").unwrap(),
"byte-equal → no-op"
);
assert!(write_if_changed(&path, "v2").unwrap(), "changed → wrote");
assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
}
#[test]
fn regenerate_emits_header_all_headings_exec_path_then_noops() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let exec = Path::new("/abs/target/debug/doctrine");
assert!(regenerate(root, exec).unwrap(), "first regenerate writes");
let snapshot = fs::read_to_string(root.join(BOOT_REL)).unwrap();
assert!(snapshot.starts_with(BOOT_HEADER), "header present");
for heading in headings() {
assert!(
snapshot.contains(&format!("## {heading}")),
"heading {heading} present"
);
}
assert!(
snapshot.contains("/abs/target/debug/doctrine"),
"exec path present"
);
assert!(
!regenerate(root, exec).unwrap(),
"second regenerate is a no-op write"
);
}
#[test]
fn section_or_marker_keeps_rows_and_falls_back_on_empty_or_error() {
assert_eq!(
section_or_marker("Accepted ADRs", Ok("a\nb\n".into())),
"a\nb"
);
assert_eq!(
section_or_marker("Accepted ADRs", Ok(" \n".into())),
"<!-- Accepted ADRs: not yet populated -->",
"empty listing → marker",
);
assert_eq!(
section_or_marker("Memory", Err(anyhow::anyhow!("boom"))),
"<!-- Memory: not yet populated -->",
"producer error → marker, never a crash",
);
}
#[test]
fn regenerate_projects_accepted_adrs_and_memory_pointers() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let exec = Path::new("/abs/target/debug/doctrine");
adr::run_new(Some(root.to_path_buf()), Some("Use Rust".into()), None).unwrap();
adr::run_new(Some(root.to_path_buf()), Some("Adopt CI".into()), None).unwrap();
adr::run_status(Some(root.to_path_buf()), 1, adr::AdrStatus::Accepted).unwrap();
memory::run_record(
Some(root.to_path_buf()),
&memory::RecordArgs {
title: "Boot pointer note",
memory_type: memory::MemoryType::Signpost,
key: None,
status: memory::Status::Active,
summary: None,
tags: &[],
paths: &[],
globs: &[],
commands: &[],
repo: None,
global: false,
},
)
.unwrap();
assert!(regenerate(root, exec).unwrap());
let snap = fs::read_to_string(root.join(BOOT_REL)).unwrap();
assert!(
snap.contains("ADR-001 │ accepted"),
"accepted ADR row projected with prefixed id:\n{snap}"
);
assert!(
snap.lines()
.any(|l| l.starts_with("id") && l.contains("status")),
"ADR section carries the header row:\n{snap}"
);
assert!(
!snap.contains("adopt-ci"),
"non-accepted ADR filtered:\n{snap}"
);
assert!(
snap.contains("Boot pointer note"),
"memory pointer projected:\n{snap}"
);
assert!(!snap.contains("<!-- Accepted ADRs:"), "ADR marker replaced");
assert!(!snap.contains("<!-- Memory:"), "memory marker replaced");
}
#[test]
fn regenerate_projects_required_policies_filtered() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let exec = Path::new("/abs/target/debug/doctrine");
policy::run_new(
Some(root.to_path_buf()),
Some("Commit cadence".into()),
None,
)
.unwrap();
policy::run_new(
Some(root.to_path_buf()),
Some("Branch hygiene".into()),
None,
)
.unwrap();
policy::run_new(Some(root.to_path_buf()), Some("Old rule".into()), None).unwrap();
policy::run_new(Some(root.to_path_buf()), Some("Dead rule".into()), None).unwrap();
policy::run_status(Some(root.to_path_buf()), 1, policy::PolicyStatus::Required).unwrap();
policy::run_status(
Some(root.to_path_buf()),
3,
policy::PolicyStatus::Deprecated,
)
.unwrap();
policy::run_status(Some(root.to_path_buf()), 4, policy::PolicyStatus::Retired).unwrap();
assert!(regenerate(root, exec).unwrap());
let snap = fs::read_to_string(root.join(BOOT_REL)).unwrap();
assert!(
snap.contains("POL-001 │ required"),
"required policy row projected with prefixed id:\n{snap}"
);
let section = snap
.split_once("## Active Policies\n")
.map(|(_, tail)| tail.split_once("\n## ").map_or(tail, |(body, _)| body))
.expect("Active Policies section present");
for hidden in ["branch-hygiene", "old-rule", "dead-rule"] {
assert!(
!section.contains(hidden),
"non-required policy {hidden} must not project:\n{section}"
);
}
assert!(
!snap.contains("<!-- Active Policies:"),
"policy marker replaced when a required policy exists"
);
}
#[test]
fn regenerate_projects_in_force_standards_filtered() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let exec = Path::new("/abs/target/debug/doctrine");
standard::run_new(Some(root.to_path_buf()), Some("A draft rule".into()), None).unwrap();
standard::run_new(
Some(root.to_path_buf()),
Some("A default rule".into()),
None,
)
.unwrap();
standard::run_new(
Some(root.to_path_buf()),
Some("A required rule".into()),
None,
)
.unwrap();
standard::run_new(Some(root.to_path_buf()), Some("An old rule".into()), None).unwrap();
standard::run_new(Some(root.to_path_buf()), Some("A dead rule".into()), None).unwrap();
standard::run_status(
Some(root.to_path_buf()),
2,
standard::StandardStatus::Default,
)
.unwrap();
standard::run_status(
Some(root.to_path_buf()),
3,
standard::StandardStatus::Required,
)
.unwrap();
standard::run_status(
Some(root.to_path_buf()),
4,
standard::StandardStatus::Deprecated,
)
.unwrap();
standard::run_status(
Some(root.to_path_buf()),
5,
standard::StandardStatus::Retired,
)
.unwrap();
assert!(regenerate(root, exec).unwrap());
let snap = fs::read_to_string(root.join(BOOT_REL)).unwrap();
assert!(
snap.contains("STD-002 │ default"),
"default standard row projected with prefixed id:\n{snap}"
);
assert!(
snap.contains("STD-003 │ required"),
"required standard row projected with prefixed id:\n{snap}"
);
let section = snap
.split_once("## Active Standards\n")
.map(|(_, tail)| tail.split_once("\n## ").map_or(tail, |(body, _)| body))
.expect("Active Standards section present");
for hidden in ["a-draft-rule", "an-old-rule", "a-dead-rule"] {
assert!(
!section.contains(hidden),
"out-of-force standard {hidden} must not project:\n{section}"
);
}
assert!(
!snap.contains("<!-- Active Standards:"),
"standard marker replaced when an in-force standard exists"
);
}
#[test]
fn regenerate_empty_policy_corpus_renders_marker() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let exec = Path::new("/abs/target/debug/doctrine");
policy::run_new(Some(root.to_path_buf()), Some("Just a draft".into()), None).unwrap();
assert!(regenerate(root, exec).unwrap());
let snap = fs::read_to_string(root.join(BOOT_REL)).unwrap();
assert!(
snap.contains(
"<!-- No active policies yet. See mem.signpost.doctrine.policies-standards -->"
),
"zero required policies → governance nudge, never a partial/stale row:\n{snap}"
);
assert!(
!snap.contains("just-a-draft"),
"draft policy must not leak into the in-force section:\n{snap}"
);
}
#[test]
fn boot_memory_section_is_active_only_decoupled_from_the_list_default() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let exec = Path::new("/abs/target/debug/doctrine");
let rec = |title: &str, status: memory::Status| {
memory::run_record(
Some(root.to_path_buf()),
&memory::RecordArgs {
title,
memory_type: memory::MemoryType::Signpost,
key: None,
status,
summary: None,
tags: &[],
paths: &[],
globs: &[],
commands: &[],
repo: None,
global: false,
},
)
.unwrap();
};
rec("Active note", memory::Status::Active);
rec("Draft note", memory::Status::Draft);
rec("Superseded note", memory::Status::Superseded);
rec("Retracted note", memory::Status::Retracted);
rec("Archived note", memory::Status::Archived);
rec("Quarantined note", memory::Status::Quarantined);
assert!(regenerate(root, exec).unwrap());
let snap = fs::read_to_string(root.join(BOOT_REL)).unwrap();
assert!(snap.contains("Active note"), "boot shows active:\n{snap}");
for leaked in [
"Draft note",
"Superseded note",
"Retracted note",
"Archived note",
"Quarantined note",
] {
assert!(
!snap.contains(leaked),
"boot must not leak {leaked}:\n{snap}"
);
}
let listed = memory::list_rows(root, None, crate::listing::ListArgs::default()).unwrap();
assert!(
listed.contains("Active note"),
"list shows active:\n{listed}"
);
assert!(listed.contains("Draft note"), "list KEEPS draft:\n{listed}");
for hidden in [
"Superseded note",
"Retracted note",
"Archived note",
"Quarantined note",
] {
assert!(
!listed.contains(hidden),
"list hides terminal {hidden}:\n{listed}"
);
}
}
#[test]
fn boot_memory_section_is_signpost_only_excludes_other_types() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let exec = Path::new("/abs/target/debug/doctrine");
let rec = |title: &str, ty: memory::MemoryType| {
memory::run_record(
Some(root.to_path_buf()),
&memory::RecordArgs {
title,
memory_type: ty,
key: None,
status: memory::Status::Active,
summary: None,
tags: &[],
paths: &[],
globs: &[],
commands: &[],
repo: None,
global: false,
},
)
.unwrap();
};
rec("Signpost pointer", memory::MemoryType::Signpost);
rec("Concept note", memory::MemoryType::Concept);
rec("Pattern note", memory::MemoryType::Pattern);
rec("Fact note", memory::MemoryType::Fact);
assert!(regenerate(root, exec).unwrap());
let snap = fs::read_to_string(root.join(BOOT_REL)).unwrap();
assert!(
snap.contains("Signpost pointer"),
"signpost-type memory projected:\n{snap}"
);
for excluded in ["Concept note", "Pattern note", "Fact note"] {
assert!(
!snap.contains(excluded),
"non-signpost {excluded} must not appear in boot:\n{snap}"
);
}
}
#[test]
fn regenerate_projects_routing_digest_and_governance_body() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let exec = Path::new("/abs/target/debug/doctrine");
let gov = root.join(GOVERNANCE_REL);
fs::create_dir_all(gov.parent().unwrap()).unwrap();
fs::write(&gov, "# Governance (project)\n\npoint at doc/spec.md\n").unwrap();
assert!(regenerate(root, exec).unwrap());
let snap = fs::read_to_string(root.join(BOOT_REL)).unwrap();
assert!(
snap.contains("Route before you act"),
"routing digest (embed) projected:\n{snap}"
);
assert!(
snap.contains("Reference forms"),
"reference-forms block projected onto the snapshot:\n{snap}"
);
assert!(
snap.contains("Reference docs") && snap.contains("using-doctrine.md"),
"reference-docs pointer projected onto the snapshot:\n{snap}"
);
assert!(
snap.contains("point at doc/spec.md"),
"governance body (disk) projected:\n{snap}"
);
assert!(
!snap.contains("<!-- Routing & Process:"),
"routing marker replaced"
);
assert!(
!snap.contains("<!-- Governance (project):"),
"governance marker replaced"
);
}
#[test]
fn boot_check_reports_clean_when_populated_and_in_sync() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let exec = Path::new("/abs/target/debug/doctrine");
let gov = root.join(GOVERNANCE_REL);
fs::create_dir_all(gov.parent().unwrap()).unwrap();
fs::write(&gov, "# Governance\n\npoint at doc/spec.md\n").unwrap();
adr::run_new(Some(root.to_path_buf()), Some("Use Rust".into()), None).unwrap();
adr::run_status(Some(root.to_path_buf()), 1, adr::AdrStatus::Accepted).unwrap();
policy::run_new(
Some(root.to_path_buf()),
Some("Commit cadence".into()),
None,
)
.unwrap();
policy::run_status(Some(root.to_path_buf()), 1, policy::PolicyStatus::Required).unwrap();
standard::run_new(
Some(root.to_path_buf()),
Some("Two-space indent".into()),
None,
)
.unwrap();
standard::run_status(
Some(root.to_path_buf()),
1,
standard::StandardStatus::Required,
)
.unwrap();
memory::run_record(
Some(root.to_path_buf()),
&memory::RecordArgs {
title: "Boot pointer note",
memory_type: memory::MemoryType::Signpost,
key: None,
status: memory::Status::Active,
summary: None,
tags: &[],
paths: &[],
globs: &[],
commands: &[],
repo: None,
global: false,
},
)
.unwrap();
assert!(regenerate(root, exec).unwrap(), "seed the on-disk snapshot");
let report = boot_check(root, exec);
assert!(
report.is_clean(),
"fully populated + in sync → clean: stale={}, markers={:?}",
report.stale,
report.marker_sections
);
}
#[test]
fn boot_check_flags_disk_staleness() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let exec = Path::new("/abs/target/debug/doctrine");
assert!(boot_check(root, exec).stale, "absent boot.md → stale");
regenerate(root, exec).unwrap();
let dest = root.join(BOOT_REL);
fs::write(&dest, "# tampered\n").unwrap();
assert!(boot_check(root, exec).stale, "edited boot.md → stale");
regenerate(root, exec).unwrap();
assert!(!boot_check(root, exec).stale, "regenerated → not stale");
}
#[test]
fn boot_check_tallies_marker_sections() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let exec = Path::new("/abs/target/debug/doctrine");
regenerate(root, exec).unwrap();
let bare = boot_check(root, exec);
assert!(!bare.stale, "freshly written → not stale");
assert!(
bare.marker_sections
.contains(&"Governance (project)".to_string()),
"absent governance.md is an unpopulated section: {:?}",
bare.marker_sections
);
assert!(
!bare
.marker_sections
.contains(&"Invoking doctrine".to_string()),
"exec path is always populated: {:?}",
bare.marker_sections
);
assert!(!bare.is_clean(), "marker sections present → not clean");
let gov = root.join(GOVERNANCE_REL);
fs::create_dir_all(gov.parent().unwrap()).unwrap();
fs::write(&gov, "# Governance\n\npoint at doc/spec.md\n").unwrap();
regenerate(root, exec).unwrap();
let seeded = boot_check(root, exec);
assert!(
!seeded
.marker_sections
.contains(&"Governance (project)".to_string()),
"seeded governance.md drops the marker: {:?}",
seeded.marker_sections
);
}
#[test]
fn boot_check_reports_empty_governance_as_unpopulated() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let exec = Path::new("/abs/target/debug/doctrine");
regenerate(root, exec).unwrap();
let report = boot_check(root, exec);
assert!(
report
.marker_sections
.contains(&"Active Policies".to_string()),
"empty policies reported: {:?}",
report.marker_sections
);
assert!(
report
.marker_sections
.contains(&"Active Standards".to_string()),
"empty standards reported: {:?}",
report.marker_sections
);
assert!(report.marker_sections.contains(&"Memory".to_string()));
}
#[test]
fn boot_check_populated_governance_does_not_warn() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let exec = Path::new("/abs/target/debug/doctrine");
policy::run_new(
Some(root.to_path_buf()),
Some("Commit cadence".into()),
None,
)
.unwrap();
policy::run_status(Some(root.to_path_buf()), 1, policy::PolicyStatus::Required).unwrap();
standard::run_new(
Some(root.to_path_buf()),
Some("Two-space indent".into()),
None,
)
.unwrap();
standard::run_status(
Some(root.to_path_buf()),
1,
standard::StandardStatus::Required,
)
.unwrap();
regenerate(root, exec).unwrap();
let report = boot_check(root, exec);
assert!(
!report
.marker_sections
.contains(&"Active Policies".to_string()),
"populated Policies → not a marker: {:?}",
report.marker_sections
);
assert!(
!report
.marker_sections
.contains(&"Active Standards".to_string()),
"populated Standards → not a marker: {:?}",
report.marker_sections
);
assert!(report.marker_sections.contains(&"Memory".to_string()));
}
#[test]
fn boot_check_is_deterministic() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let exec = Path::new("/abs/target/debug/doctrine");
regenerate(root, exec).unwrap();
assert_eq!(
boot_check(root, exec),
boot_check(root, exec),
"no clock/rng → identical reports"
);
}
const REF: &str = "@.doctrine/state/boot.md";
fn session_entries(json: &str) -> Vec<Value> {
let value: Value = serde_json::from_str(json).expect("valid JSON");
value
.get("hooks")
.and_then(|h| h.get("SessionStart"))
.and_then(Value::as_array)
.expect("SessionStart array")
.clone()
}
fn commands(json: &str) -> Vec<String> {
session_entries(json)
.iter()
.filter_map(|e| e.get("hooks").and_then(Value::as_array))
.flatten()
.filter_map(|h| h.get("command").and_then(Value::as_str))
.map(str::to_string)
.collect()
}
#[test]
fn parse_harness_known_and_unknown() {
assert_eq!(parse_harness("claude").unwrap(), Harness::Claude);
assert_eq!(parse_harness("CODEX").unwrap(), Harness::Codex);
assert!(
parse_harness("cursor")
.unwrap_err()
.to_string()
.contains("Unknown harness")
);
}
#[test]
fn import_targets_is_one_file_per_harness() {
let root = Path::new("/r");
assert_eq!(
import_targets(&Harness::Claude, root),
vec![root.join("CLAUDE.md")]
);
assert_eq!(
import_targets(&Harness::Codex, root),
vec![root.join("AGENTS.md")]
);
}
#[test]
fn resolve_harnesses_explicit_wins() {
let root = Path::new("/r");
let got = resolve_harnesses(&["codex".into(), "claude".into()], root).unwrap();
assert_eq!(got, vec![Harness::Codex, Harness::Claude]);
}
#[test]
fn resolve_harnesses_auto_detects_by_marker() {
let bare = tempfile::tempdir().unwrap();
fs::write(bare.path().join("AGENTS.md"), "x").unwrap();
assert_eq!(
resolve_harnesses(&[], bare.path()).unwrap(),
vec![Harness::Codex]
);
let split = tempfile::tempdir().unwrap();
fs::create_dir(split.path().join(".claude")).unwrap();
fs::write(split.path().join("AGENTS.md"), "real codex surface").unwrap();
assert_eq!(
resolve_harnesses(&[], split.path()).unwrap(),
vec![Harness::Claude, Harness::Codex]
);
let alias = tempfile::tempdir().unwrap();
fs::create_dir(alias.path().join(".claude")).unwrap();
fs::write(alias.path().join("CLAUDE.md"), "real").unwrap();
std::os::unix::fs::symlink(
alias.path().join("CLAUDE.md"),
alias.path().join("AGENTS.md"),
)
.unwrap();
assert_eq!(
resolve_harnesses(&[], alias.path()).unwrap(),
vec![Harness::Claude]
);
let codex_marker = tempfile::tempdir().unwrap();
fs::create_dir(codex_marker.path().join(".claude")).unwrap();
fs::create_dir(codex_marker.path().join(".codex")).unwrap();
assert_eq!(
resolve_harnesses(&[], codex_marker.path()).unwrap(),
vec![Harness::Claude, Harness::Codex]
);
let lone_pair = tempfile::tempdir().unwrap();
fs::write(lone_pair.path().join("CLAUDE.md"), "real").unwrap();
std::os::unix::fs::symlink(
lone_pair.path().join("CLAUDE.md"),
lone_pair.path().join("AGENTS.md"),
)
.unwrap();
assert_eq!(
resolve_harnesses(&[], lone_pair.path()).unwrap(),
vec![Harness::Codex]
);
}
#[test]
fn resolve_harnesses_errors_when_none() {
let dir = tempfile::tempdir().unwrap();
let err = resolve_harnesses(&[], dir.path()).unwrap_err();
assert!(err.to_string().contains("No --agent given"));
}
#[test]
fn plan_boot_import_creates_adds_and_is_idempotent() {
let (action, body) = plan_boot_import(None, REF);
assert!(matches!(action, RefAction::Create));
assert_eq!(body.unwrap(), format!("{REF}\n"));
let (action, body) = plan_boot_import(Some("# Title\n\nbody\n"), REF);
assert!(matches!(action, RefAction::Add));
assert_eq!(body.unwrap(), format!("{REF}\n\n# Title\n\nbody\n"));
let (action, body) = plan_boot_import(Some("@.doctrine/state/boot.md\n\nrest\n"), REF);
assert!(matches!(action, RefAction::Present));
assert!(body.is_none());
}
#[test]
fn ensure_boot_import_creates_prepends_and_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("CLAUDE.md");
let out = ensure_boot_import(&[target.clone()], REF, false).unwrap();
assert_eq!(out, vec![RefOutcome::Created(target.clone())]);
assert_eq!(fs::read_to_string(&target).unwrap(), format!("{REF}\n"));
let out = ensure_boot_import(&[target.clone()], REF, false).unwrap();
assert_eq!(out, vec![RefOutcome::Present(resolve_target(&target))]);
assert_eq!(fs::read_to_string(&target).unwrap(), format!("{REF}\n"));
}
#[test]
fn ensure_boot_import_preserves_existing_content() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("AGENTS.md");
fs::write(&target, "# Existing\n\nkeep me\n").unwrap();
let out = ensure_boot_import(&[target.clone()], REF, false).unwrap();
assert_eq!(out, vec![RefOutcome::Added(resolve_target(&target))]);
let body = fs::read_to_string(&target).unwrap();
assert_eq!(body, format!("{REF}\n\n# Existing\n\nkeep me\n"));
}
#[test]
fn ensure_boot_import_dedups_same_inode_to_one_write() {
let dir = tempfile::tempdir().unwrap();
let agents = dir.path().join("AGENTS.md");
let claude = dir.path().join("CLAUDE.md");
fs::write(&agents, "real\n").unwrap();
std::os::unix::fs::symlink(&agents, &claude).unwrap();
let out = ensure_boot_import(&[claude.clone(), agents.clone()], REF, false).unwrap();
assert_eq!(out.len(), 1, "same inode → exactly one outcome");
let body = fs::read_to_string(&agents).unwrap();
assert_eq!(body.matches(REF).count(), 1, "ref written exactly once");
assert!(
fs::symlink_metadata(&claude)
.unwrap()
.file_type()
.is_symlink()
);
assert_eq!(fs::read_to_string(&claude).unwrap(), body);
}
#[test]
fn ensure_boot_import_dry_run_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("CLAUDE.md");
let out = ensure_boot_import(&[target.clone()], REF, true).unwrap();
assert_eq!(out, vec![RefOutcome::Created(target.clone())]);
assert!(!target.exists(), "dry-run must not write");
}
#[test]
fn ownership_match_survives_spaces_and_rejects_foreign() {
assert!(is_doctrine_boot_command("/usr/bin/doctrine boot"));
assert!(is_doctrine_boot_command("doctrine boot"));
assert!(is_doctrine_boot_command("/nix/store/a b/doctrine boot"));
assert!(!is_doctrine_boot_command("/usr/bin/tool boot"));
assert!(!is_doctrine_boot_command("/x/doctrine-helper run"));
assert!(!is_doctrine_boot_command("/x/doctrine check"));
assert!(!is_doctrine_boot_command("doctrine"));
}
#[test]
fn plan_session_hook_wires_into_empty_or_missing() {
let exec = Path::new("/abs/doctrine");
for existing in [None, Some(""), Some("{}")] {
let plan = plan_session_hook(existing, exec);
assert!(matches!(plan.outcome, RefreshOutcome::Wired(_)));
let json = plan.new_json.unwrap();
assert_eq!(commands(&json), vec!["/abs/doctrine boot".to_string()]);
assert!(json.contains(SESSION_MATCHER));
}
}
#[test]
fn plan_session_hook_noops_when_current() {
let exec = Path::new("/abs/doctrine");
let wired = plan_session_hook(None, exec).new_json.unwrap();
let again = plan_session_hook(Some(&wired), exec);
assert!(matches!(again.outcome, RefreshOutcome::None));
assert!(again.new_json.is_none(), "current hook → no rewrite");
}
#[test]
fn plan_session_hook_refreshes_on_path_change_preserving_foreign() {
let old_exec = Path::new("/old/doctrine");
let new_exec = Path::new("/new path/doctrine");
let seeded = serde_json::to_string_pretty(&serde_json::json!({
"model": "opus",
"hooks": {
"SessionStart": [
{ "matcher": "startup", "hooks": [ { "type": "command", "command": "/usr/bin/notify start" } ] },
{ "matcher": SESSION_MATCHER, "hooks": [ { "type": "command", "command": boot_command(old_exec) } ] }
]
}
})).unwrap();
let plan = plan_session_hook(Some(&seeded), new_exec);
assert!(matches!(plan.outcome, RefreshOutcome::Refreshed(_)));
let json = plan.new_json.unwrap();
let cmds = commands(&json);
assert!(
cmds.contains(&"/usr/bin/notify start".to_string()),
"foreign hook preserved"
);
assert!(
cmds.contains(&"/new path/doctrine boot".to_string()),
"command refreshed"
);
assert!(
!cmds.contains(&"/old/doctrine boot".to_string()),
"stale command gone"
);
assert!(json.contains("\"model\""), "unrelated key preserved");
}
#[test]
fn plan_session_hook_fails_soft_on_malformed_json() {
let plan = plan_session_hook(Some("{ not json"), Path::new("/abs/doctrine"));
assert!(matches!(plan.outcome, RefreshOutcome::PrintedFallback));
assert!(plan.new_json.is_none(), "malformed → never clobber");
}
#[test]
fn install_refresh_writes_settings_and_respects_dry_run() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let exec = Path::new("/abs/doctrine");
let settings = root.join(SETTINGS_REL);
let out = install_refresh(&Harness::Claude, root, exec, true).unwrap();
assert!(matches!(out.hook, RefreshOutcome::Wired(_)));
assert!(matches!(out.baseref, BaseRefOutcome::Set));
assert!(!settings.exists(), "dry-run must not write settings");
let out = install_refresh(&Harness::Claude, root, exec, false).unwrap();
assert!(matches!(out.hook, RefreshOutcome::Wired(_)));
assert!(matches!(out.baseref, BaseRefOutcome::Set));
let json = fs::read_to_string(&settings).unwrap();
assert_eq!(commands(&json), vec!["/abs/doctrine boot".to_string()]);
let parsed: Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["worktree"]["baseRef"], Value::String("head".into()));
let out = install_refresh(&Harness::Codex, root, exec, false).unwrap();
assert!(matches!(out.hook, RefreshOutcome::None));
assert!(matches!(out.baseref, BaseRefOutcome::NotApplicable));
}
#[test]
fn plan_baseref_writes_head_when_absent() {
let plan = plan_baseref(None);
assert!(matches!(plan.outcome, BaseRefOutcome::Set));
let json = plan.new_json.expect("a write is planned");
let parsed: Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["worktree"]["baseRef"], Value::String("head".into()));
}
#[test]
fn plan_baseref_idempotent_when_already_head() {
let existing = r#"{"worktree":{"baseRef":"head"}}"#;
let plan = plan_baseref(Some(existing));
assert!(matches!(plan.outcome, BaseRefOutcome::AlreadyHead));
assert!(plan.new_json.is_none(), "no write when already head");
}
#[test]
fn plan_baseref_conflict_reports_no_clobber() {
let existing = r#"{"worktree":{"baseRef":"trunk"}}"#;
let plan = plan_baseref(Some(existing));
match plan.outcome {
BaseRefOutcome::Conflict(v) => assert_eq!(v, "trunk"),
other => panic!("expected Conflict, got {other:?}"),
}
assert!(plan.new_json.is_none(), "conflict must not write");
}
#[test]
fn plan_baseref_preserves_hooks_and_other_worktree_keys() {
let existing = r#"{
"hooks": {
"SessionStart": [
{"matcher":"startup|clear","hooks":[{"type":"command","command":"/abs/doctrine boot"}]}
],
"SubagentStart": [
{"matcher":"dispatch-worker","hooks":[{"type":"command","command":"/abs/doctrine worktree marker --stamp-subagent"}]}
]
},
"worktree": {"autoFetch": true}
}"#;
let plan = plan_baseref(Some(existing));
assert!(matches!(plan.outcome, BaseRefOutcome::Set));
let parsed: Value = serde_json::from_str(&plan.new_json.unwrap()).unwrap();
assert_eq!(parsed["worktree"]["baseRef"], Value::String("head".into()));
assert_eq!(parsed["worktree"]["autoFetch"], Value::Bool(true));
assert_eq!(
parsed["hooks"]["SessionStart"][0]["hooks"][0]["command"],
Value::String("/abs/doctrine boot".into())
);
assert_eq!(
parsed["hooks"]["SubagentStart"][0]["hooks"][0]["command"],
Value::String("/abs/doctrine worktree marker --stamp-subagent".into())
);
}
#[test]
fn plan_baseref_malformed_left_untouched() {
let plan = plan_baseref(Some("{ not json"));
assert!(matches!(plan.outcome, BaseRefOutcome::PrintedFallback));
assert!(
plan.new_json.is_none(),
"malformed file must not be clobbered"
);
}
#[test]
fn sync_ownership_is_disjoint_from_boot() {
assert!(is_doctrine_sync_command("/usr/bin/doctrine memory sync"));
assert!(is_doctrine_sync_command("doctrine memory sync"));
assert!(is_doctrine_sync_command(
"/nix/store/a b/doctrine memory sync"
));
assert!(!is_doctrine_sync_command("/usr/bin/tool memory sync"));
assert!(!is_doctrine_sync_command("/abs/doctrine boot"));
assert!(!is_doctrine_sync_command("/abs/doctrine memory"));
assert!(!is_doctrine_boot_command("/abs/doctrine memory sync"));
assert!(!is_doctrine_sync_command("/abs/doctrine boot"));
}
#[test]
fn install_claude_hook_wires_boot_and_sync_as_two_entries() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let exec = Path::new("/abs/doctrine");
install_claude_hook(root, &HookSpec::boot(exec), false).unwrap();
let out = install_claude_hook(root, &HookSpec::sync(exec), false).unwrap();
assert!(matches!(out, RefreshOutcome::Wired(_)));
let json = fs::read_to_string(root.join(SETTINGS_REL)).unwrap();
let cmds = commands(&json);
assert!(
cmds.contains(&"/abs/doctrine boot".to_string()),
"boot kept"
);
assert!(
cmds.contains(&"/abs/doctrine memory sync".to_string()),
"sync wired"
);
let again = install_claude_hook(root, &HookSpec::sync(exec), false).unwrap();
assert!(matches!(again, RefreshOutcome::None));
let json = fs::read_to_string(root.join(SETTINGS_REL)).unwrap();
assert_eq!(
commands(&json),
vec![
"/abs/doctrine boot".to_string(),
"/abs/doctrine memory sync".to_string(),
],
"two entries, neither duplicated nor clobbered"
);
}
#[test]
fn install_claude_hook_sync_respects_dry_run() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let out =
install_claude_hook(root, &HookSpec::sync(Path::new("/abs/doctrine")), true).unwrap();
assert!(matches!(out, RefreshOutcome::Wired(_)));
assert!(
!root.join(SETTINGS_REL).exists(),
"dry-run must not write settings"
);
}
fn event_entries(json: &str, event: &str) -> Option<Vec<Value>> {
let value: Value = serde_json::from_str(json).expect("valid JSON");
value
.get("hooks")
.and_then(|h| h.get(event))
.and_then(Value::as_array)
.cloned()
}
#[test]
fn install_claude_stamp_hook_appends_subagentstart_leaves_sessionstart_intact() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let exec = Path::new("/abs/doctrine");
install_claude_hook(root, &HookSpec::boot(exec), false).unwrap();
let settings_path = root.join(SETTINGS_REL);
let seeded = fs::read_to_string(&settings_path).unwrap();
let mut value: Value = serde_json::from_str(&seeded).unwrap();
hook_array_mut(&mut value, "SessionStart")
.unwrap()
.push(serde_json::json!({
"matcher": "startup",
"hooks": [ { "type": "command", "command": "/usr/bin/foreign hook" } ],
}));
fs::write(
&settings_path,
serde_json::to_string_pretty(&value).unwrap(),
)
.unwrap();
let out = install_claude_hook(root, &HookSpec::stamp_subagent(exec), false).unwrap();
assert!(matches!(out, RefreshOutcome::Wired(_)), "stamp wired");
let json = fs::read_to_string(&settings_path).unwrap();
let session = event_entries(&json, "SessionStart").expect("SessionStart kept");
let session_cmds: Vec<String> = session
.iter()
.filter_map(|e| e.get("hooks").and_then(Value::as_array))
.flatten()
.filter_map(|h| h.get("command").and_then(Value::as_str))
.map(str::to_string)
.collect();
assert_eq!(
session_cmds,
vec![
"/abs/doctrine boot".to_string(),
"/usr/bin/foreign hook".to_string(),
],
"SessionStart untouched"
);
let sub = event_entries(&json, "SubagentStart").expect("SubagentStart created");
assert_eq!(sub.len(), 1, "one SubagentStart entry");
assert_eq!(
sub[0].get("matcher").and_then(Value::as_str),
Some(crate::worktree::DISPATCH_WORKER_AGENT_TYPE),
"matcher-scoped to the dispatch-worker agent type"
);
let sub_cmd = sub[0]
.get("hooks")
.and_then(Value::as_array)
.and_then(|a| a.first())
.and_then(|h| h.get("command"))
.and_then(Value::as_str);
assert_eq!(
sub_cmd,
Some("/abs/doctrine worktree marker --stamp-subagent")
);
let again = install_claude_hook(root, &HookSpec::stamp_subagent(exec), false).unwrap();
assert!(matches!(again, RefreshOutcome::None), "reinstall no-op");
let json = fs::read_to_string(&settings_path).unwrap();
assert_eq!(
event_entries(&json, "SubagentStart").unwrap().len(),
1,
"no duplicate on reinstall"
);
}
#[test]
fn stamp_subagent_matcher_tracks_worktree_const() {
let spec = HookSpec::stamp_subagent(Path::new("/abs/doctrine"));
assert_eq!(spec.matcher, crate::worktree::DISPATCH_WORKER_AGENT_TYPE);
assert_eq!(spec.event, "SubagentStart");
}
#[test]
fn stamp_ownership_is_disjoint_from_boot_and_sync() {
assert!(is_doctrine_stamp_command(
"/abs/doctrine worktree marker --stamp-subagent"
));
assert!(!is_doctrine_stamp_command("/abs/doctrine boot"));
assert!(!is_doctrine_stamp_command("/abs/doctrine memory sync"));
assert!(!is_doctrine_boot_command(
"/abs/doctrine worktree marker --stamp-subagent"
));
assert!(!is_doctrine_sync_command(
"/abs/doctrine worktree marker --stamp-subagent"
));
assert!(!is_doctrine_stamp_command(
"/usr/bin/tool worktree marker --stamp-subagent"
));
}
const FAKE_EXEC: &str = "/fake/doctrine";
#[test]
fn wire_adds_import_and_hook_then_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let exec = Path::new(FAKE_EXEC);
wire(root, exec, &[Harness::Claude], false).unwrap();
let claude_md = fs::read_to_string(root.join("CLAUDE.md")).unwrap();
assert_eq!(claude_md.matches(REF).count(), 1, "import ref wired once");
let settings = fs::read_to_string(root.join(SETTINGS_REL)).unwrap();
assert_eq!(
commands(&settings),
vec![format!("{FAKE_EXEC} boot")],
"hook wired once"
);
wire(root, exec, &[Harness::Claude], false).unwrap();
let claude_md = fs::read_to_string(root.join("CLAUDE.md")).unwrap();
assert_eq!(
claude_md.matches(REF).count(),
1,
"re-run does not duplicate ref"
);
let settings = fs::read_to_string(root.join(SETTINGS_REL)).unwrap();
assert_eq!(
commands(&settings).len(),
1,
"re-run does not duplicate hook"
);
}
#[test]
fn wire_dry_run_mutates_nothing() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
wire(root, Path::new(FAKE_EXEC), &[Harness::Claude], true).unwrap();
assert!(!root.join("CLAUDE.md").exists(), "dry-run wrote no import");
assert!(
!root.join(SETTINGS_REL).exists(),
"dry-run wrote no settings"
);
}
#[test]
fn wire_isolates_one_harness_failure() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join(SETTINGS_REL)).unwrap();
wire(
root,
Path::new(FAKE_EXEC),
&[Harness::Claude, Harness::Codex],
false,
)
.unwrap();
let agents = fs::read_to_string(root.join("AGENTS.md")).unwrap();
assert_eq!(
agents.matches(REF).count(),
1,
"codex import survived the claude failure"
);
}
}