mod audit;
mod checks;
mod mcp_registration;
mod tier_s;
use audit::audit_palaces;
pub use audit::{PalaceAuditEntry, PalaceAuditStatus};
#[cfg(target_os = "macos")]
use checks::check_launchd_plist;
use checks::{
check_daemon_health, check_fastembed_cache, check_kg_redb_size, check_stale_palace_locks,
};
use mcp_registration::check_mcp_registrations;
use tier_s::check_tier_s_reaffirmation;
use anyhow::Result;
use colored::Colorize;
use crate::project_root::PERSONAL_PALACE;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum CheckStatus {
Pass,
Warn,
Fail,
Unknown,
}
#[derive(Debug, Clone)]
pub(super) struct CheckResult {
pub(super) status: CheckStatus,
label: String,
detail: Option<String>,
}
impl CheckResult {
pub(super) fn pass(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
status: CheckStatus::Pass,
label: label.into(),
detail: Some(detail.into()),
}
}
pub(super) fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
status: CheckStatus::Warn,
label: label.into(),
detail: Some(detail.into()),
}
}
pub(super) fn fail(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
status: CheckStatus::Fail,
label: label.into(),
detail: Some(detail.into()),
}
}
pub(super) fn unknown(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
status: CheckStatus::Unknown,
label: label.into(),
detail: Some(detail.into()),
}
}
pub(super) fn print(&self) {
let glyph = match self.status {
CheckStatus::Pass => "✅".to_string(),
CheckStatus::Warn => "⚠️ ".to_string(),
CheckStatus::Fail => "❌".to_string(),
CheckStatus::Unknown => "❔".to_string(),
};
let label = match self.status {
CheckStatus::Pass => self.label.green().to_string(),
CheckStatus::Warn => self.label.yellow().to_string(),
CheckStatus::Fail => self.label.red().to_string(),
CheckStatus::Unknown => self.label.yellow().to_string(),
};
match &self.detail {
Some(d) => println!("{glyph} {label} — {}", d.dimmed()),
None => println!("{glyph} {label}"),
}
}
}
pub async fn handle_doctor_fix_palaces(suggest_fix: bool) -> Result<()> {
let data_dir = match trusty_common::resolve_data_dir("trusty-memory") {
Ok(d) => d,
Err(e) => {
eprintln!("{} could not resolve data directory: {e:#}", "✗".red());
return Ok(());
}
};
let registry_dir = crate::resolve_palace_registry_dir(data_dir);
println!(
"{} Auditing palaces under {}\n",
"·".dimmed(),
registry_dir.display()
);
let entries = audit_palaces(®istry_dir);
if entries.is_empty() {
println!("{} No palace directories found.", "·".dimmed());
return Ok(());
}
let mut ok_count = 0usize;
let mut orphaned_count = 0usize;
let mut empty_count = 0usize;
for entry in &entries {
match entry.status {
PalaceAuditStatus::Ok => {
ok_count += 1;
println!(
"✅ {} — {}",
entry.id.green(),
"project palace ok".dimmed()
);
}
PalaceAuditStatus::Orphaned => {
orphaned_count += 1;
println!(
"⚠️ {} — {}",
entry.id.yellow(),
"orphaned (no matching project directory found on disk)".dimmed()
);
if suggest_fix {
println!(
" {} rename suggested: {} → {}",
"→".dimmed(),
entry.id.yellow(),
PERSONAL_PALACE.cyan()
);
}
}
PalaceAuditStatus::Empty => {
empty_count += 1;
println!(
"❌ {} — {}",
entry.id.red(),
"empty (no palace.json; directory may be a leftover)".dimmed()
);
}
}
}
println!();
println!(
"{} palace audit: {} ok, {} orphaned, {} empty.",
"·".dimmed(),
ok_count,
orphaned_count,
empty_count
);
if orphaned_count > 0 && !suggest_fix {
println!(
"{} Run with {} to see rename suggestions (no filesystem changes made).",
"·".dimmed(),
"--fix-palaces --fix".cyan()
);
}
if suggest_fix && orphaned_count > 0 {
println!(
"{} Rename suggestions printed above (dry-run — no filesystem changes made).",
"·".dimmed()
);
}
Ok(())
}
pub async fn handle_doctor() -> Result<()> {
println!("{} Running trusty-memory diagnostics…\n", "·".dimmed());
let mut results: Vec<CheckResult> = Vec::new();
results.push(check_fastembed_cache());
#[cfg(target_os = "macos")]
{
results.push(check_launchd_plist());
}
#[cfg(not(target_os = "macos"))]
{
results.push(CheckResult::warn(
"launchd plist".to_string(),
"skipped (not macOS)".to_string(),
));
}
results.push(check_daemon_health().await);
results.push(check_stale_palace_locks());
results.push(check_kg_redb_size());
results.push(check_tier_s_reaffirmation().await);
results.extend(check_mcp_registrations(
crate::commands::setup::MCP_SERVER_KEY,
));
for r in &results {
r.print();
}
let summary = checks::summarize(&results);
println!();
if summary.healthy {
println!("{} {}", "✓".green(), summary.line);
Ok(())
} else {
eprintln!("{} {}", "✗".red(), summary.line);
std::process::exit(1);
}
}
#[cfg(test)]
mod tests {
use super::audit::scan_project_dirs_for_pin;
#[cfg(target_os = "macos")]
use super::checks::plist_contains_fastembed_cache_path;
use super::checks::{fastembed_cache_has_models, find_lock_files};
use super::*;
#[test]
fn fastembed_cache_check_reports_missing_dir() {
let tmp = tempfile::tempdir().expect("tempdir");
let missing = tmp.path().join("does_not_exist");
unsafe {
std::env::remove_var("FASTEMBED_CACHE_DIR");
std::env::set_var("FASTEMBED_CACHE_PATH", &missing);
}
let result = check_fastembed_cache();
unsafe {
std::env::remove_var("FASTEMBED_CACHE_PATH");
}
assert_eq!(result.status, CheckStatus::Fail, "got: {:?}", result);
}
#[test]
fn fastembed_cache_has_models_detects_entries() {
let tmp = tempfile::tempdir().expect("tempdir");
assert!(!fastembed_cache_has_models(tmp.path()).unwrap());
std::fs::write(tmp.path().join("model.onnx"), b"x").unwrap();
assert!(fastembed_cache_has_models(tmp.path()).unwrap());
}
#[cfg(target_os = "macos")]
#[test]
fn plist_check_detects_missing_env_var() {
let tmp = tempfile::tempdir().expect("tempdir");
let no_key = tmp.path().join("no_key.plist");
std::fs::write(&no_key, "<plist><dict></dict></plist>").unwrap();
assert!(
!plist_contains_fastembed_cache_path(&no_key).unwrap(),
"plist without env var must report false"
);
let with_key = tmp.path().join("with_key.plist");
std::fs::write(
&with_key,
"<plist><dict><key>FASTEMBED_CACHE_PATH</key><string>/x</string></dict></plist>",
)
.unwrap();
assert!(
plist_contains_fastembed_cache_path(&with_key).unwrap(),
"plist with env var must report true"
);
}
#[test]
fn find_orphaned_palaces_lists_non_matching_and_empty() {
let tmp = tempfile::tempdir().expect("tempdir");
let registry = tmp.path();
let personal = registry.join("personal");
std::fs::create_dir_all(&personal).unwrap();
std::fs::write(personal.join("palace.json"), b"{}").unwrap();
let orphaned = registry.join("orphaned-proj-xyzzy");
std::fs::create_dir_all(&orphaned).unwrap();
std::fs::write(orphaned.join("palace.json"), b"{}").unwrap();
let empty = registry.join("empty-palace");
std::fs::create_dir_all(&empty).unwrap();
let entries = audit_palaces(registry);
let personal_entry = entries.iter().find(|e| e.id == "personal");
assert!(personal_entry.is_some(), "personal must appear in audit");
assert_eq!(
personal_entry.unwrap().status,
PalaceAuditStatus::Ok,
"personal must be Ok"
);
let orphaned_entry = entries.iter().find(|e| e.id == "orphaned-proj-xyzzy");
assert!(orphaned_entry.is_some(), "orphaned entry must appear");
assert_eq!(
orphaned_entry.unwrap().status,
PalaceAuditStatus::Orphaned,
"orphaned-proj-xyzzy must be Orphaned"
);
let empty_entry = entries.iter().find(|e| e.id == "empty-palace");
assert!(empty_entry.is_some(), "empty entry must appear");
assert_eq!(
empty_entry.unwrap().status,
PalaceAuditStatus::Empty,
"empty-palace must be Empty"
);
}
#[test]
fn audit_palaces_ok_when_pin_file_claims_it() {
use crate::project_root::{write_project_pin, ProjectPin};
let tmp = tempfile::tempdir().expect("tempdir");
let projects_dir = tmp.path().join("Projects");
let project_dir = projects_dir.join("moved-project");
std::fs::create_dir_all(&project_dir).unwrap();
let pin = ProjectPin::new("my-old-name".to_string());
write_project_pin(&project_dir, &pin).expect("write pin");
assert!(
scan_project_dirs_for_pin(std::slice::from_ref(&projects_dir), "my-old-name"),
"scan must find the pin file that claims my-old-name"
);
assert!(
!scan_project_dirs_for_pin(std::slice::from_ref(&projects_dir), "some-other-palace"),
"scan must not match a palace id not claimed by any pin"
);
}
#[test]
fn scan_project_dirs_returns_false_for_mismatch() {
use crate::project_root::{write_project_pin, ProjectPin};
let tmp = tempfile::tempdir().expect("tempdir");
let projects_dir = tmp.path().join("Projects");
let project_dir = projects_dir.join("some-project");
std::fs::create_dir_all(&project_dir).unwrap();
let pin = ProjectPin::new("alpha".to_string());
write_project_pin(&project_dir, &pin).expect("write pin");
assert!(
!scan_project_dirs_for_pin(&[projects_dir], "beta"),
"mismatch must return false"
);
}
#[test]
fn find_lock_files_returns_paths() {
let tmp = tempfile::tempdir().expect("tempdir");
let palace = tmp.path().join("palace_a");
std::fs::create_dir_all(&palace).unwrap();
let lock = palace.join("kg.redb.lock");
std::fs::write(&lock, b"").unwrap();
std::fs::write(palace.join("kg.redb"), b"").unwrap();
let found = find_lock_files(tmp.path());
assert!(
found.iter().any(|p| p == &lock),
"expected to find {} in {:?}",
lock.display(),
found
);
assert_eq!(
found.len(),
1,
"non-lock files must be ignored: {:?}",
found
);
}
#[tokio::test]
async fn check_daemon_health_fails_cleanly_with_no_listener() {
let _guard = super::super::env_test_lock().lock().await;
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join("trusty-memory")).expect("create data dir");
unsafe {
std::env::set_var("TRUSTY_DATA_DIR_OVERRIDE", tmp.path());
}
let result = check_daemon_health().await;
unsafe {
std::env::remove_var("TRUSTY_DATA_DIR_OVERRIDE");
}
drop(_guard);
assert!(
matches!(
result.status,
CheckStatus::Fail | CheckStatus::Pass | CheckStatus::Unknown
),
"unexpected status {:?}",
result.status,
);
if result.status == CheckStatus::Fail {
let detail = result.detail.as_deref().unwrap_or("");
assert!(
detail.contains("no daemon") || detail.contains("unreachable"),
"a Fail must say the daemon is not there: {detail:?}"
);
assert!(
detail.contains("service start"),
"a Fail must name the command that fixes it: {detail:?}"
);
}
}
use super::checks::interpret_health_body;
fn interpret(body: serde_json::Value) -> CheckResult {
interpret_health_body(
"HTTP daemon".to_string(),
"http://x/health",
200,
Some(&body),
)
}
#[test]
fn wedged_body_is_fail() {
let r = interpret(serde_json::json!({
"status": "ok",
"daemon_state": "ready",
"worker": {"in_flight": 6, "oldest_age_secs": 1800, "wedged": true},
}));
assert_eq!(r.status, CheckStatus::Fail);
let d = r.detail.as_deref().unwrap_or("");
assert!(d.contains("WEDGED"), "must name the wedge: {d}");
assert!(d.contains("1800"), "must carry the observed age: {d}");
}
#[test]
fn warming_body_is_warn() {
let r = interpret(serde_json::json!({
"status": "ok",
"daemon_state": "warming",
"worker": {"in_flight": 0, "wedged": false, "stall_tracking_ok": true},
}));
assert_eq!(r.status, CheckStatus::Warn);
assert!(r.detail.as_deref().unwrap_or("").contains("WARMING"));
}
#[test]
fn indeterminate_probe_renders_as_unknown_not_pass() {
let r = interpret_health_body("HTTP daemon".to_string(), "http://x/health", 200, None);
assert_eq!(r.status, CheckStatus::Unknown);
assert_ne!(r.status, CheckStatus::Pass, "unknown must never be healthy");
assert!(r.detail.as_deref().unwrap_or("").contains("UNKNOWN"));
}
#[test]
fn body_without_worker_block_is_unknown() {
let r = interpret(serde_json::json!({"status": "ok", "daemon_state": "ready"}));
assert_eq!(r.status, CheckStatus::Unknown);
}
#[test]
fn healthy_body_is_pass() {
let r = interpret(serde_json::json!({
"status": "ok",
"daemon_state": "ready",
"worker": {
"in_flight": 2,
"oldest_age_secs": 1,
"wedged": false,
"stall_tracking_ok": true,
},
}));
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn degraded_body_is_warn() {
let r = interpret(serde_json::json!({
"status": "degraded",
"detail": "store failed: disk full",
"daemon_state": "ready",
"worker": {"in_flight": 0, "wedged": false, "stall_tracking_ok": true},
}));
assert_eq!(r.status, CheckStatus::Warn);
assert!(r.detail.as_deref().unwrap_or("").contains("disk full"));
}
#[test]
fn kg_redb_size_verdict_matches_the_thresholds() {
use super::CheckStatus;
use checks::kg_redb_verdict;
let pass = kg_redb_verdict("kg.redb size".into(), "small", 10 * 1024 * 1024);
assert!(matches!(pass.status, CheckStatus::Pass), "{pass:?}");
let warn = kg_redb_verdict("kg.redb size".into(), "mid", 120 * 1024 * 1024);
assert!(matches!(warn.status, CheckStatus::Warn), "{warn:?}");
assert!(
warn.detail
.as_deref()
.unwrap_or("")
.contains("palace stats mid"),
"the warning must name the command to run: {warn:?}"
);
let fail = kg_redb_verdict("kg.redb size".into(), "huge", 600 * 1024 * 1024);
assert!(matches!(fail.status, CheckStatus::Fail), "{fail:?}");
assert!(
fail.detail
.as_deref()
.unwrap_or("")
.contains("palace compact huge"),
"the failure must name the command to run: {fail:?}"
);
assert!(
fail.detail
.as_deref()
.unwrap_or("")
.contains("disk, not data"),
"a large store still serves every read; say so: {fail:?}"
);
}
}