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    // Writable cache probe (JOE-1783): create/delete a tiny file when possible.
206    checks.push(cache_writable_check(&cfg.cache_dir));
207
208    // Explicit offline contract: default doctor never downloads or opens network.
209    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    // Portable best-effort: try to create parent and report existence only.
291    // Full free-space probes are platform-specific; we keep this deterministic.
292    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;
319    use std::io::Write;
320    let create = || -> std::io::Result<()> {
321        fs::create_dir_all(path)?;
322        let probe = path.join(".aurum-doctor-write-probe");
323        {
324            let mut f = fs::File::create(&probe)?;
325            f.write_all(b"ok")?;
326        }
327        fs::remove_file(&probe)?;
328        Ok(())
329    };
330    match create() {
331        Ok(()) => DoctorCheck {
332            id: "cache_writable".into(),
333            ok: true,
334            severity: DoctorSeverity::Info,
335            summary: "cache directory is writable".into(),
336            detail: Some(path.display().to_string()),
337            hint: None,
338        },
339        Err(e) => DoctorCheck {
340            id: "cache_writable".into(),
341            ok: false,
342            severity: DoctorSeverity::Error,
343            summary: "cache directory is not writable".into(),
344            detail: Some(format!("{}: {e}", path.display())),
345            hint: Some("fix permissions or choose another cache root".into()),
346        },
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use crate::config::Config;
354    use crate::secret::SecretString;
355    use tempfile::tempdir;
356
357    #[test]
358    fn doctor_is_offline_and_includes_core_checks() {
359        let dir = tempdir().unwrap();
360        let mut cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
361        cfg.cache_dir = dir.path().join("cache");
362        let report = run_doctor(&cfg);
363        let ids: Vec<_> = report.checks.iter().map(|c| c.id.as_str()).collect();
364        assert!(ids.contains(&"version"));
365        assert!(ids.contains(&"config"));
366        assert!(ids.contains(&"cache_dir"));
367        assert!(ids.contains(&"ffmpeg"));
368        assert!(ids.contains(&"secrets_redacted"));
369        assert!(ids.contains(&"offline"));
370        assert!(ids.contains(&"cache_writable"));
371        assert!(report
372            .checks
373            .iter()
374            .any(|c| c.id == "offline" && c.ok && c.summary.contains("no network")));
375        // JSON must not contain secret-shaped key material from a set key.
376        cfg.openrouter_api_key = Some(SecretString::new("sk-super-secret-token-value"));
377        let report2 = run_doctor(&cfg);
378        let json = report2.to_json_pretty().unwrap();
379        assert!(!json.contains("sk-super-secret-token-value"));
380        assert!(report2
381            .checks
382            .iter()
383            .any(|c| c.id == "secrets_redacted" && c.ok));
384    }
385
386    #[test]
387    fn doctor_flags_unwritable_cache() {
388        let dir = tempdir().unwrap();
389        let mut cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
390        // Point at a non-directory path so create_dir_all fails when a file exists.
391        let file = dir.path().join("not-a-dir");
392        std::fs::write(&file, b"x").unwrap();
393        cfg.cache_dir = file;
394        let report = run_doctor(&cfg);
395        let w = report
396            .checks
397            .iter()
398            .find(|c| c.id == "cache_writable")
399            .expect("cache_writable check");
400        assert!(!w.ok);
401    }
402
403    #[test]
404    fn doctor_runs_on_defaults() {
405        let dir = tempdir().unwrap();
406        let mut cfg = Config::load().unwrap();
407        cfg.cache_dir = dir.path().to_path_buf();
408        let r = run_doctor(&cfg);
409        assert_eq!(r.schema_version, DOCTOR_SCHEMA_VERSION);
410        assert!(!r.checks.is_empty());
411        assert!(r.to_json_pretty().unwrap().contains("aurum_version"));
412        assert!(r.format_human().contains("aurum doctor"));
413    }
414}