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: workspace_root.join(".ee/indexes"),
op: Op::RunIndexRebuild {
steps: vec![
"ee index rebuild --workspace .".to_string(),
"Confirm `manifest.last_built_at` advances past the latest memories.updated_at."
.to_string(),
],
},
}
}
#[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",
"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",
];
#[cfg(test)]
mod tests {
use super::*;
fn root() -> PathBuf {
PathBuf::from("/tmp/doctor-fixers-test")
}
#[test]
fn twelve_fixer_codes_are_registered() {
assert_eq!(FIXER_FINDING_CODES.len(), 12);
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 .");
assert_eq!(dispatch.op.kind_str(), "run_index_rebuild");
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_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);
}
}