1use crate::config::Config;
6use crate::error::Result;
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9
10pub const DOCTOR_SCHEMA_VERSION: u32 = 1;
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct DoctorCheck {
16 pub id: String,
17 pub ok: bool,
18 pub severity: DoctorSeverity,
19 pub summary: String,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub detail: Option<String>,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub hint: Option<String>,
24}
25
26#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
27#[serde(rename_all = "snake_case")]
28pub enum DoctorSeverity {
29 Info,
30 Warn,
31 Error,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
36pub struct DoctorReport {
37 pub schema_version: u32,
38 pub aurum_version: String,
39 pub target: String,
40 pub features: Vec<String>,
41 pub checks: Vec<DoctorCheck>,
42 pub ok: bool,
43}
44
45impl DoctorReport {
46 pub fn to_json_pretty(&self) -> Result<String> {
47 serde_json::to_string_pretty(self)
48 .map_err(|e| crate::error::TranscriptionError::internal(format!("doctor json: {e}")))
49 }
50
51 pub fn format_human(&self) -> String {
52 let mut out = String::new();
53 out.push_str(&format!(
54 "aurum doctor version={} target={}\n",
55 self.aurum_version, self.target
56 ));
57 out.push_str(&format!("features: {}\n\n", self.features.join(", ")));
58 for c in &self.checks {
59 let mark = if c.ok { "ok" } else { "!!" };
60 out.push_str(&format!("[{mark}] {} — {}\n", c.id, c.summary));
61 if let Some(d) = &c.detail {
62 out.push_str(&format!(" {d}\n"));
63 }
64 if let Some(h) = &c.hint {
65 out.push_str(&format!(" Hint: {h}\n"));
66 }
67 }
68 out.push_str(&format!(
69 "\noverall: {}\n",
70 if self.ok { "healthy" } else { "issues found" }
71 ));
72 out
73 }
74}
75
76pub fn run_doctor(cfg: &Config) -> DoctorReport {
78 let mut checks = Vec::new();
79 #[cfg(feature = "tts")]
80 let features = vec!["stt".into(), "cleanup".into(), "tts".into()];
81 #[cfg(not(feature = "tts"))]
82 let features = vec!["stt".into(), "cleanup".into()];
83
84 checks.push(DoctorCheck {
85 id: "version".into(),
86 ok: true,
87 severity: DoctorSeverity::Info,
88 summary: format!("aurum-core {}", env!("CARGO_PKG_VERSION")),
89 detail: Some(format!("target={}", std::env::consts::ARCH)),
90 hint: None,
91 });
92
93 match cfg.validate() {
95 Ok(()) => checks.push(DoctorCheck {
96 id: "config".into(),
97 ok: true,
98 severity: DoctorSeverity::Info,
99 summary: "configuration validates".into(),
100 detail: Some(format!(
101 "provider={} tts_model={} cache={}",
102 cfg.provider,
103 cfg.tts_model,
104 cfg.cache_dir.display()
105 )),
106 hint: None,
107 }),
108 Err(e) => checks.push(DoctorCheck {
109 id: "config".into(),
110 ok: false,
111 severity: DoctorSeverity::Error,
112 summary: "configuration invalid".into(),
113 detail: Some(e.to_string()),
114 hint: Some("fix config.toml or environment overrides".into()),
115 }),
116 }
117
118 checks.push(dir_check("cache_dir", &cfg.cache_dir, true));
120
121 match which::which("ffmpeg") {
123 Ok(p) => checks.push(DoctorCheck {
124 id: "ffmpeg".into(),
125 ok: true,
126 severity: DoctorSeverity::Info,
127 summary: "ffmpeg found on PATH".into(),
128 detail: Some(p.display().to_string()),
129 hint: None,
130 }),
131 Err(_) => checks.push(DoctorCheck {
132 id: "ffmpeg".into(),
133 ok: false,
134 severity: DoctorSeverity::Warn,
135 summary: "ffmpeg not found on PATH".into(),
136 detail: Some("required for local STT file decode".into()),
137 hint: Some(
138 "install ffmpeg (brew/apt/winget) or use PCM/WAV direct paths where supported"
139 .into(),
140 ),
141 }),
142 }
143
144 let stt = crate::capabilities::local_whisper_capabilities(
146 cfg.model
147 .as_deref()
148 .unwrap_or(crate::config::DEFAULT_LOCAL_MODEL),
149 );
150 checks.push(DoctorCheck {
151 id: "capabilities_stt".into(),
152 ok: true,
153 severity: DoctorSeverity::Info,
154 summary: format!(
155 "local STT capabilities declared (timestamps_reliable={})",
156 stt.timestamps_reliable
157 ),
158 detail: Some(format!("formats={}", stt.output_formats.join(","))),
159 hint: None,
160 });
161
162 #[cfg(feature = "tts")]
163 {
164 let tts = crate::capabilities::local_tts_capabilities(&cfg.tts_model);
165 checks.push(DoctorCheck {
166 id: "capabilities_tts".into(),
167 ok: true,
168 severity: DoctorSeverity::Info,
169 summary: format!("local TTS capabilities for model {}", tts.model),
170 detail: Some(format!(
171 "network={} local_only_ok={}",
172 tts.requires_network, tts.local_only_ok
173 )),
174 hint: None,
175 });
176 }
177
178 let diag = cfg.effective_diagnostic();
180 let key_ok = diag
181 .openrouter_api_key
182 .as_deref()
183 .map(|k| k == "***" || k.is_empty())
184 .unwrap_or(true);
185 checks.push(DoctorCheck {
186 id: "secrets_redacted".into(),
187 ok: key_ok,
188 severity: if key_ok {
189 DoctorSeverity::Info
190 } else {
191 DoctorSeverity::Error
192 },
193 summary: if key_ok {
194 "config diagnostics redact secrets".into()
195 } else {
196 "config diagnostics leaked a secret".into()
197 },
198 detail: None,
199 hint: None,
200 });
201
202 checks.push(disk_space_check(&cfg.cache_dir));
204
205 checks.push(cache_writable_check(&cfg.cache_dir));
207
208 checks.push(DoctorCheck {
210 id: "offline".into(),
211 ok: true,
212 severity: DoctorSeverity::Info,
213 summary: "doctor performed no network or download".into(),
214 detail: Some(
215 "default doctor is offline-only; use explicit remote commands for connectivity checks"
216 .into(),
217 ),
218 hint: None,
219 });
220
221 let ok = checks
222 .iter()
223 .all(|c| c.ok || matches!(c.severity, DoctorSeverity::Info | DoctorSeverity::Warn));
224
225 DoctorReport {
226 schema_version: DOCTOR_SCHEMA_VERSION,
227 aurum_version: env!("CARGO_PKG_VERSION").into(),
228 target: format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS),
229 features,
230 checks,
231 ok,
232 }
233}
234
235fn dir_check(id: &str, path: &Path, create_ok: bool) -> DoctorCheck {
236 if path.as_os_str().is_empty() {
237 return DoctorCheck {
238 id: id.into(),
239 ok: false,
240 severity: DoctorSeverity::Error,
241 summary: format!("{id} is empty"),
242 detail: None,
243 hint: Some("set a writable cache directory".into()),
244 };
245 }
246 if path.exists() {
247 let meta = std::fs::metadata(path);
248 let is_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false);
249 if is_dir {
250 DoctorCheck {
251 id: id.into(),
252 ok: true,
253 severity: DoctorSeverity::Info,
254 summary: format!("{id} exists"),
255 detail: Some(path.display().to_string()),
256 hint: None,
257 }
258 } else {
259 DoctorCheck {
260 id: id.into(),
261 ok: false,
262 severity: DoctorSeverity::Error,
263 summary: format!("{id} is not a directory"),
264 detail: Some(path.display().to_string()),
265 hint: None,
266 }
267 }
268 } else if create_ok {
269 DoctorCheck {
270 id: id.into(),
271 ok: true,
272 severity: DoctorSeverity::Warn,
273 summary: format!("{id} does not exist yet (will be created on use)"),
274 detail: Some(path.display().to_string()),
275 hint: None,
276 }
277 } else {
278 DoctorCheck {
279 id: id.into(),
280 ok: false,
281 severity: DoctorSeverity::Error,
282 summary: format!("{id} missing"),
283 detail: Some(path.display().to_string()),
284 hint: None,
285 }
286 }
287}
288
289fn disk_space_check(path: &Path) -> DoctorCheck {
290 let probe = path
293 .parent()
294 .filter(|p| !p.as_os_str().is_empty())
295 .unwrap_or(path);
296 if probe.exists() || path.exists() {
297 DoctorCheck {
298 id: "disk".into(),
299 ok: true,
300 severity: DoctorSeverity::Info,
301 summary: "cache path parent is reachable".into(),
302 detail: Some(probe.display().to_string()),
303 hint: None,
304 }
305 } else {
306 DoctorCheck {
307 id: "disk".into(),
308 ok: false,
309 severity: DoctorSeverity::Warn,
310 summary: "cache path parent not found".into(),
311 detail: Some(probe.display().to_string()),
312 hint: Some("create the directory or choose another cache root".into()),
313 }
314 }
315}
316
317fn cache_writable_check(path: &Path) -> DoctorCheck {
318 use std::fs::{self, OpenOptions};
319 use std::io::Write;
320 use std::time::{SystemTime, UNIX_EPOCH};
321
322 let create = || -> std::io::Result<()> {
325 fs::create_dir_all(path)?;
326 let nanos = SystemTime::now()
327 .duration_since(UNIX_EPOCH)
328 .map(|d| d.as_nanos())
329 .unwrap_or(0);
330 let probe = path.join(format!(
331 ".aurum-doctor-write-{}-{nanos}",
332 std::process::id()
333 ));
334 if probe.symlink_metadata().is_ok() {
337 return Err(std::io::Error::new(
338 std::io::ErrorKind::AlreadyExists,
339 "probe path unexpectedly exists",
340 ));
341 }
342 let write_result = (|| -> std::io::Result<()> {
343 let mut f = OpenOptions::new()
344 .write(true)
345 .create_new(true)
346 .open(&probe)?;
347 f.write_all(b"ok")?;
348 f.sync_all()?;
349 Ok(())
350 })();
351 let _ = fs::remove_file(&probe);
352 write_result
353 };
354 match create() {
355 Ok(()) => DoctorCheck {
356 id: "cache_writable".into(),
357 ok: true,
358 severity: DoctorSeverity::Info,
359 summary: "cache directory is writable".into(),
360 detail: Some(path.display().to_string()),
361 hint: None,
362 },
363 Err(e) => DoctorCheck {
364 id: "cache_writable".into(),
365 ok: false,
366 severity: DoctorSeverity::Error,
367 summary: "cache directory is not writable".into(),
368 detail: Some(format!("{}: {e}", path.display())),
369 hint: Some("fix permissions or choose another cache root".into()),
370 },
371 }
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377 use crate::config::Config;
378 use crate::secret::SecretString;
379 use tempfile::tempdir;
380
381 #[test]
382 fn doctor_is_offline_and_includes_core_checks() {
383 let dir = tempdir().unwrap();
384 let mut cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
385 cfg.cache_dir = dir.path().join("cache");
386 let report = run_doctor(&cfg);
387 let ids: Vec<_> = report.checks.iter().map(|c| c.id.as_str()).collect();
388 assert!(ids.contains(&"version"));
389 assert!(ids.contains(&"config"));
390 assert!(ids.contains(&"cache_dir"));
391 assert!(ids.contains(&"ffmpeg"));
392 assert!(ids.contains(&"secrets_redacted"));
393 assert!(ids.contains(&"offline"));
394 assert!(ids.contains(&"cache_writable"));
395 assert!(report
396 .checks
397 .iter()
398 .any(|c| c.id == "offline" && c.ok && c.summary.contains("no network")));
399 cfg.openrouter_api_key = Some(SecretString::new("sk-super-secret-token-value"));
401 let report2 = run_doctor(&cfg);
402 let json = report2.to_json_pretty().unwrap();
403 assert!(!json.contains("sk-super-secret-token-value"));
404 assert!(report2
405 .checks
406 .iter()
407 .any(|c| c.id == "secrets_redacted" && c.ok));
408 }
409
410 #[test]
411 fn doctor_flags_unwritable_cache() {
412 let dir = tempdir().unwrap();
413 let mut cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
414 let file = dir.path().join("not-a-dir");
416 std::fs::write(&file, b"x").unwrap();
417 cfg.cache_dir = file;
418 let report = run_doctor(&cfg);
419 let w = report
420 .checks
421 .iter()
422 .find(|c| c.id == "cache_writable")
423 .expect("cache_writable check");
424 assert!(!w.ok);
425 }
426
427 #[test]
428 fn cache_writable_probe_does_not_clobber_preexisting_symlink_target() {
429 let dir = tempdir().unwrap();
431 let cache = dir.path().join("cache");
432 std::fs::create_dir_all(&cache).unwrap();
433 let victim = dir.path().join("victim-secret");
434 std::fs::write(&victim, b"keep-me").unwrap();
435 let decoy = cache.join(".aurum-doctor-write-probe");
437 #[cfg(unix)]
438 {
439 std::os::unix::fs::symlink(&victim, &decoy).unwrap();
440 }
441 #[cfg(not(unix))]
442 {
443 let _ = decoy;
445 }
446 let check = cache_writable_check(&cache);
447 assert!(check.ok, "writable cache should pass: {:?}", check);
448 assert_eq!(std::fs::read(&victim).unwrap(), b"keep-me");
450 let leftovers: Vec<_> = std::fs::read_dir(&cache)
452 .unwrap()
453 .filter_map(|e| e.ok())
454 .filter(|e| {
455 let name = e.file_name().to_string_lossy().into_owned();
456 name.starts_with(".aurum-doctor-write-") && name != ".aurum-doctor-write-probe"
457 })
458 .collect();
459 assert!(leftovers.is_empty(), "probe leftovers: {leftovers:?}");
460 }
461
462 #[test]
463 fn doctor_runs_on_defaults() {
464 let dir = tempdir().unwrap();
465 let mut cfg = Config::load().unwrap();
466 cfg.cache_dir = dir.path().to_path_buf();
467 let r = run_doctor(&cfg);
468 assert_eq!(r.schema_version, DOCTOR_SCHEMA_VERSION);
469 assert!(!r.checks.is_empty());
470 assert!(r.to_json_pretty().unwrap().contains("aurum_version"));
471 assert!(r.format_human().contains("aurum doctor"));
472 }
473}