Skip to main content

oxi/storage/packages/
doctor.rs

1//! `Doctor` — structured health checks for the package subsystem.
2//!
3//! Replaces the ad-hoc `validate_package: Result<Vec<String>>` warnings
4//! shape with structured `DoctorCheck` rows that carry:
5//! - a stable `id` so a UI or automation script can target them,
6//! - a `Health` severity (`Healthy` | `Warning` | `Error`),
7//! - a human-readable `message` and an optional remediation `hint`.
8//!
9//! Doctor is the single place where the manager speaks up about
10//! missing files, malformed manifests, missing resources, or integrity
11//! mismatches. It composes the existing helpers (`read_manifest`,
12//! `verify_lockfile_integrity`, `discover_resources`) — it does not
13//! re-implement them.
14//!
15//! Inputs to `run()`:
16//! - `RuntimeConfig::read_or_default` for the user-level enabled state
17//! - `ProjectPluginOverrides::read_or_default` for the project-level forces
18//! - the manager's `installed`/`lockfile` state (already on disk)
19//!
20//! Doctor stays *read-only*: no install/uninstall side-effects, no
21//! filesystem mutations.
22
23use super::MANIFEST_NAME;
24use super::lockfile::verify_lockfile_integrity;
25use super::overrides::{ProjectPluginOverrides, resolve_enabled};
26use super::runtime_config::RuntimeConfig;
27use super::types::ResourceKind;
28use crate::storage::packages::manager::PackageManager;
29use anyhow::Result;
30use serde::{Deserialize, Serialize};
31use std::path::Path;
32
33/// Severity of a single check.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum Health {
37    /// The check passed; the package is healthy.
38    Healthy,
39    /// The check surfaced a non-fatal issue (e.g. integrity mismatch
40    /// silently dropped the package; a missing optional resource).
41    Warning,
42    /// The check found a blocker that should prevent the package from
43    /// being treated as installed (manifest corrupt, etc.).
44    Error,
45}
46
47impl std::fmt::Display for Health {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            Health::Healthy => write!(f, "healthy"),
51            Health::Warning => write!(f, "warning"),
52            Health::Error => write!(f, "error"),
53        }
54    }
55}
56
57/// A single structured check result.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct DoctorCheck {
60    /// Stable identifier (e.g. `"manifest.parse.test-pkg"`).
61    pub id: String,
62    /// Subject of the check (usually the package name).
63    pub subject: String,
64    /// Optional resource-kind context.
65    pub kind: Option<ResourceKind>,
66    /// Severity level.
67    pub health: Health,
68    /// Human-readable summary.
69    pub message: String,
70    /// Optional remediation hint for the user.
71    pub hint: Option<String>,
72}
73
74/// Aggregated doctor report. A report is `all_clear` when every check
75/// is `Healthy`.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct DoctorReport {
78    /// Per-package, per-resource health checks.
79    pub checks: Vec<DoctorCheck>,
80    /// Resolved enabled state per (package, kind), for callers that
81    /// want to render the configuration outcome.
82    pub resolved_enabled: Vec<EnabledSummary>,
83    /// Overall verdict.
84    pub all_clear: bool,
85}
86
87/// Convenience summary showing the layered precedence outcome.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct EnabledSummary {
90    /// Package name.
91    pub package: String,
92    /// Resource kind.
93    pub kind: ResourceKind,
94    /// Resolved enabled state (final, after precedence).
95    pub enabled: bool,
96    /// `Some(s)` when the project layer forced the value.
97    pub project_forced: Option<String>,
98    /// `true` when the user-level runtime layer disabled the resource.
99    pub user_disabled: bool,
100}
101
102/// Construct a Doctor directly from a `PackageManager` reference.
103///
104/// `runtime_path` / `overrides_path` are absolute paths passed in so the
105/// caller controls which configuration sources are read — important for
106/// tests and for the future CLI subcommand that operates against a
107/// different project root.
108pub fn run(
109    manager: &PackageManager,
110    runtime_path: &Path,
111    overrides_path: &Path,
112) -> Result<DoctorReport> {
113    let runtime = RuntimeConfig::read_or_default(runtime_path);
114    let overrides = ProjectPluginOverrides::read_or_default(overrides_path);
115
116    let mut checks = Vec::new();
117    let mut resolved = Vec::new();
118
119    // Enumerate every installed package name (manifest-known AND
120    // lockfile-known), so we surface both:
121    //  - manifest-known: integrity may have dropped them silently.
122    //  - lockfile-only: install left a row but the package dir failed.
123    let mut names: Vec<String> = manager.list().iter().map(|m| m.name.clone()).collect();
124    names.extend(
125        manager
126            .lockfile()
127            .packages
128            .keys()
129            .filter(|k| !manager.list().iter().any(|m| &m.name == *k))
130            .cloned(),
131    );
132    names.sort();
133
134    for name in &names {
135        check_manifest(manager, name, &mut checks);
136        check_lockfile_integrity(manager, name, &mut checks);
137        check_resources_present(manager, name, &mut checks);
138        for kind in ResourceKind::ALL {
139            let (enabled, summary) = compute_enabled_state(name, kind, &runtime, &overrides);
140            resolved.push(summary);
141            // Healthy summary row: enabled/disabled correctly resolved.
142            checks.push(DoctorCheck {
143                id: format!("resolve.{}.{}", name, kind),
144                subject: name.clone(),
145                kind: Some(kind),
146                health: Health::Healthy,
147                message: format!(
148                    "{} resource is {}",
149                    kind,
150                    if enabled { "enabled" } else { "disabled" }
151                ),
152                hint: None,
153            });
154        }
155    }
156
157    // Configuration-file integrity itself — missing file is fine,
158    // corrupt file is an error surfaced here so Doctor is the single
159    // channel.
160    if runtime_path.exists() {
161        match RuntimeConfig::read(runtime_path) {
162            Ok(_) => checks.push(DoctorCheck {
163                id: "config.runtime-file".to_string(),
164                subject: runtime_path.display().to_string(),
165                kind: None,
166                health: Health::Healthy,
167                message: "runtime.json parsed successfully".to_string(),
168                hint: None,
169            }),
170            Err(e) => checks.push(DoctorCheck {
171                id: "config.runtime-file".to_string(),
172                subject: runtime_path.display().to_string(),
173                kind: None,
174                health: Health::Error,
175                message: format!("runtime.json failed to parse: {e}"),
176                hint: Some(
177                    "delete the corrupt file or fix the JSON; runtime.json uses BTreeMap<String, HashSet<ResourceKind>> entries".to_string(),
178                ),
179            }),
180        }
181    }
182    if overrides_path.exists() {
183        match ProjectPluginOverrides::read(overrides_path) {
184            Ok(_) => checks.push(DoctorCheck {
185                id: "config.overrides-file".to_string(),
186                subject: overrides_path.display().to_string(),
187                kind: None,
188                health: Health::Healthy,
189                message: "plugin-overrides.json parsed successfully".to_string(),
190                hint: None,
191            }),
192            Err(e) => checks.push(DoctorCheck {
193                id: "config.overrides-file".to_string(),
194                subject: overrides_path.display().to_string(),
195                kind: None,
196                health: Health::Error,
197                message: format!("plugin-overrides.json failed to parse: {e}"),
198                hint: Some(
199                    "delete the corrupt file or fix the JSON; each value must be \"on\" or \"off\""
200                        .to_string(),
201                ),
202            }),
203        }
204    }
205
206    let all_clear = checks.iter().all(|c| c.health == Health::Healthy);
207
208    Ok(DoctorReport {
209        checks,
210        resolved_enabled: resolved,
211        all_clear,
212    })
213}
214
215fn compute_enabled_state(
216    name: &str,
217    kind: ResourceKind,
218    runtime: &RuntimeConfig,
219    overrides: &ProjectPluginOverrides,
220) -> (bool, EnabledSummary) {
221    let project_forced = overrides.forced_state(name, kind).map(|s| s.to_string());
222    let user_disabled = runtime.is_disabled(name, kind);
223    let enabled = resolve_enabled(name, kind, Some(overrides), Some(runtime));
224    (
225        enabled,
226        EnabledSummary {
227            package: name.to_string(),
228            kind,
229            enabled,
230            project_forced,
231            user_disabled,
232        },
233    )
234}
235
236fn check_manifest(manager: &PackageManager, name: &str, checks: &mut Vec<DoctorCheck>) {
237    let install_dir = manager.get_install_dir(name);
238    let Some(dir) = install_dir else {
239        checks.push(DoctorCheck {
240            id: format!("manifest.install-dir.{name}"),
241            subject: name.to_string(),
242            kind: None,
243            health: Health::Error,
244            message: format!("install directory for '{name}' is missing"),
245            hint: Some(format!(
246                "reinstall with `oxi pkg install` to restore '{name}'"
247            )),
248        });
249        return;
250    };
251    let manifest_path = dir.join(MANIFEST_NAME);
252    if !manifest_path.exists() {
253        checks.push(DoctorCheck {
254            id: format!("manifest.present.{name}"),
255            subject: name.to_string(),
256            kind: None,
257            health: Health::Error,
258            message: format!("no {MANIFEST_NAME} in {dir}", dir = dir.display()),
259            hint: Some(format!(
260                "the install dir for '{name}' lost its manifest — the lockfile entry is stale; reinstall"
261            )),
262        });
263        return;
264    }
265    match PackageManager::read_manifest_for_doctor(&manifest_path) {
266        Ok(_) => checks.push(DoctorCheck {
267            id: format!("manifest.parse.{name}"),
268            subject: name.to_string(),
269            kind: None,
270            health: Health::Healthy,
271            message: format!("{MANIFEST_NAME} parsed"),
272            hint: None,
273        }),
274        Err(e) => checks.push(DoctorCheck {
275            id: format!("manifest.parse.{name}"),
276            subject: name.to_string(),
277            kind: None,
278            health: Health::Error,
279            message: format!("{MANIFEST_NAME} failed to parse: {e}"),
280            hint: Some("inspect the file at ".to_string() + &manifest_path.display().to_string()),
281        }),
282    }
283}
284
285fn check_lockfile_integrity(manager: &PackageManager, name: &str, checks: &mut Vec<DoctorCheck>) {
286    let Some(install_dir) = manager.get_install_dir(name) else {
287        // Already covered by manifest.present / install-dir.
288        return;
289    };
290    let Some(expected) = manager
291        .lockfile()
292        .packages
293        .get(name)
294        .and_then(|e| e.integrity.clone())
295    else {
296        checks.push(DoctorCheck {
297            id: format!("integrity.coverage.{name}"),
298            subject: name.to_string(),
299            kind: None,
300            health: Health::Warning,
301            message: format!(
302                "'{name}' has no integrity hash in the lockfile (very old install) — \
303                 coverage gap; reinstall to enable integrity verification"
304            ),
305            hint: Some("reinstall to populate the lockfile integrity hash".to_string()),
306        });
307        return;
308    };
309    match verify_lockfile_integrity(&install_dir, &expected) {
310        Ok(()) => checks.push(DoctorCheck {
311            id: format!("integrity.match.{name}"),
312            subject: name.to_string(),
313            kind: None,
314            health: Health::Healthy,
315            message: "sha256 matches lockfile entry".to_string(),
316            hint: None,
317        }),
318        Err(reason) => checks.push(DoctorCheck {
319            id: format!("integrity.match.{name}"),
320            subject: name.to_string(),
321            kind: None,
322            health: Health::Error,
323            message: format!("sha256 mismatch: {reason}"),
324            hint: Some(format!(
325                "'{name}' on-disk contents diverge from the lockfile; \
326                 reinstall to refresh the integrity hash (verify the package source!)"
327            )),
328        }),
329    }
330}
331
332fn check_resources_present(manager: &PackageManager, name: &str, checks: &mut Vec<DoctorCheck>) {
333    let discovered = match manager.discover_resources(name) {
334        Ok(d) => d,
335        Err(_) => return, // already covered upstream
336    };
337    if discovered.is_empty() {
338        checks.push(DoctorCheck {
339            id: format!("resources.empty.{name}"),
340            subject: name.to_string(),
341            kind: None,
342            health: Health::Warning,
343            message: format!(
344                "'{name}' has no discoverable resources (manifest may be empty and \
345                 auto-discovery found nothing)"
346            ),
347            hint: Some("verify the package manifest and on-disk directory layout".to_string()),
348        });
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use crate::storage::packages::types::PackageManifest;
356    use std::collections::BTreeMap;
357    use std::fs;
358
359    fn fresh() -> (tempfile::TempDir, std::path::PathBuf) {
360        let tmp = tempfile::tempdir().unwrap();
361        let packages_dir = tmp.path().join("packages");
362        fs::create_dir_all(&packages_dir).unwrap();
363        let runtime = tmp.path().join("runtime.json");
364        let overrides = tmp.path().join("plugin-overrides.json");
365        // Touch both files as empty so the "file exists" branch fires —
366        // we *want* the parse-failure branch to be exercised too,
367        // but the happy path here is "missing files => no extra checks".
368        let _ = fs::write(&runtime, "{}");
369        let _ = fs::write(&overrides, "{}");
370        (tmp, packages_dir)
371    }
372
373    fn create_pkg(base: &std::path::Path, name: &str, version: &str) -> std::path::PathBuf {
374        let pkg_dir = base.join("source-pkg");
375        fs::create_dir_all(&pkg_dir).unwrap();
376        let manifest = PackageManifest {
377            name: name.to_string(),
378            version: version.to_string(),
379            extensions: vec!["ext1.so".to_string()],
380            skills: vec!["skill-a".to_string()],
381            prompts: vec![],
382            themes: vec![],
383            description: None,
384            dependencies: BTreeMap::new(),
385        };
386        fs::write(
387            pkg_dir.join(MANIFEST_NAME),
388            toml::to_string_pretty(&manifest).unwrap(),
389        )
390        .unwrap();
391        fs::write(pkg_dir.join("ext1.so"), b"fake extension").unwrap();
392        fs::create_dir_all(pkg_dir.join("skill-a")).unwrap();
393        fs::write(pkg_dir.join("skill-a").join("SKILL.md"), b"# Skill A").unwrap();
394        pkg_dir
395    }
396
397    #[test]
398    fn doctor_reports_healthy_for_well_formed_package() {
399        let (tmp, packages_dir) = fresh();
400        let pkg_dir = create_pkg(tmp.path(), "healthy-pkg", "1.0.0");
401        let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
402        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
403
404        let runtime = tmp.path().join("runtime.json");
405        let report = run(
406            &mgr,
407            &runtime,
408            &ProjectPluginOverrides::project_path(tmp.path()),
409        )
410        .unwrap();
411        assert!(
412            report.all_clear,
413            "expected all_clear, got errors: {report:?}"
414        );
415        // Every (package, kind) gets a healthy enabled-summary row.
416        assert!(
417            report
418                .resolved_enabled
419                .iter()
420                .any(|s| s.package == "healthy-pkg"
421                    && s.kind == ResourceKind::Extension
422                    && s.enabled),
423            "missing enabled row: {report:?}"
424        );
425    }
426
427    #[test]
428    fn doctor_flags_missing_install_dir_after_uninstall() {
429        let (tmp, packages_dir) = fresh();
430        let pkg_dir = create_pkg(tmp.path(), "ghost-pkg", "1.0.0");
431        let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
432        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
433        // Wipe the install dir out from under the manager.
434        fs::remove_dir_all(packages_dir.join("ghost-pkg")).unwrap();
435
436        let runtime = tmp.path().join("runtime.json");
437        let report = run(
438            &mgr,
439            &runtime,
440            &ProjectPluginOverrides::project_path(tmp.path()),
441        )
442        .unwrap();
443        assert!(
444            report
445                .checks
446                .iter()
447                .any(|c| c.health == Health::Error && c.id.contains("manifest.install-dir")),
448            "expected install-dir error; got {:?}",
449            report.checks
450        );
451        assert!(!report.all_clear);
452    }
453
454    #[test]
455    fn doctor_flags_integrity_mismatch_when_manifest_tampered() {
456        let (tmp, packages_dir) = fresh();
457        let pkg_dir = create_pkg(tmp.path(), "tamper-pkg", "1.0.0");
458        let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
459        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
460        // Tamper with the manifest on disk.
461        fs::write(
462            packages_dir.join("tamper-pkg").join(MANIFEST_NAME),
463            "tampered = true\n",
464        )
465        .unwrap();
466
467        let runtime = tmp.path().join("runtime.json");
468        let report = run(
469            &mgr,
470            &runtime,
471            &ProjectPluginOverrides::project_path(tmp.path()),
472        )
473        .unwrap();
474        // After tampering, load_installed drops it -> check_manifest's
475        // install_dir returns Some(dir still) until next reload. Here
476        // we're checking directly without reload, so install dir still
477        // exists and the parse check will catch it (manifest is unparseable).
478        let has_error = report
479            .checks
480            .iter()
481            .any(|c| c.health == Health::Error && c.subject == "tamper-pkg");
482        assert!(has_error, "expected an error on tamper-pkg: {report:?}");
483        assert!(!report.all_clear);
484    }
485
486    #[test]
487    fn doctor_flags_corrupt_runtime_file() {
488        let (tmp, packages_dir) = fresh();
489        let runtime = tmp.path().join("runtime.json");
490        fs::write(&runtime, b"{ not json").unwrap();
491
492        let mgr = PackageManager::with_dir(packages_dir).unwrap();
493        let report = run(
494            &mgr,
495            &runtime,
496            &ProjectPluginOverrides::project_path(tmp.path()),
497        )
498        .unwrap();
499        assert!(
500            report
501                .checks
502                .iter()
503                .any(|c| c.id == "config.runtime-file" && c.health == Health::Error),
504            "expected runtime-file error row"
505        );
506        assert!(!report.all_clear);
507    }
508
509    #[test]
510    fn doctor_flags_corrupt_overrides_file() {
511        let (tmp, packages_dir) = fresh();
512        let overrides = ProjectPluginOverrides::project_path(tmp.path());
513        fs::create_dir_all(overrides.parent().unwrap()).unwrap();
514        fs::write(&overrides, b"{ broken").unwrap();
515        let mgr = PackageManager::with_dir(packages_dir).unwrap();
516        // Point at a *non-existent* runtime so we don't conflate.
517        let missing_runtime = tmp.path().join("nope.json");
518        let report = run(&mgr, &missing_runtime, &overrides).unwrap();
519        assert!(
520            report
521                .checks
522                .iter()
523                .any(|c| c.id == "config.overrides-file" && c.health == Health::Error),
524            "expected overrides-file error row"
525        );
526        assert!(!report.all_clear);
527    }
528
529    #[test]
530    fn doctor_summary_records_project_force_label() {
531        let (tmp, packages_dir) = fresh();
532        let pkg_dir = create_pkg(tmp.path(), "forceme", "1.0.0");
533        let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
534        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
535
536        // Project overrides force an Off on skill — verify it shows up.
537        let overrides_path = ProjectPluginOverrides::project_path(tmp.path());
538        fs::create_dir_all(overrides_path.parent().unwrap()).unwrap();
539        let mut o = ProjectPluginOverrides::new();
540        o.force_off("forceme", ResourceKind::Skill);
541        o.write(&overrides_path).unwrap();
542
543        let runtime = tmp.path().join("runtime.json");
544        let report = run(&mgr, &runtime, &overrides_path).unwrap();
545        let row = report
546            .resolved_enabled
547            .iter()
548            .find(|s| s.package == "forceme" && s.kind == ResourceKind::Skill)
549            .expect("summary row missing");
550        assert!(!row.enabled, "project force Off should win");
551        assert_eq!(row.project_forced.as_deref(), Some("off"));
552    }
553}