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::{self, OpenOptions};
319    use std::io::Write;
320    use std::time::{SystemTime, UNIX_EPOCH};
321
322    // JOE-1916 / F-003: exclusive create_new with unpredictable name; never follow
323    // a pre-existing symlink at a predictable probe path. Always remove on exit.
324    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        // Guard: if a symlink already exists at our random name (astronomically rare),
335        // refuse rather than following it.
336        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        // JSON must not contain secret-shaped key material from a set key.
400        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        // Point at a non-directory path so create_dir_all fails when a file exists.
415        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        // Ensure a fixed-name attacker path is not used; probe is random exclusive.
430        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        // Plant a predictable-name symlink like the old probe path.
436        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            // On Windows, skip symlink plant if privileges missing; still assert writable.
444            let _ = decoy;
445        }
446        let check = cache_writable_check(&cache);
447        assert!(check.ok, "writable cache should pass: {:?}", check);
448        // Victim must be intact (we never open the decoy path).
449        assert_eq!(std::fs::read(&victim).unwrap(), b"keep-me");
450        // No leftover random probe files (ignore the planted fixed-name decoy).
451        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}