commit_wizard/core/usecases/
doctor.rs1use std::time::Instant;
2
3use crate::{
4 core::{Context, CoreResult},
5 engine::{
6 config::resolver::{load_project_config, load_rules_config, load_standard_config},
7 constants::{
8 emoji::{ERROR, INFO, SUCCESS, WARN},
9 resolve_global_config_path, resolve_global_rules_path, resolve_project_config_path,
10 },
11 },
12};
13
14enum CheckStatus {
15 Ok,
16 Warn,
17 Info,
18 Error,
19}
20
21struct CheckResult {
22 label: String,
23 status: CheckStatus,
24 detail: String,
25}
26
27impl CheckResult {
28 fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
29 Self {
30 label: label.into(),
31 status: CheckStatus::Ok,
32 detail: detail.into(),
33 }
34 }
35
36 fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
37 Self {
38 label: label.into(),
39 status: CheckStatus::Warn,
40 detail: detail.into(),
41 }
42 }
43
44 fn info(label: impl Into<String>, detail: impl Into<String>) -> Self {
45 Self {
46 label: label.into(),
47 status: CheckStatus::Info,
48 detail: detail.into(),
49 }
50 }
51
52 fn error(label: impl Into<String>, detail: impl Into<String>) -> Self {
53 Self {
54 label: label.into(),
55 status: CheckStatus::Error,
56 detail: detail.into(),
57 }
58 }
59
60 fn icon(&self) -> &str {
61 match self.status {
62 CheckStatus::Ok => SUCCESS,
63 CheckStatus::Warn => WARN,
64 CheckStatus::Info => INFO,
65 CheckStatus::Error => ERROR,
66 }
67 }
68
69 fn is_error(&self) -> bool {
70 matches!(self.status, CheckStatus::Error)
71 }
72}
73
74pub fn run(ctx: &Context) -> CoreResult<()> {
75 let ui = ctx.ui();
76 let start = Instant::now();
77 let git = ctx.git();
78
79 let mut checks: Vec<CheckResult> = Vec::new();
80
81 let git_installed = git.is_installed();
83 checks.push(if git_installed {
84 CheckResult::ok("Git", "git is available in PATH")
85 } else {
86 CheckResult::error("Git", "git is not available in PATH")
87 });
88
89 let in_git_repo = ctx.in_git_repo();
90 checks.push(if in_git_repo {
91 CheckResult::ok(
92 "Git repository",
93 format!("repo root: {}", ctx.repo_root().display()),
94 )
95 } else {
96 CheckResult::warn("Git repository", "not inside a git repository")
97 });
98
99 let repo_config_path = resolve_project_config_path(
101 ctx.cwd(),
102 if in_git_repo {
103 Some(ctx.repo_root())
104 } else {
105 None
106 },
107 in_git_repo,
108 ctx.runtime().explicit_config_path().map(|p| p.as_path()),
109 );
110
111 checks.push(match &repo_config_path {
112 Some(path) => {
113 if load_project_config(path, Some(&ui)).is_none() {
114 CheckResult::error(
115 "Project config",
116 format!("malformed config at {}", path.display()),
117 )
118 } else {
119 CheckResult::ok("Project config", path.display().to_string())
120 }
121 }
122 None => CheckResult::info("Project config", "not found — default policy will be used"),
123 });
124
125 let repo_rules_path = repo_config_path
126 .as_ref()
127 .map(|p| p.parent().unwrap_or(p).join(".cwizard.rules.toml"));
128 if let Some(ref path) = repo_rules_path
129 && path.exists()
130 {
131 checks.push(if load_rules_config(path).is_none() {
132 CheckResult::error(
133 "Project rules",
134 format!("malformed rules at {}", path.display()),
135 )
136 } else {
137 CheckResult::ok("Project rules", path.display().to_string())
138 });
139 }
140
141 let global_config_path = resolve_global_config_path().ok();
143 checks.push(match &global_config_path {
144 Some(path) if path.exists() => {
145 if load_standard_config(path).is_none() {
146 CheckResult::error(
147 "Global config",
148 format!("malformed config at {}", path.display()),
149 )
150 } else {
151 CheckResult::ok("Global config", path.display().to_string())
152 }
153 }
154 _ => CheckResult::info("Global config", "not found — default policy will be used"),
155 });
156
157 let global_rules_path = resolve_global_rules_path().ok();
158 checks.push(match &global_rules_path {
159 Some(path) if path.exists() => {
160 if load_rules_config(path).is_none() {
161 CheckResult::error(
162 "Global rules",
163 format!("malformed rules at {}", path.display()),
164 )
165 } else {
166 CheckResult::ok("Global rules", path.display().to_string())
167 }
168 }
169 _ => CheckResult::info("Global rules", "not found — default policy will be used"),
170 });
171
172 let sources = &ctx.sources();
174 let registry_count = sources.registries.len();
175 checks.push(if registry_count > 0 {
176 let ids: Vec<&str> = sources.registries.iter().map(|r| r.id.as_str()).collect();
177 CheckResult::ok(
178 "Registries",
179 format!("{} loaded: {}", registry_count, ids.join(", ")),
180 )
181 } else {
182 CheckResult::info("Registries", "none configured")
183 });
184
185 for check in &checks {
187 ui.logger()
188 .kv(&format!("{} {}", check.icon(), check.label), &check.detail);
189 }
190
191 let error_count = checks.iter().filter(|c| c.is_error()).count();
192 let warn_count = checks
193 .iter()
194 .filter(|c| matches!(c.status, CheckStatus::Warn))
195 .count();
196 let duration_ms = start.elapsed().as_millis() as u64;
197
198 ui.logger().detail("");
199 if error_count == 0 && warn_count == 0 {
200 ui.logger().ok(&format!("{SUCCESS} All checks passed"));
201 } else if error_count > 0 {
202 ui.logger()
203 .warn(&format!("{WARN} {error_count} error(s) found"));
204 } else {
205 ui.logger()
206 .warn(&format!("{WARN} {warn_count} warning(s) found"));
207 }
208
209 let content = ui
210 .new_output_content()
211 .title("Doctor")
212 .subtitle("Environment diagnostics and configuration status")
213 .heading(2, "Paths")
214 .key_value("cwd", ctx.cwd().display().to_string())
215 .key_value("repo_root", ctx.repo_root().display().to_string())
216 .key_value(
217 "global_config",
218 ctx.runtime().global_config_path().display().to_string(),
219 )
220 .key_value(
221 "global_state",
222 ctx.runtime().global_state_path().display().to_string(),
223 )
224 .key_value(
225 "global_cache",
226 ctx.runtime().global_cache_path().display().to_string(),
227 )
228 .heading(2, "Environment")
229 .key_value("in_git_repo", in_git_repo.to_string())
230 .key_value("git_installed", git_installed.to_string())
231 .key_value(
232 "has_project_config",
233 sources
234 .repo_config
235 .as_ref()
236 .map(|c| c.base.is_some())
237 .unwrap_or(false)
238 .to_string(),
239 )
240 .key_value(
241 "has_project_rules",
242 sources
243 .repo_config
244 .as_ref()
245 .map(|c| c.rules.is_some())
246 .unwrap_or(false)
247 .to_string(),
248 )
249 .key_value(
250 "has_global_config",
251 sources
252 .global_config
253 .as_ref()
254 .map(|c| c.base.is_some())
255 .unwrap_or(false)
256 .to_string(),
257 )
258 .key_value(
259 "has_global_rules",
260 sources
261 .global_config
262 .as_ref()
263 .map(|c| c.rules.is_some())
264 .unwrap_or(false)
265 .to_string(),
266 )
267 .key_value(
268 "has_env_config",
269 sources
270 .env_config
271 .as_ref()
272 .map(|c| c.base.is_some())
273 .unwrap_or(false)
274 .to_string(),
275 )
276 .key_value(
277 "has_cli_config",
278 sources
279 .cli_config
280 .as_ref()
281 .map(|c| c.base.is_some())
282 .unwrap_or(false)
283 .to_string(),
284 )
285 .heading(2, "Registry Pool")
286 .key_value("total_registries", registry_count.to_string());
287
288 let mut content = content;
290 for (idx, registry) in sources.registries.iter().enumerate() {
291 let status = if registry.is_active {
292 "[ACTIVE]"
293 } else {
294 "[available]"
295 };
296 let registry_info = format!(
297 "{}: {} (tag: {}) {}",
298 idx + 1,
299 registry.url,
300 registry.tag,
301 status
302 );
303 content = content.key_value(format!("registry_{}", idx + 1), registry_info);
304 }
305
306 let state_file_path = ctx.runtime().state_file_path();
308 use crate::engine::models::state::AppState;
309 let state = AppState::load(&state_file_path).unwrap_or_default();
310
311 let content = if let Some(registry_state) = &state.registry {
312 content
313 .heading(2, "Registry State")
314 .key_value("state_file", state_file_path.display().to_string())
315 .key_value(
316 "registry_name",
317 registry_state.name.as_deref().unwrap_or("(unnamed)"),
318 )
319 .key_value("registry_url", registry_state.url.clone())
320 .key_value("registry_ref", registry_state.r#ref.clone())
321 .key_value(
322 "registry_section",
323 registry_state.section.as_deref().unwrap_or("(root)"),
324 )
325 .key_value("resolved_commit", registry_state.resolved_commit.clone())
326 .key_value("cache_path", registry_state.cache_path.clone())
327 } else {
328 content
329 .heading(2, "Registry State")
330 .key_value("state_file", state_file_path.display().to_string())
331 .plain("No registry state saved")
332 };
333
334 let content = content
335 .heading(2, "AI")
336 .heading(2, "Summary")
337 .key_value("errors", error_count.to_string())
338 .key_value("warnings", warn_count.to_string())
339 .plain(format!(
340 "{} check(s) passed, {} warning(s) found, {} error(s) found",
341 checks.len() - error_count - warn_count,
342 warn_count,
343 error_count
344 ));
345
346 let meta = ui
347 .new_output_meta()
348 .with_command("doctor".to_string())
349 .with_duration_ms(duration_ms)
350 .with_timestamp(chrono::Utc::now().to_string())
351 .with_dry_run(false);
352
353 ui.print_with_meta(&content, Some(&meta), true)
354}
355
356pub fn fix(ctx: &Context) -> CoreResult<()> {
357 let ui = ctx.ui();
358 let start = Instant::now();
359
360 ui.logger().info("Checking for fixable issues...");
361
362 let sources = ctx.sources();
363 let mut fixed = 0usize;
364
365 if sources.repo_config.is_none() {
366 ui.logger().warn(&format!(
367 "{WARN} No project config found — run 'cw configs init' to create one"
368 ));
369 } else {
370 ui.logger()
371 .ok(&format!("{SUCCESS} Project config present"));
372 fixed += 1;
373 }
374
375 if !ctx.in_git_repo() {
376 ui.logger().warn(&format!(
377 "{WARN} Not inside a git repository — no automatic fix available"
378 ));
379 } else {
380 ui.logger()
381 .ok(&format!("{SUCCESS} Git repository detected"));
382 fixed += 1;
383 }
384
385 let duration_ms = start.elapsed().as_millis() as u64;
386
387 ui.logger().detail("");
388 if fixed > 0 {
389 ui.logger()
390 .info("Run 'cw doctor' to verify the environment.");
391 }
392
393 let content = ui
394 .new_output_content()
395 .title("Doctor Fix")
396 .subtitle("Automatic repair attempt")
397 .key_value("checks_passed", fixed.to_string())
398 .heading(1, "Repairs attempted")
399 .paragraph(format!(
400 "Successfully verified {} item(s). Run 'cw doctor' to verify the full environment.",
401 fixed
402 ));
403
404 let meta = ui
405 .new_output_meta()
406 .with_command("doctor fix".to_string())
407 .with_duration_ms(duration_ms)
408 .with_timestamp(chrono::Utc::now().to_string())
409 .with_dry_run(ctx.dry_run());
410
411 ui.print_with_meta(&content, Some(&meta), true)
412}