1use std::path::PathBuf;
2
3use serde::Serialize;
4
5use crate::check::WorktreeSnapshot;
6use crate::context;
7use crate::{
8 ActionPlan, Config, EnvironmentInput, InitScriptDiscovery, RuntimePolicy, WorktreeOptions,
9};
10
11#[derive(Debug, Clone, Default, PartialEq, Eq)]
13pub struct DoctorOptions {
14 pub cwd: Option<PathBuf>,
16 pub root: Option<PathBuf>,
18 pub environment: EnvironmentInput,
20 pub config: Option<PathBuf>,
22 pub no_init_script: bool,
24 pub strict: bool,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
31#[serde(rename_all = "snake_case")]
32pub enum DiagnosticStatus {
33 Ok,
35 Warning,
37 Error,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
43pub struct Diagnostic {
44 pub name: &'static str,
46 pub status: DiagnosticStatus,
48 pub message: String,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
54pub struct DoctorReport {
55 pub fatal: bool,
57 pub context: Option<WorktreeSnapshot>,
59 pub diagnostics: Vec<Diagnostic>,
61}
62
63impl DoctorReport {
64 #[must_use]
66 pub fn has_fatal(&self) -> bool {
67 self.fatal
68 }
69}
70
71#[must_use]
73pub fn diagnose(options: DoctorOptions) -> DoctorReport {
74 let mut diagnostics = Vec::new();
75 let mut fatal = false;
76
77 let runtime_policy = match RuntimePolicy::from_environment(&options.environment, options.strict)
78 {
79 Ok(policy) => {
80 diagnostics.push(ok("environment_options", "environment options are valid"));
81 policy
82 }
83 Err(error) => {
84 diagnostics.push(error_diag("environment_options", error.to_string()));
85 return DoctorReport {
86 fatal: true,
87 context: None,
88 diagnostics,
89 };
90 }
91 };
92
93 let context = match context::resolve(&WorktreeOptions {
94 cwd: options.cwd.clone(),
95 root: options.root.clone(),
96 environment: options.environment.clone(),
97 }) {
98 Ok(context) => {
99 diagnostics.push(ok("worktree", "worktree context resolved"));
100 diagnostics.push(ok("root", "root checkout resolved"));
101 if context.default_branch.is_empty() {
102 diagnostics.push(warning("default_branch", "default branch unknown"));
103 } else {
104 diagnostics.push(ok("default_branch", "default branch resolved"));
105 }
106 diagnostics.push(ok("environment", "child environment built"));
107 context
108 }
109 Err(error) => {
110 diagnostics.push(error_diag("worktree", error.to_string()));
111 return DoctorReport {
112 fatal: true,
113 context: None,
114 diagnostics,
115 };
116 }
117 };
118 let context_snapshot = WorktreeSnapshot::from(&context);
119
120 if context.root_path == context.worktree_path && runtime_policy.pre_config_strict() {
121 fatal = true;
122 diagnostics.push(error_diag(
123 "root_worktree",
124 "root checkout is not a worktree under strict mode",
125 ));
126 }
127
128 let config_selected = if !options.no_init_script && options.config.is_none() {
129 let scripts = InitScriptDiscovery::discover(&context);
130 if let Some(path) = scripts.executable {
131 diagnostics.push(ok(
132 "init_script",
133 format!("executable init script found: {}", path.display()),
134 ));
135 false
136 } else if scripts.ignored.is_empty() {
137 diagnostics.push(warning("init_script", "no executable init script found"));
138 true
139 } else {
140 diagnostics.push(warning(
141 "init_script",
142 format!(
143 "no executable init script found; ignored {} non-executable path(s)",
144 scripts.ignored.len()
145 ),
146 ));
147 true
148 }
149 } else {
150 diagnostics.push(ok("init_script", "init script discovery skipped"));
151 true
152 };
153
154 if config_selected {
155 match check_config(&options, &context, &runtime_policy) {
156 Ok(diagnostic) => diagnostics.push(diagnostic),
157 Err(diagnostic) => {
158 fatal = true;
159 diagnostics.push(diagnostic);
160 }
161 }
162 } else {
163 diagnostics.push(ok(
164 "config",
165 "config discovery skipped because an init script takes precedence",
166 ));
167 }
168
169 DoctorReport {
170 fatal,
171 context: Some(context_snapshot),
172 diagnostics,
173 }
174}
175
176fn check_config(
177 options: &DoctorOptions,
178 context: &crate::Worktree,
179 runtime_policy: &RuntimePolicy,
180) -> std::result::Result<Diagnostic, Diagnostic> {
181 let path = Config::discover_path(context, options.config.as_deref())
182 .map_err(|error| error_diag("config", error.to_string()))?;
183
184 let Some(path) = path else {
185 if runtime_policy.pre_config_strict() {
186 return Err(error_diag("config", "no config detected under strict mode"));
187 }
188
189 return Ok(warning("config", "no config detected"));
190 };
191
192 let config =
193 Config::load(&path, context).map_err(|error| error_diag("config", error.to_string()))?;
194 let plan_options = runtime_policy.resolve(&config.options);
195 ActionPlan::from_manifest(
196 &path,
197 &config,
198 context,
199 plan_options.into_action_plan_options(),
200 )
201 .map_err(|error| error_diag("config_validation", error.to_string()))?;
202
203 Ok(ok("config", format!("config is valid: {}", path.display())))
204}
205
206fn ok(name: &'static str, message: impl Into<String>) -> Diagnostic {
207 Diagnostic {
208 name,
209 status: DiagnosticStatus::Ok,
210 message: message.into(),
211 }
212}
213
214fn warning(name: &'static str, message: impl Into<String>) -> Diagnostic {
215 Diagnostic {
216 name,
217 status: DiagnosticStatus::Warning,
218 message: message.into(),
219 }
220}
221
222fn error_diag(name: &'static str, message: impl Into<String>) -> Diagnostic {
223 Diagnostic {
224 name,
225 status: DiagnosticStatus::Error,
226 message: message.into(),
227 }
228}