use anyhow::Result;
use std::path::PathBuf;
use crate::{
canonical_system_root, effective_system_policy_path, is_user_service_active,
resolve_bin_for_readonly_probe,
};
const GUARD_SERVICE: &str = "dracon-system-guard.service";
const SYNC_SERVICE: &str = "dracon-sync.service";
const SYNC_WATCHDOG_TIMER: &str = "dracon-sync-watchdog.timer";
const FREEZE_WATCHDOG_TIMER: &str = "dracon-freeze-watchdog.timer";
const GUARD_WATCHDOG_TIMER: &str = "dracon-system-guard-watchdog.timer";
pub(crate) async fn build_doctor_report() -> crate::DoctorReport {
let root = canonical_system_root();
let nixos = root.join("nixos");
let libs = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/home"))
.join("Dev/dracon-libs");
let utils = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/home"))
.join("Dev/dracon-utilities");
let policy = root.join("utilities/sync/dracon-sync.toml");
let legacy_cfg = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/home"))
.join(".config/dracon");
let system_policy = effective_system_policy_path().unwrap_or_else(|_| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/home"))
.join(".dracon/utilities/system/dracon-system.toml")
});
let service_probe_available = resolve_bin_for_readonly_probe("systemctl").is_ok();
crate::DoctorReport {
system_root_exists: root.exists(),
nixos_root_exists: nixos.exists(),
canonical_libs_exists: libs.exists(),
canonical_utils_exists: utils.exists(),
sync_policy_exists: policy.exists(),
legacy_config_dracon_exists: legacy_cfg.exists(),
sync_service_active: is_user_service_active(SYNC_SERVICE).await,
system_policy_exists: system_policy.exists(),
guard_service_active: is_user_service_active(GUARD_SERVICE).await,
sync_watchdog_timer_active: is_user_service_active(SYNC_WATCHDOG_TIMER).await,
freeze_watchdog_timer_active: is_user_service_active(FREEZE_WATCHDOG_TIMER).await,
guard_watchdog_timer_active: is_user_service_active(GUARD_WATCHDOG_TIMER).await,
service_probe_available,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CheckState {
Ok,
Fail,
Skipped,
}
#[derive(Debug)]
struct DoctorCheck {
label: &'static str,
state: CheckState,
required: bool,
hint: &'static str,
}
fn doctor_checks(report: &crate::DoctorReport) -> Vec<DoctorCheck> {
let yes = CheckState::Ok;
let no = CheckState::Fail;
let probe = report.service_probe_available;
let service_state = |active: bool| {
if probe {
if active {
yes
} else {
no
}
} else {
CheckState::Skipped
}
};
vec![
DoctorCheck {
label: "~/.dracon",
state: if report.system_root_exists { yes } else { no },
required: true,
hint: "Create the canonical system root at ~/.dracon",
},
DoctorCheck {
label: "~/.dracon/nixos",
state: if report.nixos_root_exists { yes } else { no },
required: true,
hint: "Clone or symlink your NixOS config under ~/.dracon/nixos",
},
DoctorCheck {
label: "dracon-libs (dev sibling)",
state: if report.canonical_libs_exists { yes } else { no },
required: false,
hint: "Optional for installed binaries. Required only for `cargo build` from source: git clone https://github.com/DraconDev/dracon-libs.git ../dracon-libs",
},
DoctorCheck {
label: "dracon-utilities (self)",
state: if report.canonical_utils_exists { yes } else { no },
required: true,
hint: "This binary should live at ~/Dev/dracon-utilities (or its install.sh target)",
},
DoctorCheck {
label: "sync policy",
state: if report.sync_policy_exists { yes } else { no },
required: true,
hint: "Copy dracon-sync.example.toml to ~/.dracon/utilities/sync/dracon-sync.toml",
},
DoctorCheck {
label: "system policy (guard)",
state: if report.system_policy_exists { yes } else { no },
required: true,
hint: "Copy dracon-system.example.toml to ~/.dracon/utilities/system/dracon-system.toml (or set DRACON_SYSTEM_POLICY)",
},
DoctorCheck {
label: "guard service",
state: service_state(report.guard_service_active),
required: true,
hint: "systemctl --user enable --now dracon-system-guard.service",
},
DoctorCheck {
label: "legacy config absent",
state: if report.legacy_config_dracon_exists {
no
} else {
yes
},
required: true,
hint: "Move or remove the legacy ~/.config/dracon directory",
},
DoctorCheck {
label: "sync service",
state: service_state(report.sync_service_active),
required: true,
hint: "systemctl --user enable --now dracon-sync.service",
},
DoctorCheck {
label: "sync watchdog timer",
state: service_state(report.sync_watchdog_timer_active),
required: true,
hint: "systemctl --user enable --now dracon-sync-watchdog.timer",
},
DoctorCheck {
label: "freeze watchdog timer",
state: service_state(report.freeze_watchdog_timer_active),
required: true,
hint: "systemctl --user enable --now dracon-freeze-watchdog.timer",
},
DoctorCheck {
label: "guard watchdog timer",
state: service_state(report.guard_watchdog_timer_active),
required: true,
hint: "systemctl --user enable --now dracon-system-guard-watchdog.timer",
},
]
}
fn strict_ok(checks: &[DoctorCheck]) -> bool {
!checks
.iter()
.any(|c| c.required && c.state == CheckState::Fail)
}
fn failed_required_labels(checks: &[DoctorCheck]) -> Vec<String> {
checks
.iter()
.filter(|c| c.required && c.state == CheckState::Fail)
.map(|c| c.label.to_string())
.collect()
}
#[derive(Debug, serde::Serialize)]
struct DoctorJsonReport<'a> {
#[serde(flatten)]
report: &'a crate::DoctorReport,
strict_ok: bool,
failed_required: Vec<String>,
}
fn doctor_status(state: CheckState) -> &'static str {
match state {
CheckState::Ok => "ok",
CheckState::Fail => "fail",
CheckState::Skipped => "n/a",
}
}
pub(crate) async fn cmd_doctor(json: bool, strict: bool) -> Result<()> {
use comfy_table::{presets::UTF8_FULL_CONDENSED, Cell, Color, ContentArrangement, Table};
let report = build_doctor_report().await;
let checks = doctor_checks(&report);
if json {
let out = DoctorJsonReport {
report: &report,
strict_ok: strict_ok(&checks),
failed_required: failed_required_labels(&checks),
};
println!("{}", serde_json::to_string_pretty(&out)?);
if strict && !strict_ok(&checks) {
std::process::exit(1);
}
return Ok(());
}
let mut table = Table::new();
table
.load_preset(UTF8_FULL_CONDENSED)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec![
Cell::new(" "),
Cell::new("CHECK"),
Cell::new("STATUS"),
Cell::new("REQUIRED"),
]);
let mut failures: Vec<&DoctorCheck> = Vec::new();
for check in &checks {
let (icon, color) = match check.state {
CheckState::Ok => ("\u{2705}", Color::Green),
CheckState::Fail => ("\u{274c}", Color::Red),
CheckState::Skipped => ("\u{2013}", Color::DarkGrey),
};
if check.required && check.state == CheckState::Fail {
failures.push(check);
}
table.add_row(vec![
Cell::new(icon).fg(color),
Cell::new(check.label),
Cell::new(doctor_status(check.state)),
Cell::new(if check.required { "yes" } else { "no" }),
]);
}
println!("{table}");
if failures.is_empty() {
eprintln!("\u{2705} All required checks passed.");
} else {
eprintln!();
eprintln!("\u{26a0}\u{fe0f} Some required checks failed. Remediation:");
for check in &failures {
eprintln!(" \u{274c} {}: {}", check.label, check.hint);
}
for check in &checks {
if !check.required && check.state == CheckState::Fail {
eprintln!(
" \u{2139}\u{fe0f} {} (optional): {}",
check.label, check.hint
);
}
}
eprintln!();
eprintln!("Run with --json for machine-readable details.");
if strict {
std::process::exit(1);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::DoctorReport;
fn report() -> DoctorReport {
DoctorReport {
system_root_exists: true,
nixos_root_exists: true,
canonical_libs_exists: true,
canonical_utils_exists: true,
sync_policy_exists: true,
legacy_config_dracon_exists: false,
sync_service_active: true,
system_policy_exists: true,
guard_service_active: true,
sync_watchdog_timer_active: true,
freeze_watchdog_timer_active: true,
guard_watchdog_timer_active: true,
service_probe_available: true,
}
}
#[test]
fn doctor_checks_show_system_root_and_failure_status() {
let mut r = report();
r.system_root_exists = false;
let checks = doctor_checks(&r);
let root_check = checks
.iter()
.find(|c| c.label == "~/.dracon")
.expect("system root check should be displayed");
assert_eq!(root_check.state, CheckState::Fail);
assert_eq!(doctor_status(CheckState::Fail), "fail");
assert_eq!(doctor_status(CheckState::Ok), "ok");
}
#[test]
fn doctor_covers_the_guard_itself() {
let checks = doctor_checks(&report());
for expected in ["system policy (guard)", "guard service"] {
assert!(
checks.iter().any(|c| c.label == expected),
"doctor must check '{expected}' — it is the guard utility's own diagnostic"
);
}
}
#[test]
fn strict_ignores_optional_checks() {
let mut r = report();
r.canonical_libs_exists = false; let checks = doctor_checks(&r);
assert!(
strict_ok(&checks),
"a missing optional sibling must not fail --strict: {checks:?}"
);
let mut r = report();
r.system_policy_exists = false;
assert!(
!strict_ok(&doctor_checks(&r)),
"a missing system policy must fail --strict"
);
}
#[test]
fn json_envelope_carries_strict_verdict() {
let r = report();
let checks = doctor_checks(&r);
let out = DoctorJsonReport {
report: &r,
strict_ok: strict_ok(&checks),
failed_required: failed_required_labels(&checks),
};
let value = serde_json::to_value(&out).expect("envelope serializes");
assert_eq!(value["strict_ok"], true);
assert_eq!(value["failed_required"].as_array().unwrap().len(), 0);
assert_eq!(
value["system_policy_exists"], true,
"raw report fields must stay flattened in the envelope"
);
let mut r = report();
r.system_policy_exists = false;
let checks = doctor_checks(&r);
let out = DoctorJsonReport {
report: &r,
strict_ok: strict_ok(&checks),
failed_required: failed_required_labels(&checks),
};
let value = serde_json::to_value(&out).expect("envelope serializes");
assert_eq!(value["strict_ok"], false);
let failed = value["failed_required"].as_array().unwrap();
assert!(
failed.iter().any(|v| v == "system policy (guard)"),
"failed_required must name the failing required check: {failed:?}"
);
}
#[test]
fn legacy_hint_names_checked_path() {
let checks = doctor_checks(&report());
let legacy = checks
.iter()
.find(|c| c.label == "legacy config absent")
.expect("legacy config check should be displayed");
assert!(
legacy.hint.contains("~/.config/dracon"),
"hint must name the checked path, got: {}",
legacy.hint
);
assert!(
!legacy.hint.contains("~/dracon "),
"hint must not name the never-checked ~/dracon, got: {}",
legacy.hint
);
}
#[test]
fn doctor_covers_m8_watchdog_timers() {
let checks = doctor_checks(&report());
for expected in [
"sync watchdog timer",
"freeze watchdog timer",
"guard watchdog timer",
] {
let c = checks
.iter()
.find(|c| c.label == expected)
.unwrap_or_else(|| panic!("doctor must check '{expected}'"));
assert!(c.required, "'{expected}' must be required");
assert_eq!(c.state, CheckState::Ok);
}
let mut r = report();
r.freeze_watchdog_timer_active = false;
assert!(
!strict_ok(&doctor_checks(&r)),
"a missing watchdog timer must fail --strict"
);
let mut r = report();
r.service_probe_available = false;
r.sync_watchdog_timer_active = false;
r.freeze_watchdog_timer_active = false;
r.guard_watchdog_timer_active = false;
let checks = doctor_checks(&r);
for label in [
"sync watchdog timer",
"freeze watchdog timer",
"guard watchdog timer",
] {
let c = checks.iter().find(|c| c.label == label).expect(label);
assert_eq!(
c.state,
CheckState::Skipped,
"'{label}' must be n/a when systemctl is absent, not fail"
);
}
assert!(
strict_ok(&checks),
"unanswerable timer checks must not fail --strict"
);
}
#[test]
fn an_absent_systemctl_is_skipped_not_failed() {
let mut r = report();
r.service_probe_available = false;
r.sync_service_active = false;
r.guard_service_active = false;
let checks = doctor_checks(&r);
for label in ["guard service", "sync service"] {
let c = checks.iter().find(|c| c.label == label).expect(label);
assert_eq!(
c.state,
CheckState::Skipped,
"'{label}' must be n/a when systemctl is absent, not fail"
);
}
assert!(
strict_ok(&checks),
"an unanswerable service check must not fail --strict"
);
assert_eq!(doctor_status(CheckState::Skipped), "n/a");
}
}