tftio-lib 0.1.0

Shared CLI, agent-mode, and prompt-handling library for tftio Rust tools
Documentation
//! Health check and diagnostics module.

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

/// 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> {
        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")
}

/// Run doctor command to check health and configuration.
///
/// Returns exit code: 0 if healthy, 1 if issues found.
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);
    }

    /// Unique, per-call temp HOME root that never collides across tests.
    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");
        // SAFETY: serialized by `env_lock`; the prior value is restored before
        // the test returns. Matches the sanctioned test-only env-mutation idiom
        // used elsewhere in this crate (REPO_INVARIANTS.md #5).
        unsafe {
            std::env::set_var("HOME", value);
        }
        prior
    }

    #[allow(unsafe_code)]
    fn restore_home(prior: Option<std::ffi::OsString>) {
        // SAFETY: serialized by `env_lock`; see `set_home`.
        unsafe {
            match prior {
                Some(value) => std::env::set_var("HOME", value),
                None => std::env::remove_var("HOME"),
            }
        }
    }

    /// Write a prompter config under `<home>/.config/prompter/config.toml`.
    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");
        // HOME exists but no prompter config or library is present.
        fs::create_dir_all(&home).unwrap();
        let prior = set_home(&home);

        let checks = PrompterDoctor.tool_checks();
        let report = build_doctor_report();

        restore_home(prior);

        // The first check fails: the config file is absent.
        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
        );
        // No bundle check is emitted when the config file is missing.
        assert!(
            !checks.iter().any(|c| c.name.contains("Config bundle")),
            "no bundle check expected without a config file: {checks:?}"
        );
        // With no bundle there are no library roots, so that check fails too.
        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");
        // Valid config, but the default library directory is never created.
        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);

        // The bundle still loads (the library root is not required at load time),
        // but the directory it points at is absent.
        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");
        // The config file exists but its contents are not valid 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);

        // The config-file presence check passes...
        assert!(
            checks
                .iter()
                .any(|c| c.passed && c.name.starts_with("Config file:")),
            "config file presence should pass: {checks:?}"
        );
        // ...but loading the bundle fails.
        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();
    }
}