1use std::path::PathBuf;
2
3use serde::Serialize;
4
5use crate::check::WorktreeSnapshot;
6use crate::config::RuntimeOptionOverrides;
7use crate::context;
8use crate::{ActionPlan, Config, InitScriptDiscovery, WorktreeOptions};
9
10#[derive(Debug, Clone, Default, PartialEq, Eq)]
12pub struct DoctorOptions {
13 pub cwd: Option<PathBuf>,
15 pub root: Option<PathBuf>,
17 pub config: Option<PathBuf>,
19 pub no_init_script: bool,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
25#[serde(rename_all = "snake_case")]
26pub enum DiagnosticStatus {
27 Ok,
29 Warning,
31 Error,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
37pub struct Diagnostic {
38 pub name: &'static str,
40 pub status: DiagnosticStatus,
42 pub message: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
48pub struct DoctorReport {
49 pub fatal: bool,
51 pub context: Option<WorktreeSnapshot>,
53 pub diagnostics: Vec<Diagnostic>,
55}
56
57impl DoctorReport {
58 #[must_use]
60 pub fn has_fatal(&self) -> bool {
61 self.fatal
62 }
63}
64
65#[must_use]
67pub fn diagnose(options: DoctorOptions) -> DoctorReport {
68 let mut diagnostics = Vec::new();
69 let mut fatal = false;
70
71 let env_options = match RuntimeOptionOverrides::from_env() {
72 Ok(options) => {
73 diagnostics.push(ok("environment_options", "environment options are valid"));
74 options
75 }
76 Err(error) => {
77 diagnostics.push(error_diag("environment_options", error.to_string()));
78 return DoctorReport {
79 fatal: true,
80 context: None,
81 diagnostics,
82 };
83 }
84 };
85
86 let context = match context::resolve(&WorktreeOptions {
87 cwd: options.cwd.clone(),
88 root: options.root.clone(),
89 }) {
90 Ok(context) => {
91 diagnostics.push(ok("worktree", "worktree context resolved"));
92 diagnostics.push(ok("root", "root checkout resolved"));
93 if context.default_branch.is_empty() {
94 diagnostics.push(warning("default_branch", "default branch unknown"));
95 } else {
96 diagnostics.push(ok("default_branch", "default branch resolved"));
97 }
98 diagnostics.push(ok("environment", "child environment built"));
99 context
100 }
101 Err(error) => {
102 diagnostics.push(error_diag("worktree", error.to_string()));
103 return DoctorReport {
104 fatal: true,
105 context: None,
106 diagnostics,
107 };
108 }
109 };
110 let context_snapshot = WorktreeSnapshot::from(&context);
111
112 if !options.no_init_script && options.config.is_none() {
113 let scripts = InitScriptDiscovery::discover(&context);
114 if let Some(path) = scripts.executable {
115 diagnostics.push(ok(
116 "init_script",
117 format!("executable init script found: {}", path.display()),
118 ));
119 } else if scripts.ignored.is_empty() {
120 diagnostics.push(warning("init_script", "no executable init script found"));
121 } else {
122 diagnostics.push(warning(
123 "init_script",
124 format!(
125 "no executable init script found; ignored {} non-executable path(s)",
126 scripts.ignored.len()
127 ),
128 ));
129 }
130 } else {
131 diagnostics.push(ok("init_script", "init script discovery skipped"));
132 }
133
134 match check_config(&options, &context, env_options) {
135 Ok(diagnostic) => diagnostics.push(diagnostic),
136 Err(diagnostic) => {
137 fatal = true;
138 diagnostics.push(diagnostic);
139 }
140 }
141
142 DoctorReport {
143 fatal,
144 context: Some(context_snapshot),
145 diagnostics,
146 }
147}
148
149fn check_config(
150 options: &DoctorOptions,
151 context: &crate::Worktree,
152 env_options: RuntimeOptionOverrides,
153) -> std::result::Result<Diagnostic, Diagnostic> {
154 let path = Config::discover_path(context, options.config.as_deref())
155 .map_err(|error| error_diag("config", error.to_string()))?;
156
157 let Some(path) = path else {
158 return Ok(warning("config", "no config detected"));
159 };
160
161 let config =
162 Config::load(&path, context).map_err(|error| error_diag("config", error.to_string()))?;
163 let plan_options = env_options.resolve(&config.options, false);
164 ActionPlan::from_manifest(&path, &config, context, plan_options.into())
165 .map_err(|error| error_diag("config_validation", error.to_string()))?;
166
167 Ok(ok("config", format!("config is valid: {}", path.display())))
168}
169
170fn ok(name: &'static str, message: impl Into<String>) -> Diagnostic {
171 Diagnostic {
172 name,
173 status: DiagnosticStatus::Ok,
174 message: message.into(),
175 }
176}
177
178fn warning(name: &'static str, message: impl Into<String>) -> Diagnostic {
179 Diagnostic {
180 name,
181 status: DiagnosticStatus::Warning,
182 message: message.into(),
183 }
184}
185
186fn error_diag(name: &'static str, message: impl Into<String>) -> Diagnostic {
187 Diagnostic {
188 name,
189 status: DiagnosticStatus::Error,
190 message: message.into(),
191 }
192}