Skip to main content

prompter/
doctor.rs

1//! Health check and diagnostics module.
2
3use crate::{Config, PrompterError, load_config_bundle};
4use serde_json::json;
5use std::path::{Path, PathBuf};
6use tftio_lib::{DoctorCheck, DoctorChecks, DoctorReport, JsonOutput, RepoInfo};
7
8/// Doctor checks provider for the prompter tool.
9pub struct PrompterDoctor;
10
11impl DoctorChecks for PrompterDoctor {
12    fn repo_info() -> RepoInfo {
13        RepoInfo::new("tftio-stuff", "tools")
14    }
15
16    fn current_version() -> &'static str {
17        env!("CARGO_PKG_VERSION")
18    }
19
20    fn tool_checks(&self) -> Vec<DoctorCheck> {
21        checks_for_state(&collect_doctor_state())
22    }
23}
24
25struct DoctorState {
26    config_path: PathBuf,
27    default_library_path: PathBuf,
28    config_file_exists: bool,
29    bundle_result: Option<Result<Config, PrompterError>>,
30    library_roots: Vec<PathBuf>,
31    errors: Vec<String>,
32    warnings: Vec<String>,
33}
34
35/// Build the user-facing check list from a collected doctor state.
36fn checks_for_state(state: &DoctorState) -> Vec<DoctorCheck> {
37    let mut checks = Vec::new();
38
39    if state.config_file_exists {
40        checks.push(DoctorCheck::pass(format!(
41            "Config file: {}",
42            state.config_path.display()
43        )));
44    } else {
45        checks.push(DoctorCheck::fail(
46            "Config file",
47            format!("Config file not found: {}", state.config_path.display()),
48        ));
49    }
50
51    match &state.bundle_result {
52        Some(Ok(_)) => {
53            checks.push(DoctorCheck::pass("Config bundle loads (TOML + imports)"));
54        }
55        Some(Err(e)) => {
56            checks.push(DoctorCheck::fail(
57                "Config bundle loads (TOML + imports)",
58                format!("Bundle load failed: {e}"),
59            ));
60        }
61        None => {}
62    }
63
64    if state.library_roots.is_empty() {
65        checks.push(DoctorCheck::fail(
66            "Library directory",
67            format!(
68                "Library directory not found: {}",
69                state.default_library_path.display()
70            ),
71        ));
72    } else {
73        for root in &state.library_roots {
74            if root.exists() {
75                checks.push(DoctorCheck::pass(format!(
76                    "Library directory: {}",
77                    root.display()
78                )));
79            } else {
80                checks.push(DoctorCheck::fail(
81                    "Library directory",
82                    format!("Library directory not found: {}", root.display()),
83                ));
84            }
85        }
86    }
87
88    checks
89}
90
91fn collect_doctor_state() -> DoctorState {
92    let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("~"));
93    let config_path = home.join(".config/prompter/config.toml");
94    let default_library_path = home.join(".local/prompter/library");
95    collect_doctor_state_at(&config_path, &default_library_path)
96}
97
98/// Collect doctor state for explicit config and default-library paths.
99///
100/// Kept separate from [`collect_doctor_state`] so unit tests can drive every
101/// branch (missing config, load failure, present/absent library roots) against
102/// temporary fixtures without touching the real home directory.
103fn collect_doctor_state_at(config_path: &Path, default_library_path: &Path) -> DoctorState {
104    let config_file_exists = config_path.exists();
105    let mut errors = Vec::new();
106    let warnings = Vec::new();
107
108    let (bundle_result, library_roots) = if config_file_exists {
109        match load_config_bundle(config_path, Some(default_library_path)) {
110            Ok(cfg) => {
111                let roots = cfg.library_roots();
112                for root in &roots {
113                    if !root.exists() {
114                        errors.push(format!("Library directory not found: {}", root.display()));
115                    }
116                }
117                (Some(Ok(cfg)), roots)
118            }
119            Err(e) => {
120                errors.push(format!("Bundle load failed: {e}"));
121                (Some(Err(e)), Vec::new())
122            }
123        }
124    } else {
125        errors.push(format!("Config file not found: {}", config_path.display()));
126        (None, Vec::new())
127    };
128
129    DoctorState {
130        config_path: config_path.to_path_buf(),
131        default_library_path: default_library_path.to_path_buf(),
132        config_file_exists,
133        bundle_result,
134        library_roots,
135        errors,
136        warnings,
137    }
138}
139
140/// Build the structured doctor report from a collected state.
141fn report_for_state(state: &DoctorState) -> DoctorReport {
142    let bundle_loads = matches!(state.bundle_result, Some(Ok(_)));
143    let library_directory_exists =
144        !state.library_roots.is_empty() && state.library_roots.iter().all(|p| p.exists());
145    // Build the report from THIS state's checks, not from
146    // `DoctorReport::for_tool(&PrompterDoctor)` — the latter runs
147    // `PrompterDoctor::tool_checks()`, which re-reads the real `$HOME` and made
148    // the exit code depend on the ambient environment (green on a configured
149    // machine, red in a clean CI runner). `run_doctor` passes
150    // `collect_doctor_state()`, so production output is unchanged.
151    let mut report = DoctorReport::new(format!(
152        "🏥 {} health check",
153        <PrompterDoctor as DoctorChecks>::repo_info().name
154    ))
155    .with_checks(checks_for_state(state))
156    .with_version(<PrompterDoctor as DoctorChecks>::current_version())
157    .with_detail("config_file_exists", json!(state.config_file_exists))
158    .with_detail("bundle_loads", json!(bundle_loads))
159    .with_detail("library_directory_exists", json!(library_directory_exists))
160    .with_detail(
161        "library_roots",
162        json!(
163            state
164                .library_roots
165                .iter()
166                .map(|p| p.display().to_string())
167                .collect::<Vec<_>>()
168        ),
169    );
170
171    for error in &state.errors {
172        report = report.with_error(error.clone());
173    }
174    for warning in &state.warnings {
175        report = report.with_warning(warning.clone());
176    }
177
178    report
179        .with_info(format!("Current version: v{}", env!("CARGO_PKG_VERSION")))
180        .with_info("Check https://github.com/tftio/prompter/releases for updates")
181}
182
183/// Run doctor command to check health and configuration.
184///
185/// Returns exit code: 0 if healthy, 1 if issues found.
186#[must_use]
187pub fn run_doctor(output: JsonOutput) -> i32 {
188    report_for_state(&collect_doctor_state()).emit_output(output)
189}
190
191#[cfg(test)]
192mod tests {
193    use super::{
194        DoctorState, checks_for_state, collect_doctor_state_at, report_for_state, run_doctor,
195    };
196    use std::fs;
197    use std::path::PathBuf;
198    use std::sync::atomic::{AtomicU32, Ordering};
199    use tftio_lib::JsonOutput;
200
201    static COUNTER: AtomicU32 = AtomicU32::new(0);
202
203    fn mk_tmp(prefix: &str) -> PathBuf {
204        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
205        std::env::temp_dir().join(format!("{prefix}_{}_{n}", std::process::id()))
206    }
207
208    #[test]
209    fn state_reports_missing_config() {
210        let dir = mk_tmp("prompter_doctor_missing");
211        let state = collect_doctor_state_at(&dir.join("config.toml"), &dir.join("library"));
212        assert!(!state.config_file_exists);
213        assert!(state.bundle_result.is_none());
214        assert!(state.library_roots.is_empty());
215        let checks = checks_for_state(&state);
216        // config-missing fail + library-missing fail, no bundle check.
217        assert_eq!(checks.len(), 2);
218        assert!(checks.iter().all(|check| !check.passed));
219        // Exit code is non-zero when there are errors.
220        assert_eq!(report_for_state(&state).emit_output(JsonOutput::Text), 1);
221    }
222
223    #[test]
224    fn state_reports_loaded_bundle() {
225        let dir = mk_tmp("prompter_doctor_ok");
226        fs::create_dir_all(dir.join("library/a")).unwrap();
227        fs::write(dir.join("library/a/x.md"), b"AX\n").unwrap();
228        fs::write(
229            dir.join("config.toml"),
230            "[root]\ndepends_on = [\"a/x.md\"]\n",
231        )
232        .unwrap();
233        let state = collect_doctor_state_at(&dir.join("config.toml"), &dir.join("library"));
234        assert!(state.config_file_exists);
235        assert!(matches!(state.bundle_result, Some(Ok(_))));
236        assert!(!state.library_roots.is_empty());
237        let checks = checks_for_state(&state);
238        assert!(
239            checks.iter().all(|check| check.passed),
240            "all checks should pass for a healthy config"
241        );
242        assert_eq!(report_for_state(&state).emit_output(JsonOutput::Json), 0);
243    }
244
245    #[test]
246    fn state_reports_bundle_error() {
247        let dir = mk_tmp("prompter_doctor_badtoml");
248        fs::create_dir_all(dir.join("library")).unwrap();
249        fs::write(dir.join("config.toml"), "this is not valid toml {{{").unwrap();
250        let state = collect_doctor_state_at(&dir.join("config.toml"), &dir.join("library"));
251        assert!(state.config_file_exists);
252        assert!(matches!(state.bundle_result, Some(Err(_))));
253        let checks = checks_for_state(&state);
254        assert!(
255            checks
256                .iter()
257                .any(|check| !check.passed && check.name.contains("bundle")),
258            "a bundle-load failure should surface a failing check"
259        );
260        assert_eq!(report_for_state(&state).emit_output(JsonOutput::Text), 1);
261    }
262
263    #[test]
264    fn state_reports_missing_library_root() {
265        // A config that loads but whose (explicit) library root does not exist
266        // exercises the library-root-missing branch.
267        let dir = mk_tmp("prompter_doctor_nolib");
268        fs::create_dir_all(&dir).unwrap();
269        fs::write(
270            dir.join("config.toml"),
271            "library = \"absent\"\n\n[root]\ndepends_on = []\n",
272        )
273        .unwrap();
274        let state = collect_doctor_state_at(&dir.join("config.toml"), &dir.join("library"));
275        assert!(matches!(state.bundle_result, Some(Ok(_))));
276        assert!(!state.library_roots.is_empty());
277        assert!(state.library_roots.iter().any(|root| !root.exists()));
278        let checks = checks_for_state(&state);
279        assert!(
280            checks
281                .iter()
282                .any(|check| !check.passed && check.name.contains("Library")),
283            "a missing library root should surface a failing check"
284        );
285    }
286
287    #[test]
288    fn report_threads_warnings_without_failing() {
289        // A healthy state carrying an advisory warning but no errors still exits
290        // 0: warnings are reported but only failing checks or errors change the
291        // exit code.
292        let dir = mk_tmp("prompter_doctor_warn");
293        fs::create_dir_all(&dir).unwrap();
294        let state = DoctorState {
295            config_path: dir.join("config.toml"),
296            default_library_path: dir.clone(),
297            config_file_exists: true,
298            bundle_result: None,
299            library_roots: vec![dir.clone()],
300            errors: Vec::new(),
301            warnings: vec!["advisory note".to_string()],
302        };
303        assert_eq!(report_for_state(&state).emit_output(JsonOutput::Text), 0);
304    }
305
306    #[test]
307    fn run_doctor_returns_valid_exit_code() {
308        // Smoke test of the public entry point (reads the real home, read-only);
309        // the deterministic branch coverage is provided by the seam tests above.
310        let exit = run_doctor(JsonOutput::Text);
311        assert!(
312            exit == 0 || exit == 1,
313            "unexpected doctor exit code: {exit}"
314        );
315    }
316}