use super::{Config, PrompterError, load_config_bundle};
use crate::{DoctorCheck, DoctorChecks, DoctorReport, JsonOutput, RepoInfo};
use serde_json::json;
use std::path::PathBuf;
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> {
let state = collect_doctor_state();
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
}
}
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 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");
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,
default_library_path,
config_file_exists,
bundle_result,
library_roots,
errors,
warnings,
}
}
fn build_doctor_report() -> DoctorReport {
let state = collect_doctor_state();
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::for_tool(&PrompterDoctor)
.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);
}
for warning in state.warnings {
report = report.with_warning(warning);
}
report
.with_info(format!("Current version: v{}", env!("CARGO_PKG_VERSION")))
.with_info("Check https://github.com/tftio/prompter/releases for updates")
}
pub fn run_doctor(output: JsonOutput) -> i32 {
let report = build_doctor_report();
report.emit_output(output)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use std::sync::atomic::{AtomicU32, Ordering};
#[test]
fn test_run_doctor_returns_valid_exit_code() {
let exit_code = run_doctor(JsonOutput::Text);
assert!(exit_code == 0 || exit_code == 1);
}
#[test]
fn test_run_doctor_json_returns_valid_exit_code() {
let exit_code = run_doctor(JsonOutput::Json);
assert!(exit_code == 0 || exit_code == 1);
}
fn unique_home(label: &str) -> PathBuf {
static COUNTER: AtomicU32 = AtomicU32::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"tftio-lib-doctor-{label}-{}-{n}",
std::process::id()
))
}
#[allow(unsafe_code)]
fn set_home(value: &Path) -> Option<std::ffi::OsString> {
let prior = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", value);
}
prior
}
#[allow(unsafe_code)]
fn restore_home(prior: Option<std::ffi::OsString>) {
unsafe {
match prior {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
}
}
fn write_config(home: &Path, contents: &str) {
let cfg_dir = home.join(".config/prompter");
fs::create_dir_all(&cfg_dir).unwrap();
fs::write(cfg_dir.join("config.toml"), contents).unwrap();
}
#[test]
fn tool_checks_report_missing_config_and_library() {
let _guard = crate::test_support::env_lock();
let home = unique_home("missing-config");
fs::create_dir_all(&home).unwrap();
let prior = set_home(&home);
let checks = PrompterDoctor.tool_checks();
let report = build_doctor_report();
restore_home(prior);
let config_check = &checks[0];
assert!(!config_check.passed, "config check should fail: {checks:?}");
assert_eq!(config_check.name, "Config file");
assert!(
config_check
.message
.as_deref()
.unwrap_or_default()
.contains("Config file not found"),
"unexpected config message: {:?}",
config_check.message
);
assert!(
!checks.iter().any(|c| c.name.contains("Config bundle")),
"no bundle check expected without a config file: {checks:?}"
);
assert!(
checks
.iter()
.any(|c| !c.passed && c.name == "Library directory"),
"expected a failing library directory check: {checks:?}"
);
assert_ne!(report.exit_code(), 0, "unhealthy report must exit nonzero");
fs::remove_dir_all(&home).ok();
}
#[test]
fn tool_checks_pass_for_valid_config_and_existing_library() {
let _guard = crate::test_support::env_lock();
let home = unique_home("valid");
fs::create_dir_all(home.join(".local/prompter/library")).unwrap();
write_config(&home, "[root]\ndepends_on = []\n");
let prior = set_home(&home);
let checks = PrompterDoctor.tool_checks();
let report = build_doctor_report();
restore_home(prior);
assert!(
checks.iter().all(|c| c.passed),
"all checks should pass: {checks:?}"
);
assert!(
checks
.iter()
.any(|c| c.passed && c.name.starts_with("Config file:")),
"expected a passing config-file check: {checks:?}"
);
assert!(
checks
.iter()
.any(|c| c.passed && c.name == "Config bundle loads (TOML + imports)"),
"expected a passing bundle check: {checks:?}"
);
assert!(
checks
.iter()
.any(|c| c.passed && c.name.starts_with("Library directory:")),
"expected a passing library-directory check: {checks:?}"
);
assert_eq!(report.exit_code(), 0, "healthy report must exit zero");
fs::remove_dir_all(&home).ok();
}
#[test]
fn tool_checks_fail_when_library_root_is_missing() {
let _guard = crate::test_support::env_lock();
let home = unique_home("missing-lib");
write_config(&home, "[root]\ndepends_on = []\n");
let prior = set_home(&home);
let checks = PrompterDoctor.tool_checks();
let report = build_doctor_report();
restore_home(prior);
assert!(
checks
.iter()
.any(|c| c.passed && c.name == "Config bundle loads (TOML + imports)"),
"bundle should still load: {checks:?}"
);
let lib_fail = checks
.iter()
.find(|c| !c.passed && c.name == "Library directory")
.expect("expected a failing library-directory check");
assert!(
lib_fail
.message
.as_deref()
.unwrap_or_default()
.contains("Library directory not found"),
"unexpected library message: {:?}",
lib_fail.message
);
assert_ne!(report.exit_code(), 0, "missing library must exit nonzero");
fs::remove_dir_all(&home).ok();
}
#[test]
fn tool_checks_report_bundle_load_failure() {
let _guard = crate::test_support::env_lock();
let home = unique_home("bad-toml");
write_config(&home, "not valid toml {{{");
let prior = set_home(&home);
let checks = PrompterDoctor.tool_checks();
let report = build_doctor_report();
restore_home(prior);
assert!(
checks
.iter()
.any(|c| c.passed && c.name.starts_with("Config file:")),
"config file presence should pass: {checks:?}"
);
let bundle_fail = checks
.iter()
.find(|c| !c.passed && c.name == "Config bundle loads (TOML + imports)")
.expect("expected a failing bundle-load check");
assert!(
bundle_fail
.message
.as_deref()
.unwrap_or_default()
.contains("Bundle load failed"),
"unexpected bundle message: {:?}",
bundle_fail.message
);
assert_ne!(report.exit_code(), 0, "bundle failure must exit nonzero");
fs::remove_dir_all(&home).ok();
}
}