1use crate::{Config, PrompterError, load_config_bundle};
4use serde_json::json;
5use std::path::{Path, PathBuf};
6use tftio_lib::{DoctorCheck, DoctorChecks, DoctorReport, JsonOutput, RepoInfo};
7
8pub 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
35fn 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
98fn 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
140fn 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 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#[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 assert_eq!(checks.len(), 2);
218 assert!(checks.iter().all(|check| !check.passed));
219 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 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 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 let exit = run_doctor(JsonOutput::Text);
311 assert!(
312 exit == 0 || exit == 1,
313 "unexpected doctor exit code: {exit}"
314 );
315 }
316}