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    let probe = path
291        .parent()
292        .filter(|p| !p.as_os_str().is_empty())
293        .unwrap_or(path);
294    if !(probe.exists() || path.exists()) {
295        return DoctorCheck {
296            id: "disk".into(),
297            ok: false,
298            severity: DoctorSeverity::Warn,
299            summary: "cache path parent not found".into(),
300            detail: Some(probe.display().to_string()),
301            hint: Some("create the directory or choose another cache root".into()),
302        };
303    }
304    // Best-effort free-space probe (same helper downloads use for preflight).
305    match crate::download::available_disk_bytes(if probe.exists() { probe } else { path }) {
306        Some(free) => {
307            const WARN_BELOW: u64 = 512 * 1024 * 1024; // 512 MiB
308            let mb = free / (1024 * 1024);
309            if free < WARN_BELOW {
310                DoctorCheck {
311                    id: "disk".into(),
312                    ok: true,
313                    severity: DoctorSeverity::Warn,
314                    summary: format!("low free space (~{mb} MiB)"),
315                    detail: Some(probe.display().to_string()),
316                    hint: Some(
317                        "model downloads need budget + 64 MiB headroom; free space or shrink cache"
318                            .into(),
319                    ),
320                }
321            } else {
322                DoctorCheck {
323                    id: "disk".into(),
324                    ok: true,
325                    severity: DoctorSeverity::Info,
326                    summary: format!("free space ~{mb} MiB"),
327                    detail: Some(probe.display().to_string()),
328                    hint: None,
329                }
330            }
331        }
332        None => DoctorCheck {
333            id: "disk".into(),
334            ok: true,
335            severity: DoctorSeverity::Info,
336            summary: "cache path parent is reachable".into(),
337            detail: Some(probe.display().to_string()),
338            hint: None,
339        },
340    }
341}
342
343fn cache_writable_check(path: &Path) -> DoctorCheck {
344    use std::fs::{self, OpenOptions};
345    use std::io::Write;
346    use std::time::{SystemTime, UNIX_EPOCH};
347
348    // JOE-1916 / F-003: exclusive create_new with unpredictable name; never follow
349    // a pre-existing symlink at a predictable probe path. Always remove on exit.
350    let create = || -> std::io::Result<()> {
351        fs::create_dir_all(path)?;
352        let nanos = SystemTime::now()
353            .duration_since(UNIX_EPOCH)
354            .map(|d| d.as_nanos())
355            .unwrap_or(0);
356        let probe = path.join(format!(
357            ".aurum-doctor-write-{}-{nanos}",
358            std::process::id()
359        ));
360        // Guard: if a symlink already exists at our random name (astronomically rare),
361        // refuse rather than following it.
362        if probe.symlink_metadata().is_ok() {
363            return Err(std::io::Error::new(
364                std::io::ErrorKind::AlreadyExists,
365                "probe path unexpectedly exists",
366            ));
367        }
368        let write_result = (|| -> std::io::Result<()> {
369            let mut f = OpenOptions::new()
370                .write(true)
371                .create_new(true)
372                .open(&probe)?;
373            f.write_all(b"ok")?;
374            f.sync_all()?;
375            Ok(())
376        })();
377        let _ = fs::remove_file(&probe);
378        write_result
379    };
380    match create() {
381        Ok(()) => DoctorCheck {
382            id: "cache_writable".into(),
383            ok: true,
384            severity: DoctorSeverity::Info,
385            summary: "cache directory is writable".into(),
386            detail: Some(path.display().to_string()),
387            hint: None,
388        },
389        Err(e) => DoctorCheck {
390            id: "cache_writable".into(),
391            ok: false,
392            severity: DoctorSeverity::Error,
393            summary: "cache directory is not writable".into(),
394            detail: Some(format!("{}: {e}", path.display())),
395            hint: Some("fix permissions or choose another cache root".into()),
396        },
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use crate::config::Config;
404    use crate::secret::SecretString;
405    use tempfile::tempdir;
406
407    #[test]
408    fn doctor_is_offline_and_includes_core_checks() {
409        let dir = tempdir().unwrap();
410        let mut cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
411        cfg.cache_dir = dir.path().join("cache");
412        let report = run_doctor(&cfg);
413        let ids: Vec<_> = report.checks.iter().map(|c| c.id.as_str()).collect();
414        assert!(ids.contains(&"version"));
415        assert!(ids.contains(&"config"));
416        assert!(ids.contains(&"cache_dir"));
417        assert!(ids.contains(&"ffmpeg"));
418        assert!(ids.contains(&"secrets_redacted"));
419        assert!(ids.contains(&"offline"));
420        assert!(ids.contains(&"cache_writable"));
421        assert!(report
422            .checks
423            .iter()
424            .any(|c| c.id == "offline" && c.ok && c.summary.contains("no network")));
425        // JSON must not contain secret-shaped key material from a set key.
426        cfg.openrouter_api_key = Some(SecretString::new("sk-super-secret-token-value"));
427        let report2 = run_doctor(&cfg);
428        let json = report2.to_json_pretty().unwrap();
429        assert!(!json.contains("sk-super-secret-token-value"));
430        assert!(report2
431            .checks
432            .iter()
433            .any(|c| c.id == "secrets_redacted" && c.ok));
434    }
435
436    #[test]
437    fn doctor_flags_unwritable_cache() {
438        let dir = tempdir().unwrap();
439        let mut cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
440        // Point at a non-directory path so create_dir_all fails when a file exists.
441        let file = dir.path().join("not-a-dir");
442        std::fs::write(&file, b"x").unwrap();
443        cfg.cache_dir = file;
444        let report = run_doctor(&cfg);
445        let w = report
446            .checks
447            .iter()
448            .find(|c| c.id == "cache_writable")
449            .expect("cache_writable check");
450        assert!(!w.ok);
451    }
452
453    #[test]
454    fn cache_writable_probe_does_not_clobber_preexisting_symlink_target() {
455        // Ensure a fixed-name attacker path is not used; probe is random exclusive.
456        let dir = tempdir().unwrap();
457        let cache = dir.path().join("cache");
458        std::fs::create_dir_all(&cache).unwrap();
459        let victim = dir.path().join("victim-secret");
460        std::fs::write(&victim, b"keep-me").unwrap();
461        // Plant a predictable-name symlink like the old probe path.
462        let decoy = cache.join(".aurum-doctor-write-probe");
463        #[cfg(unix)]
464        {
465            std::os::unix::fs::symlink(&victim, &decoy).unwrap();
466        }
467        #[cfg(not(unix))]
468        {
469            // On Windows, skip symlink plant if privileges missing; still assert writable.
470            let _ = decoy;
471        }
472        let check = cache_writable_check(&cache);
473        assert!(check.ok, "writable cache should pass: {:?}", check);
474        // Victim must be intact (we never open the decoy path).
475        assert_eq!(std::fs::read(&victim).unwrap(), b"keep-me");
476        // No leftover random probe files (ignore the planted fixed-name decoy).
477        let leftovers: Vec<_> = std::fs::read_dir(&cache)
478            .unwrap()
479            .filter_map(|e| e.ok())
480            .filter(|e| {
481                let name = e.file_name().to_string_lossy().into_owned();
482                name.starts_with(".aurum-doctor-write-") && name != ".aurum-doctor-write-probe"
483            })
484            .collect();
485        assert!(leftovers.is_empty(), "probe leftovers: {leftovers:?}");
486    }
487
488    #[test]
489    fn doctor_runs_on_defaults() {
490        let dir = tempdir().unwrap();
491        let mut cfg = Config::load().unwrap();
492        cfg.cache_dir = dir.path().to_path_buf();
493        let r = run_doctor(&cfg);
494        assert_eq!(r.schema_version, DOCTOR_SCHEMA_VERSION);
495        assert!(!r.checks.is_empty());
496        assert!(r.to_json_pretty().unwrap().contains("aurum_version"));
497        assert!(r.format_human().contains("aurum doctor"));
498    }
499}