Skip to main content

aurum_core/
doctor.rs

1//! Read-only system / config / cache / capability diagnostics (JOE-1628).
2//!
3//! Never performs downloads, network calls, or secret printing.
4
5use crate::config::Config;
6use crate::error::Result;
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9
10/// Schema version for doctor JSON.
11pub const DOCTOR_SCHEMA_VERSION: u32 = 1;
12
13/// One diagnostic check result.
14#[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/// Full doctor report (redacted; safe to print).
35#[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
76/// Run the standard doctor suite using the effective config.
77pub 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    // Config validate.
94    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    // Cache directory.
119    checks.push(dir_check("cache_dir", &cfg.cache_dir, true));
120
121    // FFmpeg (STT path).
122    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    // Capability surface (static).
145    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    // Secrets redaction probe.
179    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    // Disk free space (best-effort).
203    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    // Portable best-effort: try to create parent and report existence only.
275    // Full free-space probes are platform-specific; we keep this deterministic.
276    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}