mod audit;
mod checks;
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_stale_palace_locks};
use anyhow::Result;
use colored::Colorize;
use crate::project_root::PERSONAL_PALACE;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum CheckStatus {
Pass,
Warn,
Fail,
}
#[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 print(&self) {
let glyph = match self.status {
CheckStatus::Pass => "✅".to_string(),
CheckStatus::Warn => "⚠️ ".to_string(),
CheckStatus::Fail => "❌".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(),
};
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());
for r in &results {
r.print();
}
let failed = results
.iter()
.filter(|r| r.status == CheckStatus::Fail)
.count();
let passed = results
.iter()
.filter(|r| r.status == CheckStatus::Pass)
.count();
let warned = results
.iter()
.filter(|r| r.status == CheckStatus::Warn)
.count();
println!();
if failed == 0 {
println!(
"{} {} passed, {} warnings, {} failed.",
"✓".green(),
passed,
warned,
failed
);
Ok(())
} else {
eprintln!(
"{} {} passed, {} warnings, {} failed.",
"✗".red(),
passed,
warned,
failed
);
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, PIN_SCHEMA_VERSION};
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 {
schema_version: PIN_SCHEMA_VERSION,
palace: "my-old-name".to_string(),
note: None,
};
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, PIN_SCHEMA_VERSION};
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 {
schema_version: PIN_SCHEMA_VERSION,
palace: "alpha".to_string(),
note: None,
};
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_stale_addr_and_no_listener() {
let _guard = super::super::env_test_lock().lock().await;
let tmp = tempfile::tempdir().expect("tempdir");
let data_dir = tmp.path().join("trusty-memory");
std::fs::create_dir_all(&data_dir).expect("create data dir");
std::fs::write(data_dir.join("http_addr"), "127.0.0.1:19876\n").expect("write stale addr");
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!(
result.status == CheckStatus::Fail || result.status == CheckStatus::Pass,
"unexpected status {:?}; expected Fail (stale addr, no listener) \
or Pass (fallback found live daemon)",
result.status,
);
if result.status == CheckStatus::Fail {
let detail = result.detail.as_deref().unwrap_or("");
assert!(
detail.contains("unreachable") || detail.contains("no daemon"),
"Fail detail must mention unreachable/no daemon: {detail:?}"
);
}
}
#[tokio::test]
async fn check_daemon_health_fails_when_no_addr_file_and_no_listener() {
let _guard = super::super::env_test_lock().lock().await;
let tmp = tempfile::tempdir().expect("tempdir");
let data_dir = tmp.path().join("trusty-memory");
std::fs::create_dir_all(&data_dir).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!(
result.status == CheckStatus::Fail || result.status == CheckStatus::Pass,
"unexpected status: {:?}",
result.status
);
if result.status == CheckStatus::Fail {
let detail = result.detail.as_deref().unwrap_or("");
assert!(
detail.contains("no daemon") || detail.contains("no addr"),
"detail must hint at the absence: {detail:?}"
);
}
}
}