use std::io::Write;
use std::path::Path;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::macos_perm::{
notification_state, pasteboard_access_state, PermissionState,
};
use crate::service::{self, ServiceStatus, SystemLaunchctl};
use crate::storage::{expand_tilde, Storage};
const VISION_FIXTURE: &[u8] = include_bytes!("../../tests/fixtures/blank-16x16.png");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckStatus {
Pass,
Warn,
Fail,
}
impl CheckStatus {
fn marker(self) -> &'static str {
match self {
CheckStatus::Pass => "PASS",
CheckStatus::Warn => "WARN",
CheckStatus::Fail => "FAIL",
}
}
}
#[derive(Debug, Clone)]
pub struct Check {
pub name: &'static str,
pub status: CheckStatus,
pub detail: String,
}
impl Check {
pub fn pass(name: &'static str, detail: impl Into<String>) -> Self {
Self { name, status: CheckStatus::Pass, detail: detail.into() }
}
pub fn warn(name: &'static str, detail: impl Into<String>) -> Self {
Self { name, status: CheckStatus::Warn, detail: detail.into() }
}
pub fn fail(name: &'static str, detail: impl Into<String>) -> Self {
Self { name, status: CheckStatus::Fail, detail: detail.into() }
}
}
pub fn run_all<W: Write>(cfg: &Config, cfg_path: &Path, out: &mut W) -> Result<()> {
let checks = collect_checks(cfg, cfg_path);
print_report(&checks, out)?;
if checks.iter().any(|c| c.status == CheckStatus::Fail) {
return Err(Error::Doctor("one or more doctor checks failed".into()));
}
Ok(())
}
pub fn collect_checks(cfg: &Config, cfg_path: &Path) -> Vec<Check> {
vec![
check_config_file(cfg_path),
check_log_dir(cfg),
check_sqlite(cfg),
check_pasteboard(),
check_notifications(),
check_launchagent(),
check_mcp_registration(),
check_vision_smoke(cfg),
]
}
fn print_report<W: Write>(checks: &[Check], out: &mut W) -> Result<()> {
let max = checks.iter().map(|c| c.name.len()).max().unwrap_or(0);
for c in checks {
writeln!(
out,
"{:<6} {:<width$} — {}",
c.status.marker(),
c.name,
c.detail,
width = max,
)?;
}
let failed = checks.iter().filter(|c| c.status == CheckStatus::Fail).count();
let warned = checks.iter().filter(|c| c.status == CheckStatus::Warn).count();
let passed = checks.iter().filter(|c| c.status == CheckStatus::Pass).count();
writeln!(out, "\n{passed} pass, {warned} warn, {failed} fail")?;
Ok(())
}
fn check_config_file(cfg_path: &Path) -> Check {
let name = "config file";
if !cfg_path.exists() {
return Check::fail(name, format!("missing: {} (run `tl config reset`)", cfg_path.display()));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
match std::fs::metadata(cfg_path) {
Ok(meta) => {
let mode = meta.permissions().mode() & 0o777;
if mode == 0o600 {
Check::pass(name, format!("{} (mode 600)", cfg_path.display()))
} else {
Check::warn(
name,
format!(
"{} has permissions {mode:o}; recommend chmod 600 to keep API keys private",
cfg_path.display(),
),
)
}
}
Err(e) => Check::warn(name, format!("could not stat {}: {e}", cfg_path.display())),
}
}
#[cfg(not(unix))]
Check::pass(name, format!("{} (perm check skipped — non-Unix)", cfg_path.display()))
}
fn check_log_dir(cfg: &Config) -> Check {
let name = "log dir writable";
let dir = expand_tilde(&cfg.storage.log_dir);
if let Err(e) = std::fs::create_dir_all(&dir) {
return Check::fail(name, format!("{}: {e}", dir.display()));
}
let probe = dir.join(".tl-doctor-write-probe");
match std::fs::write(&probe, b"ok") {
Ok(()) => {
let _ = std::fs::remove_file(&probe);
Check::pass(name, dir.display().to_string())
}
Err(e) => Check::fail(name, format!("{}: {e}", dir.display())),
}
}
fn check_sqlite(cfg: &Config) -> Check {
let name = "sqlite + FTS5";
let path = expand_tilde(&cfg.storage.sqlite_path);
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
return Check::fail(name, format!("could not create {}: {e}", parent.display()));
}
}
match Storage::open(&path, cfg.storage.ring_buffer_size) {
Ok(s) => {
match s.search("__tl_doctor_probe__", 1, None) {
Ok(_) => Check::pass(name, format!("{} (FTS5 ok)", path.display())),
Err(e) => Check::fail(name, format!("FTS5 query failed: {e}")),
}
}
Err(e) => Check::fail(name, format!("{}: {e}", path.display())),
}
}
fn check_pasteboard() -> Check {
let name = "clipboard access";
match pasteboard_access_state() {
PermissionState::Granted => Check::pass(name, "NSPasteboard.changeCount succeeded"),
PermissionState::Denied => Check::fail(
name,
"NSPasteboard read failed — grant Pasteboard access in System Settings → Privacy & Security",
),
PermissionState::Unknown => {
Check::warn(name, "permission state could not be probed (non-macOS or sandbox)")
}
}
}
fn check_notifications() -> Check {
let name = "notifications";
match notification_state() {
PermissionState::Granted => Check::pass(name, "UNUserNotificationCenter authorised"),
PermissionState::Denied => Check::fail(
name,
"notifications denied — enable in System Settings → Notifications",
),
PermissionState::Unknown => Check::warn(
name,
"permission unknown (UN center requires bundled app); first notification will prompt the user",
),
}
}
fn check_launchagent() -> Check {
let name = "launchagent";
match service::status(&SystemLaunchctl) {
Ok(ServiceStatus::NotInstalled) => {
Check::warn(name, "not installed (run `tl install` to register the LaunchAgent)")
}
Ok(ServiceStatus::Installed { loaded, pid, last_exit_code }) => {
let detail = format!(
"loaded={loaded} pid={pid:?} last_exit={last_exit_code:?}"
);
if loaded {
Check::pass(name, detail)
} else {
Check::warn(name, format!("plist on disk but not loaded — `tl start` to bootstrap. {detail}"))
}
}
Err(e) => Check::fail(name, format!("status query failed: {e}")),
}
}
fn check_mcp_registration() -> Check {
let name = "mcp registration";
let which = std::process::Command::new("which").arg("claude").output();
let claude_present = matches!(which, Ok(o) if o.status.success() && !o.stdout.is_empty());
if !claude_present {
return Check::warn(
name,
"`claude` CLI not on PATH — install Claude Code and run `claude mcp add textlog -- tl mcp`",
);
}
let out = std::process::Command::new("claude").arg("mcp").arg("list").output();
match out {
Ok(o) if o.status.success() => {
let stdout = String::from_utf8_lossy(&o.stdout);
if stdout.lines().any(|l| l.contains("textlog")) {
Check::pass(name, "found in `claude mcp list`")
} else {
Check::warn(
name,
"not registered — run `claude mcp add textlog -- tl mcp`",
)
}
}
Ok(o) => Check::warn(
name,
format!(
"`claude mcp list` returned {}: {}",
o.status,
String::from_utf8_lossy(&o.stderr).trim()
),
),
Err(e) => Check::warn(name, format!("could not run claude: {e}")),
}
}
fn check_vision_smoke(cfg: &Config) -> Check {
let name = "apple vision";
match crate::ocr::ocr_image(VISION_FIXTURE, &cfg.ocr) {
Ok(r) => Check::pass(
name,
format!(
"ocr_image succeeded ({} blocks, mean confidence {:.2})",
r.block_count, r.confidence,
),
),
Err(e) => Check::fail(name, format!("OCR smoke failed: {e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn cfg_with_tmp(tmp: &TempDir) -> Config {
let mut cfg = Config::default();
cfg.storage.sqlite_path = tmp.path().join("index.db").to_string_lossy().into_owned();
cfg.storage.log_dir = tmp.path().join("logs").to_string_lossy().into_owned();
cfg
}
#[test]
fn check_config_file_missing_is_fail() {
let tmp = TempDir::new().unwrap();
let p = tmp.path().join("does-not-exist.toml");
let c = check_config_file(&p);
assert_eq!(c.status, CheckStatus::Fail);
assert!(c.detail.contains("missing"));
}
#[cfg(unix)]
#[test]
fn check_config_file_warns_on_loose_perms() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let p = tmp.path().join("config.toml");
std::fs::write(&p, "x").unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
let c = check_config_file(&p);
assert_eq!(c.status, CheckStatus::Warn);
assert!(c.detail.contains("644"));
}
#[cfg(unix)]
#[test]
fn check_config_file_passes_on_0600() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let p = tmp.path().join("config.toml");
std::fs::write(&p, "x").unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap();
let c = check_config_file(&p);
assert_eq!(c.status, CheckStatus::Pass);
}
#[test]
fn check_log_dir_creates_and_writes_probe() {
let tmp = TempDir::new().unwrap();
let cfg = cfg_with_tmp(&tmp);
let c = check_log_dir(&cfg);
assert_eq!(c.status, CheckStatus::Pass);
assert!(tmp.path().join("logs").exists());
assert!(!tmp.path().join("logs/.tl-doctor-write-probe").exists());
}
#[test]
fn check_log_dir_fails_when_parent_unwritable() {
let mut cfg = Config::default();
cfg.storage.log_dir = "/private/textlog-doctor-bad".into();
let c = check_log_dir(&cfg);
assert_eq!(c.status, CheckStatus::Fail, "expected fail; got: {c:?}");
}
#[test]
fn check_sqlite_passes_with_fresh_db() {
let tmp = TempDir::new().unwrap();
let cfg = cfg_with_tmp(&tmp);
let c = check_sqlite(&cfg);
assert_eq!(c.status, CheckStatus::Pass);
assert!(c.detail.contains("FTS5"));
}
#[test]
fn run_all_returns_err_when_any_check_fails() {
let tmp = TempDir::new().unwrap();
let mut cfg = cfg_with_tmp(&tmp);
cfg.storage.log_dir = "/private/cannot-write".into();
let bad_cfg_path = tmp.path().join("nope.toml");
let mut buf = Vec::new();
let r = run_all(&cfg, &bad_cfg_path, &mut buf);
assert!(r.is_err(), "expected Err on failed checks");
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("FAIL"));
}
#[test]
fn print_report_lists_each_check_once() {
let mut buf = Vec::new();
let checks = vec![
Check::pass("a", "ok"),
Check::warn("b", "meh"),
Check::fail("c", "broken"),
];
print_report(&checks, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("PASS"));
assert!(s.contains("WARN"));
assert!(s.contains("FAIL"));
assert!(s.contains("1 pass, 1 warn, 1 fail"));
}
}