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 let ok = checks
206 .iter()
207 .all(|c| c.ok || matches!(c.severity, DoctorSeverity::Info | DoctorSeverity::Warn));
208
209 DoctorReport {
210 schema_version: DOCTOR_SCHEMA_VERSION,
211 aurum_version: env!("CARGO_PKG_VERSION").into(),
212 target: format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS),
213 features,
214 checks,
215 ok,
216 }
217}
218
219fn dir_check(id: &str, path: &Path, create_ok: bool) -> DoctorCheck {
220 if path.as_os_str().is_empty() {
221 return DoctorCheck {
222 id: id.into(),
223 ok: false,
224 severity: DoctorSeverity::Error,
225 summary: format!("{id} is empty"),
226 detail: None,
227 hint: Some("set a writable cache directory".into()),
228 };
229 }
230 if path.exists() {
231 let meta = std::fs::metadata(path);
232 let is_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false);
233 if is_dir {
234 DoctorCheck {
235 id: id.into(),
236 ok: true,
237 severity: DoctorSeverity::Info,
238 summary: format!("{id} exists"),
239 detail: Some(path.display().to_string()),
240 hint: None,
241 }
242 } else {
243 DoctorCheck {
244 id: id.into(),
245 ok: false,
246 severity: DoctorSeverity::Error,
247 summary: format!("{id} is not a directory"),
248 detail: Some(path.display().to_string()),
249 hint: None,
250 }
251 }
252 } else if create_ok {
253 DoctorCheck {
254 id: id.into(),
255 ok: true,
256 severity: DoctorSeverity::Warn,
257 summary: format!("{id} does not exist yet (will be created on use)"),
258 detail: Some(path.display().to_string()),
259 hint: None,
260 }
261 } else {
262 DoctorCheck {
263 id: id.into(),
264 ok: false,
265 severity: DoctorSeverity::Error,
266 summary: format!("{id} missing"),
267 detail: Some(path.display().to_string()),
268 hint: None,
269 }
270 }
271}
272
273fn disk_space_check(path: &Path) -> DoctorCheck {
274 let probe = path
277 .parent()
278 .filter(|p| !p.as_os_str().is_empty())
279 .unwrap_or(path);
280 if probe.exists() || path.exists() {
281 DoctorCheck {
282 id: "disk".into(),
283 ok: true,
284 severity: DoctorSeverity::Info,
285 summary: "cache path parent is reachable".into(),
286 detail: Some(probe.display().to_string()),
287 hint: None,
288 }
289 } else {
290 DoctorCheck {
291 id: "disk".into(),
292 ok: false,
293 severity: DoctorSeverity::Warn,
294 summary: "cache path parent not found".into(),
295 detail: Some(probe.display().to_string()),
296 hint: Some("create the directory or choose another cache root".into()),
297 }
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use tempfile::tempdir;
305
306 #[test]
307 fn doctor_runs_on_defaults() {
308 let dir = tempdir().unwrap();
309 let mut cfg = Config::load().unwrap();
310 cfg.cache_dir = dir.path().to_path_buf();
311 let r = run_doctor(&cfg);
312 assert_eq!(r.schema_version, DOCTOR_SCHEMA_VERSION);
313 assert!(!r.checks.is_empty());
314 assert!(r.to_json_pretty().unwrap().contains("aurum_version"));
315 assert!(r.format_human().contains("aurum doctor"));
316 }
317}