use anyhow::Result;
use colored::Colorize;
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::project_root::{project_slug_at, PERSONAL_PALACE};
#[derive(Debug, Clone, PartialEq, Eq)]
enum CheckStatus {
Pass,
Warn,
Fail,
}
#[derive(Debug, Clone)]
struct CheckResult {
status: CheckStatus,
label: String,
detail: Option<String>,
}
impl CheckResult {
fn pass(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
status: CheckStatus::Pass,
label: label.into(),
detail: Some(detail.into()),
}
}
fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
status: CheckStatus::Warn,
label: label.into(),
detail: Some(detail.into()),
}
}
fn fail(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
status: CheckStatus::Fail,
label: label.into(),
detail: Some(detail.into()),
}
}
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}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PalaceAuditStatus {
Ok,
Orphaned,
Empty,
}
#[derive(Debug, Clone)]
pub struct PalaceAuditEntry {
pub id: String,
pub data_dir: PathBuf,
pub status: PalaceAuditStatus,
}
pub fn audit_palaces(registry_dir: &Path) -> Vec<PalaceAuditEntry> {
let Ok(entries) = std::fs::read_dir(registry_dir) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let id = match path.file_name().and_then(|n| n.to_str()) {
Some(s) => s.to_string(),
None => continue,
};
if !path.join("palace.json").exists() {
out.push(PalaceAuditEntry {
id,
data_dir: path,
status: PalaceAuditStatus::Empty,
});
continue;
}
if id == PERSONAL_PALACE {
out.push(PalaceAuditEntry {
id,
data_dir: path,
status: PalaceAuditStatus::Ok,
});
continue;
}
let matches_ancestor = project_slug_at(registry_dir)
.map(|slug| slug == id)
.unwrap_or(false);
if matches_ancestor {
out.push(PalaceAuditEntry {
id,
data_dir: path,
status: PalaceAuditStatus::Ok,
});
continue;
}
let found_on_disk = dirs::home_dir()
.map(|home| {
let candidates = [
home.join("Projects").join(&id),
home.join("Developer").join(&id),
home.join("Code").join(&id),
home.join(&id),
];
candidates.iter().any(|c| c.is_dir())
})
.unwrap_or(false);
let status = if found_on_disk {
PalaceAuditStatus::Ok
} else {
PalaceAuditStatus::Orphaned
};
out.push(PalaceAuditEntry {
id,
data_dir: path,
status,
});
}
out.sort_by(|a, b| a.id.cmp(&b.id));
out
}
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);
}
}
fn check_fastembed_cache() -> CheckResult {
let cache = trusty_common::embedder::resolve_fastembed_cache_dir();
let label = "fastembed cache".to_string();
if !cache.exists() {
return CheckResult::fail(
label,
format!(
"missing: {} — run `trusty-memory setup` to pre-warm",
cache.display()
),
);
}
if !cache.is_dir() {
return CheckResult::fail(label, format!("not a directory: {}", cache.display()));
}
match fastembed_cache_has_models(&cache) {
Ok(true) => CheckResult::pass(label, format!("ready at {}", cache.display())),
Ok(false) => CheckResult::warn(
label,
format!(
"{} exists but is empty — daemon will download on first request",
cache.display()
),
),
Err(e) => CheckResult::fail(label, format!("cannot read {}: {e}", cache.display())),
}
}
fn fastembed_cache_has_models(path: &Path) -> std::io::Result<bool> {
let mut iter = std::fs::read_dir(path)?;
Ok(iter.next().is_some())
}
#[cfg(target_os = "macos")]
fn check_launchd_plist() -> CheckResult {
let label = "launchd plist".to_string();
let Some(home) = dirs::home_dir() else {
return CheckResult::fail(label, "could not resolve $HOME".to_string());
};
let plist = home
.join("Library")
.join("LaunchAgents")
.join(format!("{}.plist", crate::commands::service::LAUNCHD_LABEL));
if !plist.exists() {
return CheckResult::fail(
label,
format!(
"missing: {} — run `trusty-memory service install`",
plist.display()
),
);
}
match plist_contains_fastembed_cache_path(&plist) {
Ok(true) => CheckResult::pass(label, format!("{} ok", plist.display())),
Ok(false) => CheckResult::fail(
label,
format!(
"{} is missing FASTEMBED_CACHE_PATH — reinstall via `trusty-memory service install`",
plist.display()
),
),
Err(e) => CheckResult::fail(label, format!("cannot read {}: {e}", plist.display())),
}
}
#[cfg(target_os = "macos")]
fn plist_contains_fastembed_cache_path(path: &Path) -> std::io::Result<bool> {
let contents = std::fs::read_to_string(path)?;
Ok(contents.contains("FASTEMBED_CACHE_PATH"))
}
async fn check_daemon_health() -> CheckResult {
let label = "HTTP daemon".to_string();
let addr = match trusty_common::read_daemon_addr("trusty-memory") {
Ok(Some(a)) => a,
Ok(None) => {
return CheckResult::fail(
label,
"no daemon address recorded — start with `trusty-memory service start`".to_string(),
);
}
Err(e) => {
return CheckResult::fail(label, format!("could not read daemon address: {e}"));
}
};
let base = if addr.starts_with("http://") || addr.starts_with("https://") {
addr.clone()
} else {
format!("http://{addr}")
};
let url = format!("{base}/health");
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
{
Ok(c) => c,
Err(e) => return CheckResult::fail(label, format!("could not build HTTP client: {e}")),
};
match client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => {
CheckResult::pass(label, format!("{} → {}", url, resp.status()))
}
Ok(resp) => CheckResult::fail(label, format!("{} → {}", url, resp.status())),
Err(e) => CheckResult::fail(label, format!("{url} unreachable: {e}")),
}
}
fn check_stale_palace_locks() -> CheckResult {
let label = "palace locks".to_string();
let data_dir = match trusty_common::resolve_data_dir("trusty-memory") {
Ok(d) => d,
Err(e) => return CheckResult::fail(label, format!("could not resolve data dir: {e}")),
};
let root = crate::resolve_palace_registry_dir(data_dir);
let locks = find_lock_files(&root);
if locks.is_empty() {
CheckResult::pass(label, format!("{} clean", root.display()))
} else {
let preview = locks
.iter()
.take(3)
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ");
let suffix = if locks.len() > 3 {
format!(" (+{} more)", locks.len() - 3)
} else {
String::new()
};
CheckResult::warn(
label,
format!(
"{} lock file(s) found: {preview}{suffix} — if the daemon is stopped, these can be removed",
locks.len()
),
)
}
}
fn find_lock_files(root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(root) else {
return out;
};
for entry in entries.flatten() {
let path = entry.path();
if is_lock_file(&path) {
out.push(path.clone());
}
if path.is_dir() {
if let Ok(sub) = std::fs::read_dir(&path) {
for child in sub.flatten() {
let cpath = child.path();
if is_lock_file(&cpath) {
out.push(cpath);
}
}
}
}
}
out
}
fn is_lock_file(path: &Path) -> bool {
path.extension().and_then(|s| s.to_str()) == Some("lock")
}
#[cfg(test)]
mod tests {
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 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
);
}
}