use crate::{Config, PrompterError, load_config_bundle};
use serde_json::json;
use std::path::{Path, PathBuf};
use tftio_lib::{DoctorCheck, DoctorChecks, DoctorReport, JsonOutput, RepoInfo};
pub struct PrompterDoctor;
impl DoctorChecks for PrompterDoctor {
fn repo_info() -> RepoInfo {
RepoInfo::new("tftio-stuff", "tools")
}
fn current_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
fn tool_checks(&self) -> Vec<DoctorCheck> {
checks_for_state(&collect_doctor_state())
}
}
struct DoctorState {
config_path: PathBuf,
default_library_path: PathBuf,
config_file_exists: bool,
bundle_result: Option<Result<Config, PrompterError>>,
library_roots: Vec<PathBuf>,
errors: Vec<String>,
warnings: Vec<String>,
}
fn checks_for_state(state: &DoctorState) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
if state.config_file_exists {
checks.push(DoctorCheck::pass(format!(
"Config file: {}",
state.config_path.display()
)));
} else {
checks.push(DoctorCheck::fail(
"Config file",
format!("Config file not found: {}", state.config_path.display()),
));
}
match &state.bundle_result {
Some(Ok(_)) => {
checks.push(DoctorCheck::pass("Config bundle loads (TOML + imports)"));
}
Some(Err(e)) => {
checks.push(DoctorCheck::fail(
"Config bundle loads (TOML + imports)",
format!("Bundle load failed: {e}"),
));
}
None => {}
}
if state.library_roots.is_empty() {
checks.push(DoctorCheck::fail(
"Library directory",
format!(
"Library directory not found: {}",
state.default_library_path.display()
),
));
} else {
for root in &state.library_roots {
if root.exists() {
checks.push(DoctorCheck::pass(format!(
"Library directory: {}",
root.display()
)));
} else {
checks.push(DoctorCheck::fail(
"Library directory",
format!("Library directory not found: {}", root.display()),
));
}
}
}
checks
}
fn collect_doctor_state() -> DoctorState {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("~"));
let config_path = home.join(".config/prompter/config.toml");
let default_library_path = home.join(".local/prompter/library");
collect_doctor_state_at(&config_path, &default_library_path)
}
fn collect_doctor_state_at(config_path: &Path, default_library_path: &Path) -> DoctorState {
let config_file_exists = config_path.exists();
let mut errors = Vec::new();
let warnings = Vec::new();
let (bundle_result, library_roots) = if config_file_exists {
match load_config_bundle(config_path, Some(default_library_path)) {
Ok(cfg) => {
let roots = cfg.library_roots();
for root in &roots {
if !root.exists() {
errors.push(format!("Library directory not found: {}", root.display()));
}
}
(Some(Ok(cfg)), roots)
}
Err(e) => {
errors.push(format!("Bundle load failed: {e}"));
(Some(Err(e)), Vec::new())
}
}
} else {
errors.push(format!("Config file not found: {}", config_path.display()));
(None, Vec::new())
};
DoctorState {
config_path: config_path.to_path_buf(),
default_library_path: default_library_path.to_path_buf(),
config_file_exists,
bundle_result,
library_roots,
errors,
warnings,
}
}
fn report_for_state(state: &DoctorState) -> DoctorReport {
let bundle_loads = matches!(state.bundle_result, Some(Ok(_)));
let library_directory_exists =
!state.library_roots.is_empty() && state.library_roots.iter().all(|p| p.exists());
let mut report = DoctorReport::new(format!(
"🏥 {} health check",
<PrompterDoctor as DoctorChecks>::repo_info().name
))
.with_checks(checks_for_state(state))
.with_version(<PrompterDoctor as DoctorChecks>::current_version())
.with_detail("config_file_exists", json!(state.config_file_exists))
.with_detail("bundle_loads", json!(bundle_loads))
.with_detail("library_directory_exists", json!(library_directory_exists))
.with_detail(
"library_roots",
json!(
state
.library_roots
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
),
);
for error in &state.errors {
report = report.with_error(error.clone());
}
for warning in &state.warnings {
report = report.with_warning(warning.clone());
}
report
.with_info(format!("Current version: v{}", env!("CARGO_PKG_VERSION")))
.with_info("Check https://github.com/tftio/prompter/releases for updates")
}
#[must_use]
pub fn run_doctor(output: JsonOutput) -> i32 {
report_for_state(&collect_doctor_state()).emit_output(output)
}
#[cfg(test)]
mod tests {
use super::{
DoctorState, checks_for_state, collect_doctor_state_at, report_for_state, run_doctor,
};
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use tftio_lib::JsonOutput;
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn mk_tmp(prefix: &str) -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("{prefix}_{}_{n}", std::process::id()))
}
#[test]
fn state_reports_missing_config() {
let dir = mk_tmp("prompter_doctor_missing");
let state = collect_doctor_state_at(&dir.join("config.toml"), &dir.join("library"));
assert!(!state.config_file_exists);
assert!(state.bundle_result.is_none());
assert!(state.library_roots.is_empty());
let checks = checks_for_state(&state);
assert_eq!(checks.len(), 2);
assert!(checks.iter().all(|check| !check.passed));
assert_eq!(report_for_state(&state).emit_output(JsonOutput::Text), 1);
}
#[test]
fn state_reports_loaded_bundle() {
let dir = mk_tmp("prompter_doctor_ok");
fs::create_dir_all(dir.join("library/a")).unwrap();
fs::write(dir.join("library/a/x.md"), b"AX\n").unwrap();
fs::write(
dir.join("config.toml"),
"[root]\ndepends_on = [\"a/x.md\"]\n",
)
.unwrap();
let state = collect_doctor_state_at(&dir.join("config.toml"), &dir.join("library"));
assert!(state.config_file_exists);
assert!(matches!(state.bundle_result, Some(Ok(_))));
assert!(!state.library_roots.is_empty());
let checks = checks_for_state(&state);
assert!(
checks.iter().all(|check| check.passed),
"all checks should pass for a healthy config"
);
assert_eq!(report_for_state(&state).emit_output(JsonOutput::Json), 0);
}
#[test]
fn state_reports_bundle_error() {
let dir = mk_tmp("prompter_doctor_badtoml");
fs::create_dir_all(dir.join("library")).unwrap();
fs::write(dir.join("config.toml"), "this is not valid toml {{{").unwrap();
let state = collect_doctor_state_at(&dir.join("config.toml"), &dir.join("library"));
assert!(state.config_file_exists);
assert!(matches!(state.bundle_result, Some(Err(_))));
let checks = checks_for_state(&state);
assert!(
checks
.iter()
.any(|check| !check.passed && check.name.contains("bundle")),
"a bundle-load failure should surface a failing check"
);
assert_eq!(report_for_state(&state).emit_output(JsonOutput::Text), 1);
}
#[test]
fn state_reports_missing_library_root() {
let dir = mk_tmp("prompter_doctor_nolib");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("config.toml"),
"library = \"absent\"\n\n[root]\ndepends_on = []\n",
)
.unwrap();
let state = collect_doctor_state_at(&dir.join("config.toml"), &dir.join("library"));
assert!(matches!(state.bundle_result, Some(Ok(_))));
assert!(!state.library_roots.is_empty());
assert!(state.library_roots.iter().any(|root| !root.exists()));
let checks = checks_for_state(&state);
assert!(
checks
.iter()
.any(|check| !check.passed && check.name.contains("Library")),
"a missing library root should surface a failing check"
);
}
#[test]
fn report_threads_warnings_without_failing() {
let dir = mk_tmp("prompter_doctor_warn");
fs::create_dir_all(&dir).unwrap();
let state = DoctorState {
config_path: dir.join("config.toml"),
default_library_path: dir.clone(),
config_file_exists: true,
bundle_result: None,
library_roots: vec![dir.clone()],
errors: Vec::new(),
warnings: vec!["advisory note".to_string()],
};
assert_eq!(report_for_state(&state).emit_output(JsonOutput::Text), 0);
}
#[test]
fn run_doctor_returns_valid_exit_code() {
let exit = run_doctor(JsonOutput::Text);
assert!(
exit == 0 || exit == 1,
"unexpected doctor exit code: {exit}"
);
}
}