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