1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum Health {
37 Healthy,
39 Warning,
42 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#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct DoctorCheck {
60 pub id: String,
62 pub subject: String,
64 pub kind: Option<ResourceKind>,
66 pub health: Health,
68 pub message: String,
70 pub hint: Option<String>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct DoctorReport {
78 pub checks: Vec<DoctorCheck>,
80 pub resolved_enabled: Vec<EnabledSummary>,
83 pub all_clear: bool,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct EnabledSummary {
90 pub package: String,
92 pub kind: ResourceKind,
94 pub enabled: bool,
96 pub project_forced: Option<String>,
98 pub user_disabled: bool,
100}
101
102pub 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 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 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 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 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, };
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 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 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 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 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 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 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 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}