use std::path::{Path, PathBuf};
use super::doctor_runtime::Op;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FixerDispatch {
pub finding_code: &'static str,
pub severity: &'static str,
pub path: PathBuf,
pub op: Op,
}
impl FixerDispatch {
fn manual(
finding_code: &'static str,
severity: &'static str,
path: impl Into<PathBuf>,
steps: &[&str],
) -> Self {
Self {
finding_code,
severity,
path: path.into(),
op: Op::Manual {
steps: steps.iter().map(|step| (*step).to_string()).collect(),
},
}
}
}
#[must_use]
pub fn fix_search_index_stale(workspace_root: &Path) -> FixerDispatch {
FixerDispatch {
finding_code: "search_index_stale",
severity: "warning",
path: search_index_dir(workspace_root),
op: Op::RunIndexRebuild {
steps: search_index_repair_steps(workspace_root),
},
}
}
#[must_use]
pub fn fix_search_index_missing(workspace_root: &Path) -> FixerDispatch {
FixerDispatch {
finding_code: "search_index_missing",
severity: "warning",
path: search_index_dir(workspace_root),
op: Op::RunIndexRebuild {
steps: search_index_repair_steps(workspace_root),
},
}
}
#[must_use]
pub fn fix_database_empty(workspace_root: &Path) -> FixerDispatch {
FixerDispatch::manual(
"database_empty",
"error",
workspace_root.join(".ee").join("ee.db"),
&[
"Do not run `ee init` or a migration over the empty file: that builds a fresh store and hides the loss.",
"List recoverable backups: `ee backup list --workspace .`.",
"Recover one with `ee backup restore` into a side path, inspect it, then move it into `.ee/`.",
"Or accept the loss: move `.ee/ee.db` aside and run `ee init --workspace .` to start an empty store.",
],
)
}
#[must_use]
pub fn fix_database_corrupted(workspace_root: &Path) -> FixerDispatch {
FixerDispatch::manual(
"database_corrupted",
"error",
workspace_root.join(".ee").join("ee.db"),
&[
"Keep the damaged file: copy `.ee/ee.db` aside before any recovery attempt.",
"Do not run `ee index rebuild` or a migration against it; both read the damaged store.",
"List recoverable backups: `ee backup list --workspace .`, recover one into a side path with `ee backup restore`, inspect it, then move it into `.ee/`.",
"Or accept the loss: move `.ee/ee.db` aside and run `ee init --workspace .` to start an empty store.",
],
)
}
#[must_use]
pub fn fix_database_locked(workspace_root: &Path) -> FixerDispatch {
FixerDispatch::manual(
"database_locked",
"error",
workspace_root.join(".ee").join("ee.db"),
&[
"The database could not be inspected because a writer holds its lock; this is not evidence of data loss.",
"Identify the active writer and wait for it to finish. If it is stuck, coordinate an orderly stop with its owner before retrying.",
"Leave the lock file, database and all sidecars in place; removing a lock file can let two writers operate concurrently.",
"After the holder releases the lock, rerun `ee doctor --workspace . --json` before considering any repair.",
],
)
}
#[must_use]
pub fn fix_database_unavailable(workspace_root: &Path) -> FixerDispatch {
FixerDispatch::manual(
"database_unavailable",
"error",
workspace_root.join(".ee").join("ee.db"),
&[
"Database inspection failed, so neither readiness nor corruption has been established.",
"Inspect the reported access, permission, storage or runtime failure and coordinate with any active writer.",
"Preserve the database and its sidecars together; do not replace the store or rewrite its migration ledger to clear an unknown error.",
"After resolving the underlying failure, rerun `ee doctor --workspace . --json` before any dependent index repair.",
],
)
}
#[must_use]
pub fn fix_database_migration_drift(workspace_root: &Path) -> FixerDispatch {
FixerDispatch::manual(
"database_migration_drift",
"error",
workspace_root.join(".ee").join("ee.db"),
&[
"The applied migration checksums differ from this binary's expected history; this does not establish database-file corruption.",
"Preserve the database and all sidecars. Compare the binary revision and recorded migration history with the known source of this store.",
"Do not rewrite checksums or run a migration merely to suppress the mismatch; resolve the history discrepancy first.",
"After reconciling the binary and history, rerun `ee doctor --workspace . --json`; dependent repairs remain blocked until inspection succeeds.",
],
)
}
#[must_use]
pub fn fix_database_missing(workspace_root: &Path) -> FixerDispatch {
let path = workspace_root.join(".ee").join("ee.db");
if super::doctor::prior_data_evidence(workspace_root).is_empty() {
FixerDispatch::manual(
"database_missing",
"warning",
path,
&[
"Nothing shows this workspace ever held data: it has no search index and no backups.",
"Create the store: `ee init --workspace .`.",
"If it did hold data, do not init first: list recoverable backups with `ee backup list --workspace .`.",
],
)
} else {
FixerDispatch::manual(
"database_missing",
"warning",
path,
&[
"The database is missing, but this workspace held data: its search index or backups still exist.",
"Do not run `ee init` or a migration: that builds a fresh empty store and hides the loss.",
"List recoverable backups: `ee backup list --workspace .`, recover one into a side path with `ee backup restore`, inspect it, then move it into `.ee/`.",
"Or accept the loss: run `ee init --workspace .` to start an empty store.",
],
)
}
}
fn search_index_dir(workspace_root: &Path) -> PathBuf {
crate::config::workspace::resolve_store_index_dir(workspace_root, None, None)
}
fn search_index_repair_steps(workspace_root: &Path) -> Vec<String> {
let workspace = shell_quote_arg(&workspace_root.to_string_lossy());
vec![
format!("ee index rebuild --workspace {workspace} --json"),
format!("ee index status --workspace {workspace} --json"),
"Confirm the index is ready and its source and index generations agree.".to_owned(),
]
}
#[must_use]
pub fn fix_graph_snapshot_stale(workspace_root: &Path) -> FixerDispatch {
FixerDispatch {
finding_code: "graph_snapshot_stale",
severity: "warning",
path: workspace_root.join(".ee/graph"),
op: Op::RunGraphRefresh {
steps: vec![
"ee graph centrality-refresh --workspace .".to_string(),
"Confirm `graph_snapshot_high_watermark` advances past the latest memory_links.created_at.".to_string(),
],
},
}
}
#[must_use]
pub fn fix_wal_checkpoint_pending(workspace_root: &Path) -> FixerDispatch {
FixerDispatch {
finding_code: "wal_checkpoint_pending",
severity: "warning",
path: workspace_root.join(".ee/ee.db-wal"),
op: Op::RunWalCheckpoint {
mode: "truncate".to_string(),
steps: vec![
"ee maintenance wal-checkpoint --workspace . --mode truncate --json".to_string(),
"Verify `.ee/ee.db-wal` shrinks back to the truncation floor.".to_string(),
],
},
}
}
#[must_use]
pub fn fix_schema_migration_pending(
workspace_root: &Path,
target_version: impl Into<String>,
) -> FixerDispatch {
FixerDispatch {
finding_code: "schema_migration_pending",
severity: "error",
path: workspace_root.join(".ee/ee.db"),
op: Op::RunMigration {
target_version: target_version.into(),
steps: vec![
"ee migrate run --workspace . --json".to_string(),
"Confirm schema_migrations.version matches the binary's MIGRATIONS::TARGET."
.to_string(),
],
},
}
}
#[must_use]
pub fn fix_beads_jsonl_drift(workspace_root: &Path, expected_row_count: usize) -> FixerDispatch {
FixerDispatch {
finding_code: "beads_jsonl_drift",
severity: "warning",
path: workspace_root.join(".beads/issues.jsonl"),
op: Op::RewriteJsonl {
row_count: expected_row_count,
steps: vec![
"br sync --flush-only".to_string(),
"Confirm `br doctor` reports `counts.db_vs_jsonl: Both have N records`."
.to_string(),
],
},
}
}
#[must_use]
pub fn fix_workspace_config_malformed_toml(workspace_root: &Path) -> FixerDispatch {
let path = workspace_root.join(".ee/config.toml");
FixerDispatch {
finding_code: "workspace_config_malformed_toml",
severity: "error",
path,
op: Op::QuarantineByRename {
dest_under_quarantine: PathBuf::from("config.toml"),
},
}
}
#[must_use]
pub fn fix_workspace_config_atomic_rewrite(workspace_root: &Path, summary: &str) -> FixerDispatch {
FixerDispatch {
finding_code: "workspace_config_atomic_rewrite_needed",
severity: "warning",
path: workspace_root.join(".ee/config.toml"),
op: Op::AtomicRewriteToml {
steps: vec![
format!("doctor will rewrite config.toml: {summary}"),
"Verify the resulting file parses under ConfigFile::parse.".to_string(),
],
},
}
}
#[must_use]
pub fn fix_agent_coordination_stale_lease(
workspace_root: &Path,
reservation_marker: &Path,
) -> FixerDispatch {
let rel = reservation_marker
.strip_prefix(workspace_root)
.map(Path::to_path_buf)
.unwrap_or_else(|_| reservation_marker.to_path_buf());
FixerDispatch {
finding_code: "agent_coordination_stale_lease",
severity: "warning",
path: reservation_marker.to_path_buf(),
op: Op::QuarantineByRename {
dest_under_quarantine: rel,
},
}
}
#[must_use]
pub fn fix_cass_integration_drift(workspace_root: &Path) -> FixerDispatch {
FixerDispatch::manual(
"cass_integration_drift",
"warning",
workspace_root.join(".ee/cass"),
&[
"Run `ee import cass --dry-run --json` to preview the affected workspace import.",
"Run `ee import cass --json` to refresh imported CASS evidence from the source corpus.",
],
)
}
#[must_use]
pub fn fix_policy_safety_inconsistent(workspace_root: &Path) -> FixerDispatch {
FixerDispatch::manual(
"policy_safety_inconsistent",
"error",
workspace_root.join(".ee/config.toml"),
&[
"Diff `.ee/config.toml` `[policy.safety]` block against the binary's policy registry.",
"Apply the reconciled values manually; doctor never silently mutates policy.",
],
)
}
#[must_use]
pub fn fix_snapshot_backup_owed(workspace_root: &Path, label: impl Into<String>) -> FixerDispatch {
let label = label.into();
let backup_command = format!(
"ee backup create --workspace . --label {} --json",
shell_quote_arg(&label)
);
FixerDispatch {
finding_code: "snapshot_backup_owed",
severity: "warning",
path: workspace_root.join(".ee"),
op: Op::SnapshotBackup {
label: label.clone(),
steps: vec![
backup_command,
"Verify the backup manifest hash matches the pre-mutation source.".to_string(),
],
},
}
}
fn shell_quote_arg(value: &str) -> String {
let mut quoted = String::with_capacity(value.len() + 2);
quoted.push('\'');
for ch in value.chars() {
if ch == '\'' {
quoted.push_str("'\\''");
} else {
quoted.push(ch);
}
}
quoted.push('\'');
quoted
}
#[must_use]
pub fn fix_state_file_permission_drift(path: impl Into<PathBuf>) -> FixerDispatch {
FixerDispatch {
finding_code: "state_file_permission_drift",
severity: "warning",
path: path.into(),
op: Op::Chmod { mode: 0o600 },
}
}
pub const FIXER_FINDING_CODES: &[&str] = &[
"search_index_stale",
"search_index_missing",
"database_empty",
"database_corrupted",
"database_locked",
"database_unavailable",
"database_migration_drift",
"graph_snapshot_stale",
"wal_checkpoint_pending",
"schema_migration_pending",
"beads_jsonl_drift",
"workspace_config_malformed_toml",
"workspace_config_atomic_rewrite_needed",
"agent_coordination_stale_lease",
"cass_integration_drift",
"policy_safety_inconsistent",
"snapshot_backup_owed",
"state_file_permission_drift",
"database_missing",
];
pub const FIX_DISPATCHED_FINDINGS: &[&str] = &[
"database_empty",
"database_corrupted",
"database_locked",
"database_unavailable",
"database_migration_drift",
"database_missing",
"search_index_missing",
"search_index_stale",
"schema_migration_pending",
"cass_integration_drift",
];
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FixMode {
AutoRepair,
AutoGuidance,
Manual,
}
impl FixMode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::AutoRepair => "auto_repair",
Self::AutoGuidance => "auto_guidance",
Self::Manual => "manual",
}
}
}
#[must_use]
pub fn store_unreadable<'a>(checks: impl IntoIterator<Item = (&'a str, Option<&'a str>)>) -> bool {
checks.into_iter().any(|(name, code)| {
name == "database"
&& matches!(
code,
Some("EE-E200" | "EE-E201" | "EE-E202" | "EE-E206" | "EE-E207" | "EE-E702")
)
})
}
#[must_use]
pub fn fix_finding_for_check(
error_code: Option<&str>,
check_name: &str,
store_unreadable: bool,
) -> Option<&'static str> {
if check_name == "database" {
match error_code {
Some("EE-E206") => return Some("database_empty"),
Some("EE-E202") => return Some("database_corrupted"),
Some("EE-E201") => return Some("database_locked"),
Some("EE-E207") => return Some("database_unavailable"),
Some("EE-E702") => return Some("database_migration_drift"),
Some("EE-E200") => return Some("database_missing"),
_ => {}
}
}
if store_unreadable
&& (check_name == "search_index"
|| matches!(error_code, Some("EE-E300" | "EE-E301" | "EE-E700")))
{
return None;
}
match error_code {
Some("EE-E300") => Some("search_index_missing"),
Some("EE-E301") => Some("search_index_stale"),
Some("EE-E700") => Some("schema_migration_pending"),
Some("EE-E507") => Some("cass_integration_drift"),
_ if check_name == "search_index" => Some("search_index_stale"),
_ => None,
}
}
#[must_use]
pub fn fix_dispatch_for_finding(workspace_root: &Path, finding: &str) -> Option<FixerDispatch> {
match finding {
"database_empty" => Some(fix_database_empty(workspace_root)),
"database_corrupted" => Some(fix_database_corrupted(workspace_root)),
"database_locked" => Some(fix_database_locked(workspace_root)),
"database_unavailable" => Some(fix_database_unavailable(workspace_root)),
"database_migration_drift" => Some(fix_database_migration_drift(workspace_root)),
"database_missing" => Some(fix_database_missing(workspace_root)),
"search_index_missing" => Some(fix_search_index_missing(workspace_root)),
"search_index_stale" => Some(fix_search_index_stale(workspace_root)),
"schema_migration_pending" => {
Some(fix_schema_migration_pending(workspace_root, "V_LATEST"))
}
"cass_integration_drift" => Some(fix_cass_integration_drift(workspace_root)),
_ => None,
}
}
#[must_use]
pub fn fix_mode_for_check(
error_code: Option<&str>,
check_name: &str,
store_unreadable: bool,
) -> (FixMode, Option<&'static str>) {
let Some(finding) = fix_finding_for_check(error_code, check_name, store_unreadable) else {
return (FixMode::Manual, None);
};
match fix_dispatch_for_finding(Path::new("."), finding) {
Some(dispatch) if dispatch.op.is_advisory() => (FixMode::AutoGuidance, Some(finding)),
Some(_) => (FixMode::AutoRepair, Some(finding)),
None => (FixMode::Manual, None),
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UnresolvedCoreCheck {
pub name: &'static str,
pub severity: &'static str,
pub error_code: Option<&'static str>,
pub repair: Option<&'static str>,
pub fix_mode: &'static str,
pub fix_finding: Option<&'static str>,
}
#[must_use]
pub fn unresolved_core_checks(checks: &[super::doctor::CheckResult]) -> Vec<UnresolvedCoreCheck> {
let unreadable = store_unreadable(
checks
.iter()
.map(|check| (check.name, check.error_code.map(|code| code.id))),
);
checks
.iter()
.filter(|check| !check.is_topline_healthy())
.filter_map(|check| {
let error_code = check.error_code.map(|code| code.id);
let (mode, finding) = fix_mode_for_check(error_code, check.name, unreadable);
if mode == FixMode::AutoRepair {
return None;
}
Some(UnresolvedCoreCheck {
name: check.name,
severity: check.severity.as_str(),
error_code,
repair: check.repair,
fix_mode: mode.as_str(),
fix_finding: finding,
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unresolved_core_checks_retain_undispatched_and_guidance_failures_only() {
use crate::core::doctor::{CheckResult, CheckSeverity};
use crate::models::error_codes::{CASS_DEGRADED, INDEX_NOT_FOUND, MIGRATION_REQUIRED};
let mut uncoded = CheckResult::ok("runtime", "PRIVATE-CHECK-MESSAGE");
uncoded.severity = CheckSeverity::Error;
let checks = [
CheckResult::ok("configuration", "ready"),
CheckResult::warning("search_index", "rebuildable", INDEX_NOT_FOUND),
CheckResult::warning("cass", "optional", CASS_DEGRADED).advisory(),
CheckResult::error("database", "PRIVATE-MIGRATION-MESSAGE", MIGRATION_REQUIRED),
uncoded,
];
let pending = unresolved_core_checks(&checks);
assert_eq!(pending.len(), 2);
assert_eq!(pending[0].name, "database");
assert_eq!(pending[0].fix_mode, "auto_guidance");
assert_eq!(pending[0].fix_finding, Some("schema_migration_pending"));
assert_eq!(pending[1].name, "runtime");
assert_eq!(pending[1].fix_mode, "manual");
assert_eq!(pending[1].error_code, None);
assert_eq!(pending[1].repair, None);
let json = serde_json::to_string(&pending).expect("serializable pending checks");
assert!(!json.contains("PRIVATE"));
assert_eq!(pending, unresolved_core_checks(&checks));
}
#[test]
fn unresolved_core_checks_keep_suppressed_repairs_without_dispatching_them() {
use crate::core::doctor::CheckResult;
use crate::models::error_codes::{DATABASE_CORRUPTED, INDEX_NOT_FOUND, MIGRATION_REQUIRED};
let pending = unresolved_core_checks(&[
CheckResult::error("database", "damaged store", DATABASE_CORRUPTED),
CheckResult::warning("search_index", "no safe source", INDEX_NOT_FOUND),
CheckResult::error("migration", "no safe source", MIGRATION_REQUIRED),
]);
assert_eq!(pending.len(), 3);
assert_eq!(pending[0].fix_finding, Some("database_corrupted"));
for check in &pending[1..] {
assert_eq!(check.fix_mode, "manual");
assert_eq!(check.fix_finding, None);
}
}
#[test]
fn unresolved_core_checks_follow_health_tiers_not_severity_alone() {
use crate::core::doctor::CheckResult;
use crate::models::error_codes::RUNTIME_UNAVAILABLE;
let core = CheckResult::warning("runtime", "required", RUNTIME_UNAVAILABLE);
assert_eq!(unresolved_core_checks(&[core]).len(), 1);
let advisory = CheckResult::error("runtime", "optional", RUNTIME_UNAVAILABLE).advisory();
assert!(unresolved_core_checks(&[advisory]).is_empty());
}
#[cfg(unix)]
#[test]
fn search_index_repair_commands_preserve_the_selected_workspace_as_one_argument() {
for workspace in [
"/tmp/doctor workspace",
"/tmp/agent's store",
"/tmp/$(exit 99); literal",
] {
for dispatch in [
fix_search_index_missing(Path::new(workspace)),
fix_search_index_stale(Path::new(workspace)),
] {
let Op::RunIndexRebuild { steps } = dispatch.op else {
panic!("expected a real index rebuild");
};
for (step, verb) in [(&steps[0], "rebuild"), (&steps[1], "status")] {
let output = std::process::Command::new("sh")
.args(["-c", &format!("set -- {step}; printf '%s\\0' \"$@\"")])
.output()
.expect("shell argument probe");
assert!(output.status.success());
let args: Vec<_> = output
.stdout
.split(|byte| *byte == 0)
.filter(|arg| !arg.is_empty())
.collect();
let expected = ["ee", "index", verb, "--workspace", workspace, "--json"];
assert_eq!(args, expected.map(str::as_bytes));
}
assert!(!steps.join(" ").contains("manifest.last_built_at"));
}
}
}
#[test]
fn every_fix_finding_constructs_a_dispatch_with_that_code() {
let checks = [
(Some("EE-E300"), "search_index", false),
(Some("EE-E301"), "search_index", false),
(Some("EE-E700"), "database", false),
(Some("EE-E507"), "cass", false),
(Some("EE-E999"), "search_index", false),
(Some("EE-E206"), "database", true),
(Some("EE-E202"), "database", true),
(Some("EE-E201"), "database", true),
(Some("EE-E207"), "database", true),
(Some("EE-E702"), "database", true),
(Some("EE-E200"), "database", true),
];
for (code, name, unreadable) in checks {
let finding =
fix_finding_for_check(code, name, unreadable).expect("dispatched finding");
let dispatch = fix_dispatch_for_finding(&root(), finding).expect("dispatch");
assert_eq!(dispatch.finding_code, finding);
}
assert_eq!(
fix_finding_for_check(Some("EE-E102"), "shard_fanout", false),
None
);
assert_eq!(fix_finding_for_check(None, "database", false), None);
assert!(fix_dispatch_for_finding(&root(), "graph_snapshot_stale").is_none());
}
#[test]
fn fix_dispatched_findings_is_exactly_what_the_table_returns() {
let codes = std::iter::once(None).chain(
crate::models::error_codes::ALL_ERROR_CODES
.iter()
.map(|code| Some(code.id)),
);
let mut reached = std::collections::BTreeSet::new();
for code in codes {
for name in ["database", "search_index", "cass", "runtime"] {
for unreadable in [false, true] {
if let Some(finding) = fix_finding_for_check(code, name, unreadable) {
assert!(
FIX_DISPATCHED_FINDINGS.contains(&finding),
"{finding} ({code:?}, {name}) is dispatched but not listed"
);
reached.insert(finding);
}
}
}
}
let listed = FIX_DISPATCHED_FINDINGS
.iter()
.copied()
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(reached, listed, "every listed finding must be reachable");
for finding in FIX_DISPATCHED_FINDINGS {
assert!(
fix_dispatch_for_finding(&root(), finding).is_some(),
"{finding}"
);
}
}
#[test]
fn unreadable_store_skips_the_repairs_that_read_it() {
assert!(store_unreadable([
("search_index", Some("EE-E300")),
("database", Some("EE-E202")),
]));
assert!(store_unreadable([("database", Some("EE-E206"))]));
assert!(store_unreadable([
("database", Some("EE-E200")),
("search_index", Some("EE-E300")),
]));
assert!(!store_unreadable([("database", Some("EE-E700"))]));
assert!(!store_unreadable([("search_index", Some("EE-E202"))]));
for (code, name) in [
(Some("EE-E300"), "search_index"),
(Some("EE-E301"), "search_index"),
(Some("EE-E999"), "search_index"),
(Some("EE-E700"), "database"),
] {
assert_eq!(
fix_finding_for_check(code, name, true),
None,
"{code:?} {name}"
);
}
assert_eq!(
fix_finding_for_check(Some("EE-E507"), "cass", true),
Some("cass_integration_drift")
);
}
#[test]
fn fix_mode_separates_repairs_from_guidance_and_manual_steps() {
assert_eq!(
fix_mode_for_check(Some("EE-E300"), "search_index", false),
(FixMode::AutoRepair, Some("search_index_missing"))
);
assert_eq!(
fix_mode_for_check(Some("EE-E301"), "search_index", false),
(FixMode::AutoRepair, Some("search_index_stale"))
);
assert_eq!(
fix_mode_for_check(Some("EE-E700"), "database", false),
(FixMode::AutoGuidance, Some("schema_migration_pending"))
);
assert_eq!(
fix_mode_for_check(Some("EE-E507"), "cass", false),
(FixMode::AutoGuidance, Some("cass_integration_drift"))
);
assert_eq!(
fix_mode_for_check(Some("EE-E202"), "database", true),
(FixMode::AutoGuidance, Some("database_corrupted"))
);
assert_eq!(
fix_mode_for_check(Some("EE-E300"), "search_index", true),
(FixMode::Manual, None)
);
assert_eq!(
fix_mode_for_check(Some("EE-E200"), "database", true),
(FixMode::AutoGuidance, Some("database_missing"))
);
assert_eq!(
fix_mode_for_check(Some("EE-E102"), "shard_fanout", false),
(FixMode::Manual, None)
);
}
#[test]
fn missing_database_guidance_depends_on_prior_data_evidence() {
let steps = |dispatch: &FixerDispatch| match &dispatch.op {
Op::Manual { steps } => steps.join("\n"),
other => panic!("database_missing must be guidance-only, got {other:?}"),
};
let never_used = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(never_used.path().join(".ee")).expect("bare .ee");
let dispatch = fix_database_missing(never_used.path());
assert_eq!(dispatch.finding_code, "database_missing");
assert!(dispatch.op.is_advisory());
let text = steps(&dispatch);
assert!(
text.contains("Create the store: `ee init --workspace .`"),
"{text}"
);
assert!(!text.contains("Do not run `ee init`"), "{text}");
let lost = tempfile::tempdir().expect("tempdir");
let index = crate::config::workspace::resolve_store_index_dir(lost.path(), None, None);
std::fs::create_dir_all(&index).expect("index dir");
std::fs::write(index.join("segment"), b"x").expect("index entry");
let text = steps(&fix_database_missing(lost.path()));
assert!(text.contains("Do not run `ee init`"), "{text}");
assert!(text.contains("ee backup list --workspace ."), "{text}");
}
fn root() -> PathBuf {
PathBuf::from("/tmp/doctor-fixers-test")
}
#[test]
fn fixer_codes_are_unique() {
let mut sorted = FIXER_FINDING_CODES.to_vec();
sorted.sort();
sorted.dedup();
assert_eq!(
sorted.len(),
FIXER_FINDING_CODES.len(),
"codes must be unique"
);
}
#[test]
fn search_index_stale_dispatches_run_index_rebuild() {
let dispatch = fix_search_index_stale(&root());
assert_eq!(dispatch.finding_code, "search_index_stale");
let Op::RunIndexRebuild { steps } = &dispatch.op else {
panic!("expected RunIndexRebuild");
};
assert_eq!(
steps[0],
"ee index rebuild --workspace '/tmp/doctor-fixers-test' --json"
);
assert_eq!(dispatch.op.kind_str(), "run_index_rebuild");
assert!(!dispatch.op.is_advisory());
assert!(dispatch.op.is_writing());
}
#[test]
fn search_index_fixers_target_the_directory_the_detector_inspects() {
let workspace = root();
let expected = crate::config::workspace::resolve_store_index_dir(&workspace, None, None);
assert!(expected.ends_with(".ee/index"), "{}", expected.display());
for dispatch in [
fix_search_index_stale(&workspace),
fix_search_index_missing(&workspace),
] {
assert_eq!(dispatch.path, expected, "{}", dispatch.finding_code);
}
}
#[test]
fn search_index_missing_is_its_own_finding() {
let dispatch = fix_search_index_missing(&root());
assert_eq!(dispatch.finding_code, "search_index_missing");
let Op::RunIndexRebuild { steps } = &dispatch.op else {
panic!("expected RunIndexRebuild");
};
assert_eq!(
steps[0],
"ee index rebuild --workspace '/tmp/doctor-fixers-test' --json"
);
assert_eq!(
steps[1],
"ee index status --workspace '/tmp/doctor-fixers-test' --json"
);
assert!(!dispatch.op.is_advisory());
assert!(dispatch.op.is_writing());
}
#[test]
fn graph_snapshot_stale_dispatches_run_graph_refresh() {
let dispatch = fix_graph_snapshot_stale(&root());
assert!(matches!(dispatch.op, Op::RunGraphRefresh { .. }));
assert_eq!(dispatch.op.kind_str(), "run_graph_refresh");
}
#[test]
fn wal_checkpoint_pending_dispatches_run_wal_checkpoint() {
let dispatch = fix_wal_checkpoint_pending(&root());
let Op::RunWalCheckpoint {
ref mode,
ref steps,
} = dispatch.op
else {
panic!("expected RunWalCheckpoint");
};
assert_eq!(mode, "truncate");
assert_eq!(
steps[0],
"ee maintenance wal-checkpoint --workspace . --mode truncate --json"
);
assert_eq!(dispatch.op.kind_str(), "run_wal_checkpoint");
}
#[test]
fn schema_migration_pending_dispatches_run_migration() {
let dispatch = fix_schema_migration_pending(&root(), "V099");
let Op::RunMigration {
ref target_version,
ref steps,
} = dispatch.op
else {
panic!("expected RunMigration");
};
assert_eq!(target_version, "V099");
assert_eq!(steps[0], "ee migrate run --workspace . --json");
assert_eq!(dispatch.severity, "error");
}
#[test]
fn beads_jsonl_drift_dispatches_rewrite_jsonl() {
let dispatch = fix_beads_jsonl_drift(&root(), 2317);
let Op::RewriteJsonl { row_count, .. } = dispatch.op else {
panic!("expected RewriteJsonl");
};
assert_eq!(row_count, 2317);
}
#[test]
fn workspace_config_malformed_toml_dispatches_quarantine() {
let dispatch = fix_workspace_config_malformed_toml(&root());
assert!(matches!(dispatch.op, Op::QuarantineByRename { .. }));
assert!(dispatch.op.is_writing());
}
#[test]
fn workspace_config_atomic_rewrite_dispatches_atomic_rewrite_toml() {
let dispatch = fix_workspace_config_atomic_rewrite(&root(), "set runtime.profile = swarm");
assert!(matches!(dispatch.op, Op::AtomicRewriteToml { .. }));
assert_eq!(dispatch.op.kind_str(), "atomic_rewrite_toml");
}
#[test]
fn agent_coordination_stale_lease_dispatches_quarantine_under_workspace() {
let workspace = root();
let marker = workspace.join(".agent-mail/file_reservations/abc.json");
let dispatch = fix_agent_coordination_stale_lease(&workspace, &marker);
let Op::QuarantineByRename {
ref dest_under_quarantine,
} = dispatch.op
else {
panic!("expected QuarantineByRename");
};
assert_eq!(
dest_under_quarantine,
&PathBuf::from(".agent-mail/file_reservations/abc.json")
);
}
#[test]
fn cass_integration_drift_is_manual() {
let dispatch = fix_cass_integration_drift(&root());
let Op::Manual { steps } = &dispatch.op else {
panic!("expected Manual");
};
assert_eq!(
steps,
&[
"Run `ee import cass --dry-run --json` to preview the affected workspace import.",
"Run `ee import cass --json` to refresh imported CASS evidence from the source corpus.",
]
);
assert!(
steps.iter().all(|step| !step.contains("ee cass")),
"manual CASS repair steps must use the shipped `ee import cass` surface"
);
assert!(dispatch.op.is_advisory());
}
#[test]
fn policy_safety_inconsistent_is_manual_error() {
let dispatch = fix_policy_safety_inconsistent(&root());
assert!(matches!(dispatch.op, Op::Manual { .. }));
assert_eq!(dispatch.severity, "error");
}
#[test]
fn snapshot_backup_owed_dispatches_snapshot_backup() {
let dispatch = fix_snapshot_backup_owed(&root(), "doctor pre ' migration");
let Op::SnapshotBackup { ref label, .. } = dispatch.op else {
panic!("expected SnapshotBackup");
};
assert_eq!(label, "doctor pre ' migration");
}
#[test]
fn snapshot_backup_owed_uses_workspace_json_command_and_quotes_label() {
let dispatch = fix_snapshot_backup_owed(&root(), "doctor pre ' migration");
let Op::SnapshotBackup { ref steps, .. } = dispatch.op else {
panic!("expected SnapshotBackup");
};
assert_eq!(
steps[0],
"ee backup create --workspace . --label 'doctor pre '\\'' migration' --json"
);
}
#[test]
fn state_file_permission_drift_dispatches_chmod_0600() {
let dispatch = fix_state_file_permission_drift(root().join(".ee/secrets.json"));
let Op::Chmod { mode } = dispatch.op else {
panic!("expected Chmod");
};
assert_eq!(mode, 0o600);
assert!(dispatch.op.is_writing());
}
#[test]
fn every_registered_code_emits_a_dispatch_for_its_finding() {
let workspace = root();
let dispatches: Vec<&'static str> = vec![
fix_search_index_stale(&workspace).finding_code,
fix_search_index_missing(&workspace).finding_code,
fix_database_empty(&workspace).finding_code,
fix_database_corrupted(&workspace).finding_code,
fix_database_locked(&workspace).finding_code,
fix_database_unavailable(&workspace).finding_code,
fix_database_migration_drift(&workspace).finding_code,
fix_database_missing(&workspace).finding_code,
fix_graph_snapshot_stale(&workspace).finding_code,
fix_wal_checkpoint_pending(&workspace).finding_code,
fix_schema_migration_pending(&workspace, "V001").finding_code,
fix_beads_jsonl_drift(&workspace, 0).finding_code,
fix_workspace_config_malformed_toml(&workspace).finding_code,
fix_workspace_config_atomic_rewrite(&workspace, "noop").finding_code,
fix_agent_coordination_stale_lease(&workspace, &workspace.join("a.json")).finding_code,
fix_cass_integration_drift(&workspace).finding_code,
fix_policy_safety_inconsistent(&workspace).finding_code,
fix_snapshot_backup_owed(&workspace, "x").finding_code,
fix_state_file_permission_drift(workspace.join("a")).finding_code,
];
let mut sorted = dispatches.clone();
sorted.sort();
let mut expected = FIXER_FINDING_CODES.to_vec();
expected.sort();
assert_eq!(sorted, expected);
}
fn fixer_codes_in_source(source: &str) -> std::collections::BTreeMap<String, Option<String>> {
let production = source.split("#[cfg(test)]").next().unwrap_or(source);
let mut codes = std::collections::BTreeMap::new();
let mut current: Option<String> = None;
let mut awaiting_manual_code = false;
for line in production.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("pub fn fix_") {
let name: String = rest
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
let name = format!("fix_{name}");
codes.insert(name.clone(), None);
current = Some(name);
awaiting_manual_code = false;
continue;
}
if trimmed.starts_with("fn ")
|| (trimmed.starts_with("pub") && trimmed.contains(" fn "))
{
current = None;
continue;
}
let Some(name) = current.clone() else {
continue;
};
if codes.get(&name).is_some_and(Option::is_some) {
continue;
}
let literal = |text: &str| text.split('"').nth(1).map(str::to_owned);
if let Some(rest) = trimmed.strip_prefix("finding_code: ") {
if let Some(code) = literal(rest) {
codes.insert(name, Some(code));
}
} else if trimmed.starts_with("FixerDispatch::manual(") {
awaiting_manual_code = true;
} else if awaiting_manual_code && trimmed.starts_with('"') {
if let Some(code) = literal(trimmed) {
codes.insert(name, Some(code));
}
awaiting_manual_code = false;
}
}
codes
}
fn dispatched_fixers(source: &str, header: &str) -> std::collections::BTreeSet<String> {
let Some(start) = source.find(&format!("\n{header}")) else {
return std::collections::BTreeSet::new();
};
let body = &source[start + 1 + header.len()..];
let body = body.find("\n}").map_or(body, |end| &body[..end]);
let mut called = std::collections::BTreeSet::new();
let mut rest = body;
while let Some(at) = rest.find("fix_") {
let preceded_by_ident = rest[..at]
.chars()
.next_back()
.is_some_and(|c| c.is_ascii_alphanumeric() || c == '_');
let tail = &rest[at..];
let name: String = tail
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
if !preceded_by_ident && tail[name.len()..].starts_with('(') {
called.insert(name.clone());
}
rest = &tail[name.len().max(1)..];
}
called
}
#[test]
fn fixer_source_scanners_see_planted_fixers_and_dispatches() {
let module = r#"
pub fn fix_planted_field(root: &Path) -> FixerDispatch {
FixerDispatch {
finding_code: "planted_field",
}
}
pub fn fix_planted_manual(root: &Path) -> FixerDispatch {
FixerDispatch::manual(
"planted_manual",
"error",
)
}
pub fn fix_planted_table(root: &Path, finding: &str) -> Option<FixerDispatch> {
match finding {
"planted_field" => Some(fix_planted_field(root)),
_ => None,
}
}
pub fn not_a_fixer() -> FixerDispatch {
FixerDispatch {
finding_code: "stray",
}
}
#[cfg(test)]
pub fn fix_test_only() -> FixerDispatch { finding_code: "test_only" }
"#;
let codes = fixer_codes_in_source(module);
let code = |name: &str| codes.get(name).cloned();
assert_eq!(
code("fix_planted_field"),
Some(Some("planted_field".to_owned()))
);
assert_eq!(
code("fix_planted_manual"),
Some(Some("planted_manual".to_owned()))
);
assert_eq!(code("fix_planted_table"), Some(None), "{codes:?}");
assert!(!codes.contains_key("not_a_fixer"), "{codes:?}");
assert!(!codes.contains_key("fix_test_only"), "{codes:?}");
let table = dispatched_fixers(module, "pub fn fix_planted_table(");
assert_eq!(
table.into_iter().collect::<Vec<_>>(),
vec!["fix_planted_field".to_owned()]
);
let cli = "\nfn other() { fix_elsewhere(x) }\nfn doctor_fix_json(w: &Path) {\n Some(fix_planted_field(w));\n prefix_fix_not_a_call(w);\n}\nfn after() { fix_after(x) }\n";
let called = dispatched_fixers(cli, "fn doctor_fix_json(");
assert_eq!(
called.into_iter().collect::<Vec<_>>(),
vec!["fix_planted_field".to_owned()]
);
assert!(dispatched_fixers(cli, "fn absent(").is_empty());
}
#[test]
fn fixer_codes_match_every_fixer_and_every_dispatch() {
let module = include_str!("doctor_fixers.rs");
let fixers = fixer_codes_in_source(module);
let derived: std::collections::BTreeSet<&str> =
fixers.values().flatten().map(String::as_str).collect();
let registered: std::collections::BTreeSet<&str> =
FIXER_FINDING_CODES.iter().copied().collect();
assert_eq!(
derived, registered,
"FIXER_FINDING_CODES must equal the codes the fix_* functions emit"
);
let dispatched_unregistered: Vec<&str> = FIX_DISPATCHED_FINDINGS
.iter()
.copied()
.filter(|finding| !registered.contains(finding))
.collect();
assert!(
dispatched_unregistered.is_empty(),
"findings --fix dispatches that are not in FIXER_FINDING_CODES: {dispatched_unregistered:?}"
);
let table = dispatched_fixers(module, "pub fn fix_dispatch_for_finding(");
assert!(
!table.is_empty(),
"fix_dispatch_for_finding must be found and dispatch at least one fixer"
);
let table_violations: Vec<(&String, Option<&Option<String>>)> = table
.iter()
.map(|name| (name, fixers.get(name)))
.filter(
|(_, code)| !matches!(code, Some(Some(code)) if registered.contains(code.as_str())),
)
.collect();
assert!(
table_violations.is_empty(),
"fix_dispatch_for_finding calls fixers with no registered code: {table_violations:?}"
);
assert!(
include_str!("../cli/mod.rs").contains("fix_dispatch_for_finding("),
"src/cli/mod.rs must dispatch --fix through fix_dispatch_for_finding"
);
}
}