tftio-prompter 4.0.2

A CLI tool for composing reusable prompt snippets from a library using TOML profiles
Documentation
//! Health check and diagnostics module.

use crate::{Config, PrompterError, load_config_bundle};
use serde_json::json;
use std::path::{Path, PathBuf};
use tftio_lib::{DoctorCheck, DoctorChecks, DoctorReport, JsonOutput, RepoInfo};

/// Doctor checks provider for the prompter tool.
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>,
}

/// Build the user-facing check list from a collected doctor state.
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)
}

/// Collect doctor state for explicit config and default-library paths.
///
/// Kept separate from [`collect_doctor_state`] so unit tests can drive every
/// branch (missing config, load failure, present/absent library roots) against
/// temporary fixtures without touching the real home directory.
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,
    }
}

/// Build the structured doctor report from a collected state.
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());
    // Build the report from THIS state's checks, not from
    // `DoctorReport::for_tool(&PrompterDoctor)` — the latter runs
    // `PrompterDoctor::tool_checks()`, which re-reads the real `$HOME` and made
    // the exit code depend on the ambient environment (green on a configured
    // machine, red in a clean CI runner). `run_doctor` passes
    // `collect_doctor_state()`, so production output is unchanged.
    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")
}

/// Run doctor command to check health and configuration.
///
/// Returns exit code: 0 if healthy, 1 if issues found.
#[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);
        // config-missing fail + library-missing fail, no bundle check.
        assert_eq!(checks.len(), 2);
        assert!(checks.iter().all(|check| !check.passed));
        // Exit code is non-zero when there are errors.
        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() {
        // A config that loads but whose (explicit) library root does not exist
        // exercises the library-root-missing branch.
        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() {
        // A healthy state carrying an advisory warning but no errors still exits
        // 0: warnings are reported but only failing checks or errors change the
        // exit code.
        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() {
        // Smoke test of the public entry point (reads the real home, read-only);
        // the deterministic branch coverage is provided by the seam tests above.
        let exit = run_doctor(JsonOutput::Text);
        assert!(
            exit == 0 || exit == 1,
            "unexpected doctor exit code: {exit}"
        );
    }
}