1pub mod coverage;
8pub mod probes;
9pub mod project;
10pub mod skill_surface;
11pub mod upgrade;
12pub mod version;
13pub mod version_alignment;
14
15use crate::install::link_map::{LinkMap, LinkMapError};
16use crate::install::overlay::{self, LinkMapOverlay, OverlaySummary};
17use crate::install::plan::{InstallPlan, PlanError};
18use crate::render::manifest::{ProductRoot, RuntimeRootsManifest, SCHEMA_VERSION};
19use serde::Serialize;
20use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22use thiserror::Error;
23
24#[derive(Debug, Clone)]
25pub struct DoctorOptions {
26 pub overlay_enabled: bool,
27 pub overlay_path: Option<PathBuf>,
28 pub cli_tools_profile: String,
29 pub check_project: Option<PathBuf>,
30 pub class_filter: Option<DoctorClass>,
31 pub pin_path: Option<PathBuf>,
32}
33
34impl Default for DoctorOptions {
35 fn default() -> Self {
36 Self {
37 overlay_enabled: true,
38 overlay_path: None,
39 cli_tools_profile: "recommended".to_string(),
40 check_project: None,
41 class_filter: None,
42 pin_path: None,
43 }
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum DoctorClass {
49 SkillSurface,
50 VersionAlignment,
51}
52
53#[derive(Debug, Error)]
54pub enum DoctorError {
55 #[error("runtime-roots: {0}")]
56 RuntimeRoots(#[from] RuntimeRootsError),
57 #[error("link-map: {0}")]
58 LinkMap(#[from] LinkMapError),
59 #[error("plan: {0}")]
60 Plan(#[from] PlanError),
61 #[error("coverage: {0}")]
62 Coverage(#[from] coverage::CoverageError),
63 #[error("version-alignment: {0}")]
64 VersionAlignment(#[from] version_alignment::VersionAlignmentError),
65 #[error("unknown product `{product}`; expected `codex`, `claude`, or `hermes`")]
66 UnknownProduct { product: String },
67}
68
69#[derive(Debug, Error)]
70pub enum RuntimeRootsError {
71 #[error("missing runtime-roots manifest: {path}")]
72 Missing { path: PathBuf },
73 #[error("schema_version mismatch in {file}: expected {expected}, got {found}")]
74 SchemaVersion {
75 file: PathBuf,
76 expected: u32,
77 found: u32,
78 },
79 #[error("parse error in {file}: {source}")]
80 Parse {
81 file: PathBuf,
82 #[source]
83 source: serde_yaml_ng::Error,
84 },
85 #[error("io error reading {file}: {source}")]
86 Io {
87 file: PathBuf,
88 #[source]
89 source: std::io::Error,
90 },
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
94#[serde(rename_all = "lowercase")]
95pub enum DoctorSeverity {
96 Ok,
97 Warn,
98 Block,
99}
100
101impl DoctorSeverity {
102 pub fn exit_code(self) -> u8 {
103 match self {
104 DoctorSeverity::Ok => 0,
105 DoctorSeverity::Warn => 1,
106 DoctorSeverity::Block => 2,
107 }
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
112pub struct DoctorFinding {
113 pub product: String,
114 pub check: &'static str,
115 pub severity: DoctorSeverity,
116 pub entry_id: Option<String>,
117 pub path: Option<PathBuf>,
118 pub message: String,
119}
120
121impl DoctorFinding {
122 pub fn warn(
123 product: &str,
124 check: &'static str,
125 entry_id: Option<String>,
126 path: Option<PathBuf>,
127 message: impl Into<String>,
128 ) -> Self {
129 Self {
130 product: product.to_string(),
131 check,
132 severity: DoctorSeverity::Warn,
133 entry_id,
134 path,
135 message: message.into(),
136 }
137 }
138
139 pub fn block(
140 product: &str,
141 check: &'static str,
142 entry_id: Option<String>,
143 path: Option<PathBuf>,
144 message: impl Into<String>,
145 ) -> Self {
146 Self {
147 product: product.to_string(),
148 check,
149 severity: DoctorSeverity::Block,
150 entry_id,
151 path,
152 message: message.into(),
153 }
154 }
155}
156
157#[derive(Debug, Clone)]
158pub struct ResolvedRuntimeRoots {
159 pub product: String,
160 pub live_home: PathBuf,
161 pub docs_home: PathBuf,
162 pub state_home: PathBuf,
163 pub plugin_root: Option<PathBuf>,
164}
165
166#[derive(Debug)]
167pub struct DoctorOutcome {
168 pub product: String,
169 pub findings: Vec<DoctorFinding>,
170 pub version_probes: Vec<version::VersionProbeFinding>,
171 pub coverage_probes: Vec<coverage::CoverageFinding>,
172 pub project_probes: Vec<project::ProjectOverlayFinding>,
173 pub skill_surface: Option<skill_surface::SkillSurfaceReport>,
174 pub version_alignment: Option<version_alignment::VersionAlignmentReport>,
175 pub acceptance_boundary: Option<String>,
176 pub ok: usize,
177 pub warn: usize,
178 pub block: usize,
179 pub overlay: Option<OverlaySummary>,
180}
181
182impl DoctorOutcome {
183 pub fn total_checks(&self) -> usize {
184 self.ok + self.warn + self.block
185 }
186
187 pub fn exit_code(&self) -> u8 {
188 if self.block > 0 {
189 DoctorSeverity::Block.exit_code()
190 } else if self.warn > 0 {
191 DoctorSeverity::Warn.exit_code()
192 } else {
193 DoctorSeverity::Ok.exit_code()
194 }
195 }
196}
197
198pub fn run(
199 product: &str,
200 source_root: &Path,
201 live_home_override: Option<&Path>,
202 state_home_override: Option<&Path>,
203 options: &DoctorOptions,
204) -> Result<DoctorOutcome, DoctorError> {
205 if matches!(options.class_filter, Some(DoctorClass::SkillSurface)) {
206 return run_skill_surface_only(product, source_root, options);
207 }
208 if matches!(options.class_filter, Some(DoctorClass::VersionAlignment)) {
209 return run_version_alignment_only(options);
210 }
211
212 let runtime_roots = load_runtime_roots(source_root)?;
213 let product_root = product_root(&runtime_roots, product)?;
214 let resolved_roots = resolve_runtime_roots(
215 product,
216 product_root,
217 live_home_override,
218 state_home_override,
219 );
220
221 let mut link_map = LinkMap::load(source_root, product)?;
222 let mut overlay_summary = None;
223 if options.overlay_enabled {
224 let overlay_opt = match options.overlay_path.as_deref() {
225 Some(path) => LinkMapOverlay::load_from(path)?,
226 None => LinkMapOverlay::load_optional(source_root)?,
227 };
228 if let Some(overlay) = overlay_opt {
229 let summary = overlay::apply(&mut link_map, &overlay)?;
230 overlay_summary = Some(summary);
231 }
232 }
233
234 let plan = InstallPlan::build(
235 product,
236 source_root,
237 &resolved_roots.live_home,
238 &resolved_roots.state_home,
239 &link_map,
240 )?;
241
242 let mut report = probes::ProbeReport::default();
243 report.extend(probes::runtime_roots(&resolved_roots));
244 report.extend(probes::install_plan(product, &plan));
245 let version_probe = version::probe_product(product, product_root);
246 match version_probe.severity {
247 DoctorSeverity::Ok => report.ok += 1,
248 DoctorSeverity::Warn | DoctorSeverity::Block => {
249 report.findings.push(version_probe.to_doctor_finding());
250 }
251 }
252 let coverage_probes = coverage::probe(source_root, &options.cli_tools_profile)?;
253 for probe in &coverage_probes {
254 match probe.severity {
255 DoctorSeverity::Ok => report.ok += 1,
256 DoctorSeverity::Warn | DoctorSeverity::Block => {
257 report.findings.push(probe.to_doctor_finding(product));
258 }
259 }
260 }
261 let project_probes = options
262 .check_project
263 .as_deref()
264 .map(project::probe_project)
265 .unwrap_or_default();
266 for probe in &project_probes {
267 match probe.severity {
268 DoctorSeverity::Ok => report.ok += 1,
269 DoctorSeverity::Warn | DoctorSeverity::Block => {
270 report.findings.push(probe.to_doctor_finding(product));
271 }
272 }
273 }
274
275 let warn = report
276 .findings
277 .iter()
278 .filter(|f| f.severity == DoctorSeverity::Warn)
279 .count();
280 let block = report
281 .findings
282 .iter()
283 .filter(|f| f.severity == DoctorSeverity::Block)
284 .count();
285
286 Ok(DoctorOutcome {
287 product: product.to_string(),
288 findings: report.findings,
289 version_probes: vec![version_probe],
290 coverage_probes,
291 project_probes,
292 skill_surface: None,
293 version_alignment: None,
294 acceptance_boundary: None,
295 ok: report.ok,
296 warn,
297 block,
298 overlay: overlay_summary,
299 })
300}
301
302pub fn resolve_runtime_roots_for_product(
303 product: &str,
304 source_root: &Path,
305 live_home_override: Option<&Path>,
306 state_home_override: Option<&Path>,
307) -> Result<ResolvedRuntimeRoots, DoctorError> {
308 let runtime_roots = load_runtime_roots(source_root)?;
309 let product_root = product_root(&runtime_roots, product)?;
310 Ok(resolve_runtime_roots(
311 product,
312 product_root,
313 live_home_override,
314 state_home_override,
315 ))
316}
317
318fn run_skill_surface_only(
319 product: &str,
320 source_root: &Path,
321 options: &DoctorOptions,
322) -> Result<DoctorOutcome, DoctorError> {
323 let link_map = match load_effective_link_map(source_root, product, options)? {
324 Some(link_map) => link_map,
325 None => {
326 return Ok(DoctorOutcome {
327 product: product.to_string(),
328 findings: Vec::new(),
329 version_probes: Vec::new(),
330 coverage_probes: Vec::new(),
331 project_probes: Vec::new(),
332 skill_surface: Some(skill_surface::SkillSurfaceReport::empty(product)),
333 version_alignment: None,
334 acceptance_boundary: skill_surface::acceptance_boundary(product)
335 .map(str::to_string),
336 ok: 0,
337 warn: 0,
338 block: 0,
339 overlay: None,
340 });
341 }
342 };
343 let report = skill_surface::check(product, source_root, &link_map);
344 let warn = report
345 .findings
346 .iter()
347 .filter(|finding| finding.severity == DoctorSeverity::Warn)
348 .count();
349 let block = report
350 .findings
351 .iter()
352 .filter(|finding| finding.severity == DoctorSeverity::Block)
353 .count();
354 let ok = report
355 .items
356 .iter()
357 .filter(|item| item.warnings.is_empty())
358 .count();
359 let acceptance_boundary = report.acceptance_boundary.clone();
360 Ok(DoctorOutcome {
361 product: product.to_string(),
362 findings: report.findings.clone(),
363 version_probes: Vec::new(),
364 coverage_probes: Vec::new(),
365 project_probes: Vec::new(),
366 skill_surface: Some(report),
367 version_alignment: None,
368 acceptance_boundary,
369 ok,
370 warn,
371 block,
372 overlay: None,
373 })
374}
375
376fn run_version_alignment_only(options: &DoctorOptions) -> Result<DoctorOutcome, DoctorError> {
377 let Some(pin_path) = options.pin_path.as_deref() else {
378 return Err(DoctorError::VersionAlignment(
379 version_alignment::VersionAlignmentError::MissingPin,
380 ));
381 };
382 let report = version_alignment::check(pin_path, env!("CARGO_PKG_VERSION"))?;
385 let ok = report
386 .items
387 .iter()
388 .filter(|item| item.severity == DoctorSeverity::Ok)
389 .count();
390 let warn = report
391 .items
392 .iter()
393 .filter(|item| item.severity == DoctorSeverity::Warn)
394 .count();
395 let block = report
396 .items
397 .iter()
398 .filter(|item| item.severity == DoctorSeverity::Block)
399 .count();
400 let acceptance_boundary = report.acceptance_boundary.clone();
401 Ok(DoctorOutcome {
402 product: "host".to_string(),
403 findings: report.findings.clone(),
404 version_probes: Vec::new(),
405 coverage_probes: Vec::new(),
406 project_probes: Vec::new(),
407 skill_surface: None,
408 version_alignment: Some(report),
409 acceptance_boundary,
410 ok,
411 warn,
412 block,
413 overlay: None,
414 })
415}
416
417fn load_effective_link_map(
418 source_root: &Path,
419 product: &str,
420 options: &DoctorOptions,
421) -> Result<Option<LinkMap>, DoctorError> {
422 let mut link_map = match LinkMap::load(source_root, product) {
423 Ok(link_map) => link_map,
424 Err(LinkMapError::Missing { .. }) => return Ok(None),
425 Err(err) => return Err(err.into()),
426 };
427 if options.overlay_enabled {
428 let overlay_opt = match options.overlay_path.as_deref() {
429 Some(path) => LinkMapOverlay::load_from(path)?,
430 None => LinkMapOverlay::load_optional(source_root)?,
431 };
432 if let Some(overlay) = overlay_opt {
433 overlay::apply(&mut link_map, &overlay)?;
434 }
435 }
436 Ok(Some(link_map))
437}
438
439fn load_runtime_roots(source_root: &Path) -> Result<RuntimeRootsManifest, RuntimeRootsError> {
440 let file = source_root.join("manifests").join("runtime-roots.yaml");
441 if !file.exists() {
442 return Err(RuntimeRootsError::Missing { path: file });
443 }
444 let raw = std::fs::read_to_string(&file).map_err(|source| RuntimeRootsError::Io {
445 file: file.clone(),
446 source,
447 })?;
448 let parsed: RuntimeRootsManifest =
449 serde_yaml_ng::from_str(&raw).map_err(|source| RuntimeRootsError::Parse {
450 file: file.clone(),
451 source,
452 })?;
453 if parsed.schema_version != SCHEMA_VERSION {
454 return Err(RuntimeRootsError::SchemaVersion {
455 file,
456 expected: SCHEMA_VERSION,
457 found: parsed.schema_version,
458 });
459 }
460 Ok(parsed)
461}
462
463fn product_root<'a>(
464 runtime_roots: &'a RuntimeRootsManifest,
465 product: &str,
466) -> Result<&'a ProductRoot, DoctorError> {
467 match product {
468 "codex" => Ok(&runtime_roots.products.codex),
469 "claude" => Ok(&runtime_roots.products.claude),
470 "hermes" => Ok(&runtime_roots.products.hermes),
471 other => Err(DoctorError::UnknownProduct {
472 product: other.to_string(),
473 }),
474 }
475}
476
477fn resolve_runtime_roots(
478 product: &str,
479 root: &ProductRoot,
480 live_home_override: Option<&Path>,
481 state_home_override: Option<&Path>,
482) -> ResolvedRuntimeRoots {
483 let env: BTreeMap<String, String> = std::env::vars().collect();
484 resolve_runtime_roots_with_env(product, root, live_home_override, state_home_override, env)
485}
486
487fn resolve_runtime_roots_with_env(
488 product: &str,
489 root: &ProductRoot,
490 live_home_override: Option<&Path>,
491 state_home_override: Option<&Path>,
492 mut env: BTreeMap<String, String>,
493) -> ResolvedRuntimeRoots {
494 if let Some(live_home) = live_home_override
495 && product == "codex"
496 {
497 env.insert(
498 "CODEX_HOME".to_string(),
499 live_home.to_string_lossy().into_owned(),
500 );
501 }
502 if let Some(live_home) = live_home_override
503 && product == "hermes"
504 {
505 env.insert(
506 "HERMES_HOME".to_string(),
507 live_home.to_string_lossy().into_owned(),
508 );
509 }
510
511 let live_home = live_home_override
512 .map(Path::to_path_buf)
513 .unwrap_or_else(|| PathBuf::from(expand_env_vars(&root.live_home, &env)));
514 let docs_home = resolve_product_path(product, &root.docs_home, live_home_override, &env);
515 let state_home = state_home_override
516 .map(Path::to_path_buf)
517 .unwrap_or_else(|| PathBuf::from(expand_env_vars(&root.state_home, &env)));
518 let plugin_root = resolve_plugin_root(product, root, live_home_override, &env);
519
520 ResolvedRuntimeRoots {
521 product: product.to_string(),
522 live_home,
523 docs_home,
524 state_home,
525 plugin_root,
526 }
527}
528
529fn resolve_plugin_root(
530 product: &str,
531 root: &ProductRoot,
532 live_home_override: Option<&Path>,
533 env: &BTreeMap<String, String>,
534) -> Option<PathBuf> {
535 if let Some(raw) = root.plugin_root.as_deref() {
536 return Some(resolve_product_path(product, raw, live_home_override, env));
537 }
538 let name = root.plugin_root_env.as_deref()?;
539 let value = env.get(name)?;
540 if value.is_empty() {
541 None
542 } else {
543 Some(PathBuf::from(value))
544 }
545}
546
547fn resolve_product_path(
548 product: &str,
549 raw: &str,
550 live_home_override: Option<&Path>,
551 env: &BTreeMap<String, String>,
552) -> PathBuf {
553 if let Some(live_home) = live_home_override {
554 if product == "claude" {
555 if raw == "$HOME/.claude" {
556 return live_home.to_path_buf();
557 }
558 if let Some(rest) = raw.strip_prefix("$HOME/.claude/") {
559 return live_home.join(rest);
560 }
561 }
562 if product == "codex" {
563 if raw == "$CODEX_HOME" {
564 return live_home.to_path_buf();
565 }
566 if let Some(rest) = raw.strip_prefix("$CODEX_HOME/") {
567 return live_home.join(rest);
568 }
569 }
570 if product == "hermes" {
571 if raw == "$HOME/.hermes" {
572 return live_home.to_path_buf();
573 }
574 if let Some(rest) = raw.strip_prefix("$HOME/.hermes/") {
575 return live_home.join(rest);
576 }
577 }
578 }
579 PathBuf::from(expand_env_vars(raw, env))
580}
581
582fn expand_env_vars(raw: &str, env: &BTreeMap<String, String>) -> String {
583 let chars: Vec<char> = raw.chars().collect();
584 let mut out = String::new();
585 let mut i = 0;
586 while i < chars.len() {
587 if chars[i] != '$' {
588 out.push(chars[i]);
589 i += 1;
590 continue;
591 }
592 if chars.get(i + 1) == Some(&'{')
593 && let Some(end) = find_matching_brace(&chars, i + 1)
594 {
595 let expr: String = chars[i + 2..end].iter().collect();
596 out.push_str(&expand_braced_expr(&expr, env));
597 i = end + 1;
598 continue;
599 }
600 let mut end = i + 1;
601 while end < chars.len() && (chars[end].is_ascii_alphanumeric() || chars[end] == '_') {
602 end += 1;
603 }
604 if end == i + 1 {
605 out.push('$');
606 i += 1;
607 continue;
608 }
609 let name: String = chars[i + 1..end].iter().collect();
610 out.push_str(env.get(&name).map(String::as_str).unwrap_or(""));
611 i = end;
612 }
613 out
614}
615
616fn find_matching_brace(chars: &[char], open_brace: usize) -> Option<usize> {
617 let mut depth = 0usize;
618 let mut i = open_brace + 1;
619 while i < chars.len() {
620 if chars[i] == '$' && chars.get(i + 1) == Some(&'{') {
621 depth += 1;
622 i += 2;
623 continue;
624 }
625 if chars[i] == '}' {
626 if depth == 0 {
627 return Some(i);
628 }
629 depth -= 1;
630 }
631 i += 1;
632 }
633 None
634}
635
636fn expand_braced_expr(expr: &str, env: &BTreeMap<String, String>) -> String {
637 if let Some((name, fallback)) = expr.split_once(":-") {
638 if let Some(value) = env.get(name)
639 && !value.is_empty()
640 {
641 return value.clone();
642 }
643 expand_env_vars(fallback, env)
644 } else {
645 env.get(expr).cloned().unwrap_or_default()
646 }
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652
653 #[test]
654 fn env_expansion_supports_nested_default_values() {
655 let mut env = BTreeMap::new();
656 env.insert("HOME".to_string(), "/tmp/home".to_string());
657 assert_eq!(
658 expand_env_vars("${CODEX_AGENT_STATE_HOME:-$HOME/.local/state}", &env),
659 "/tmp/home/.local/state"
660 );
661 }
662
663 #[test]
664 fn env_expansion_supports_nested_braced_default_values() {
665 let mut env = BTreeMap::new();
666 env.insert("HOME".to_string(), "/tmp/home".to_string());
667 assert_eq!(
668 expand_env_vars(
669 "${CODEX_AGENT_STATE_HOME:-${XDG_STATE_HOME:-$HOME/.local/state}/agent-runtime-kit/codex}",
670 &env
671 ),
672 "/tmp/home/.local/state/agent-runtime-kit/codex"
673 );
674
675 env.insert("XDG_STATE_HOME".to_string(), "/tmp/state".to_string());
676 assert_eq!(
677 expand_env_vars(
678 "${CODEX_AGENT_STATE_HOME:-${XDG_STATE_HOME:-$HOME/.local/state}/agent-runtime-kit/codex}",
679 &env
680 ),
681 "/tmp/state/agent-runtime-kit/codex"
682 );
683
684 env.insert(
685 "CODEX_AGENT_STATE_HOME".to_string(),
686 "/tmp/codex-state".to_string(),
687 );
688 assert_eq!(
689 expand_env_vars(
690 "${CODEX_AGENT_STATE_HOME:-${XDG_STATE_HOME:-$HOME/.local/state}/agent-runtime-kit/codex}",
691 &env
692 ),
693 "/tmp/codex-state"
694 );
695 }
696
697 #[test]
698 fn runtime_root_resolution_uses_plugin_root_env_when_set() {
699 let root = ProductRoot {
700 live_home: "$HOME/.claude".to_string(),
701 docs_home: "$HOME/.claude".to_string(),
702 state_home: "$HOME/.local/state/agent-runtime-kit/claude".to_string(),
703 plugin_root: None,
704 plugin_root_env: Some("CLAUDE_PLUGIN_ROOT".to_string()),
705 hook_config_strategy: None,
706 min_version: "0.0.0".to_string(),
707 recommended_version: "0.0.0".to_string(),
708 min_version_effective_from: "2099-01-01".to_string(),
709 version_probe: "claude --version".to_string(),
710 };
711 let mut env = BTreeMap::new();
712 env.insert("HOME".to_string(), "/tmp/home".to_string());
713 env.insert(
714 "CLAUDE_PLUGIN_ROOT".to_string(),
715 "/tmp/claude-plugin".to_string(),
716 );
717
718 let resolved = resolve_runtime_roots_with_env("claude", &root, None, None, env);
719
720 assert_eq!(
721 resolved.plugin_root,
722 Some(PathBuf::from("/tmp/claude-plugin"))
723 );
724 }
725}