Skip to main content

commit_wizard/core/usecases/
doctor.rs

1use 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    // --- Git ---
82    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    // --- Project config / rules ---
100    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    // --- Global config / rules ---
142    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    // --- Registries ---
173    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    // --- Print results ---
186    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    // Add details for each registry
289    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    // Load and display state information
307    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}