cascade_cli/cli/commands/
doctor.rs

1use crate::cli::output::Output;
2use crate::config::{get_repo_config_dir, is_repo_initialized, Settings};
3use crate::errors::{CascadeError, Result};
4use crate::git::{get_current_repository, is_git_repository};
5use std::env;
6
7/// Check repository health and configuration
8pub async fn run() -> Result<()> {
9    Output::section("Cascade Doctor");
10    Output::info("Diagnosing repository health and configuration...");
11    println!();
12
13    let mut issues_found = 0;
14    let mut warnings_found = 0;
15
16    // Check 1: Git repository
17    issues_found += check_git_repository().await?;
18
19    // Check 2: Cascade initialization
20    let (repo_issues, repo_warnings) = check_cascade_initialization().await?;
21    issues_found += repo_issues;
22    warnings_found += repo_warnings;
23
24    // Check 3: Configuration
25    if issues_found == 0 {
26        let config_warnings = check_configuration().await?;
27        warnings_found += config_warnings;
28    }
29
30    // Check 4: Git configuration
31    warnings_found += check_git_configuration().await?;
32
33    // Summary
34    print_summary(issues_found, warnings_found);
35
36    Ok(())
37}
38
39async fn check_git_repository() -> Result<u32> {
40    Output::check_start("Checking Git repository");
41
42    let current_dir = env::current_dir()
43        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
44
45    if !is_git_repository(&current_dir) {
46        Output::error("Not in a Git repository");
47        Output::solution("Navigate to a Git repository or run 'git init'");
48        return Ok(1);
49    }
50
51    match get_current_repository() {
52        Ok(git_repo) => {
53            let repo_info = git_repo.get_info()?;
54            Output::success(format!(
55                "Git repository found at: {}",
56                repo_info.path.display()
57            ));
58
59            if let Some(branch) = &repo_info.head_branch {
60                Output::success(format!("Current branch: {branch}"));
61            } else {
62                Output::warning("Detached HEAD state");
63            }
64        }
65        Err(e) => {
66            Output::error(format!("Git repository error: {e}"));
67            return Ok(1);
68        }
69    }
70
71    Ok(0)
72}
73
74async fn check_cascade_initialization() -> Result<(u32, u32)> {
75    Output::check_start("Checking Cascade initialization");
76
77    let git_repo = get_current_repository()?;
78    let repo_path = git_repo.path();
79
80    if !is_repo_initialized(repo_path) {
81        Output::error("Repository not initialized for Cascade");
82        Output::solution("Run 'ca init' to initialize");
83        return Ok((1, 0));
84    }
85
86    Output::success("Repository initialized for Cascade");
87
88    // Check for configuration directory structure
89    let config_dir = get_repo_config_dir(repo_path)?;
90
91    if !config_dir.exists() {
92        Output::error("Configuration directory missing");
93        Output::solution("Run 'ca init --force' to recreate");
94        return Ok((1, 0));
95    }
96
97    Output::success("Configuration directory exists");
98
99    // Check for required subdirectories
100    let stacks_dir = config_dir.join("stacks");
101    let cache_dir = config_dir.join("cache");
102
103    let mut warnings = 0;
104
105    if !stacks_dir.exists() {
106        Output::warning("Stacks directory missing");
107        warnings += 1;
108    } else {
109        Output::success("Stacks directory exists");
110    }
111
112    if !cache_dir.exists() {
113        Output::warning("Cache directory missing");
114        warnings += 1;
115    } else {
116        Output::success("Cache directory exists");
117    }
118
119    Ok((0, warnings))
120}
121
122async fn check_configuration() -> Result<u32> {
123    Output::check_start("Checking configuration");
124
125    let git_repo = get_current_repository()?;
126    let config_dir = get_repo_config_dir(git_repo.path())?;
127    let config_file = config_dir.join("config.json");
128
129    let settings = Settings::load_from_file(&config_file)?;
130    let mut warnings = 0;
131
132    // Validate configuration
133    match settings.validate() {
134        Ok(()) => {
135            Output::success("Configuration is valid");
136        }
137        Err(e) => {
138            Output::warning(format!("Configuration validation failed: {e}"));
139            warnings += 1;
140        }
141    }
142
143    // Check Bitbucket configuration completeness
144    Output::check_start("Bitbucket configuration");
145
146    if settings.bitbucket.url.is_empty() {
147        Output::warning("Bitbucket server URL not configured");
148        Output::solution("ca config set bitbucket.url https://your-bitbucket-server.com");
149        warnings += 1;
150    } else {
151        Output::success("Bitbucket server URL configured");
152    }
153
154    if settings.bitbucket.project.is_empty() {
155        Output::warning("Bitbucket project key not configured");
156        Output::solution("ca config set bitbucket.project YOUR_PROJECT_KEY");
157        warnings += 1;
158    } else {
159        Output::success("Bitbucket project key configured");
160    }
161
162    if settings.bitbucket.repo.is_empty() {
163        Output::warning("Bitbucket repository slug not configured");
164        Output::solution("ca config set bitbucket.repo your-repo-name");
165        warnings += 1;
166    } else {
167        Output::success("Bitbucket repository slug configured");
168    }
169
170    if settings
171        .bitbucket
172        .token
173        .as_ref()
174        .is_none_or(|s| s.is_empty())
175    {
176        Output::warning("Bitbucket authentication token not configured");
177        Output::solution("ca config set bitbucket.token your-personal-access-token");
178        warnings += 1;
179    } else {
180        Output::success("Bitbucket authentication token configured");
181    }
182
183    Ok(warnings)
184}
185
186async fn check_git_configuration() -> Result<u32> {
187    Output::check_start("Checking Git configuration");
188
189    let git_repo = get_current_repository()?;
190    let repo_path = git_repo.path();
191    let git_repo_inner = git2::Repository::open(repo_path)?;
192
193    let mut warnings = 0;
194
195    // Check Git user configuration
196    match git_repo_inner.config() {
197        Ok(config) => {
198            match config.get_string("user.name") {
199                Ok(name) => {
200                    Output::success(format!("Git user.name: {name}"));
201                }
202                Err(_) => {
203                    Output::warning("Git user.name not configured");
204                    Output::solution("git config user.name \"Your Name\"");
205                    warnings += 1;
206                }
207            }
208
209            match config.get_string("user.email") {
210                Ok(email) => {
211                    Output::success(format!("Git user.email: {email}"));
212                }
213                Err(_) => {
214                    Output::warning("Git user.email not configured");
215                    Output::solution("git config user.email \"your.email@example.com\"");
216                    warnings += 1;
217                }
218            }
219        }
220        Err(_) => {
221            Output::warning("Could not read Git configuration");
222            warnings += 1;
223        }
224    }
225
226    // Check for remote repositories
227    match git_repo_inner.remotes() {
228        Ok(remotes) => {
229            if remotes.is_empty() {
230                Output::warning("No remote repositories configured");
231                Output::tip("Add a remote with 'git remote add origin <url>'");
232                warnings += 1;
233            } else {
234                Output::success(format!("Remote repositories configured: {}", remotes.len()));
235            }
236        }
237        Err(_) => {
238            Output::warning("Could not read remote repositories");
239            warnings += 1;
240        }
241    }
242
243    Ok(warnings)
244}
245
246fn print_summary(issues: u32, warnings: u32) {
247    Output::section("Summary");
248
249    if issues == 0 && warnings == 0 {
250        Output::success("All checks passed! Your repository is ready for Cascade.");
251        println!();
252        Output::tip("Next steps:");
253        Output::bullet("Create your first stack: ca create \"Add new feature\"");
254        Output::bullet("Submit for review: ca submit");
255        Output::bullet("View help: ca --help");
256    } else if issues == 0 {
257        Output::warning(format!(
258            "{} warning{} found, but no critical issues.",
259            warnings,
260            if warnings == 1 { "" } else { "s" }
261        ));
262        Output::sub_item(
263            "Your repository should work, but consider addressing the warnings above.",
264        );
265    } else {
266        Output::error(format!(
267            "{} critical issue{} found that need to be resolved.",
268            issues,
269            if issues == 1 { "" } else { "s" }
270        ));
271        if warnings > 0 {
272            Output::sub_item(format!(
273                "Additionally, {} warning{} found.",
274                warnings,
275                if warnings == 1 { "" } else { "s" }
276            ));
277        }
278        Output::sub_item("Please address the issues above before using Cascade.");
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::config::initialize_repo;
286    use git2::{Repository, Signature};
287    use std::env;
288    use tempfile::TempDir;
289
290    async fn create_test_repo() -> (TempDir, std::path::PathBuf) {
291        let temp_dir = TempDir::new().unwrap();
292        let repo_path = temp_dir.path().to_path_buf();
293
294        // Initialize git repository
295        let repo = Repository::init(&repo_path).unwrap();
296
297        // Configure git user and default branch
298        let mut config = repo.config().unwrap();
299        config.set_str("user.name", "Test User").unwrap();
300        config.set_str("user.email", "test@example.com").unwrap();
301        config.set_str("init.defaultBranch", "main").unwrap();
302
303        // Create initial commit
304        let signature = Signature::now("Test User", "test@example.com").unwrap();
305        let tree_id = {
306            let mut index = repo.index().unwrap();
307            index.write_tree().unwrap()
308        };
309        let tree = repo.find_tree(tree_id).unwrap();
310
311        let commit_oid = repo
312            .commit(
313                None, // Create initial commit without updating HEAD
314                &signature,
315                &signature,
316                "Initial commit",
317                &tree,
318                &[],
319            )
320            .unwrap();
321
322        // Create main branch and set HEAD
323        let commit = repo.find_commit(commit_oid).unwrap();
324        repo.branch("main", &commit, false).unwrap();
325        repo.set_head("refs/heads/main").unwrap();
326        repo.checkout_head(None).unwrap();
327
328        (temp_dir, repo_path)
329    }
330
331    #[tokio::test]
332    async fn test_doctor_uninitialized() {
333        let (_temp_dir, repo_path) = create_test_repo().await;
334
335        let original_dir = env::current_dir().unwrap();
336        env::set_current_dir(&repo_path).unwrap();
337
338        let result = run().await;
339
340        // Restore directory (best effort - may fail if temp dir already cleaned up)
341        let _ = env::set_current_dir(&original_dir);
342
343        if let Err(e) = &result {
344            eprintln!("Doctor command failed: {e}");
345        }
346        assert!(result.is_ok());
347
348        // _temp_dir dropped here automatically
349    }
350
351    #[tokio::test]
352    async fn test_doctor_initialized() {
353        // Keep temp_dir alive for the entire test to prevent premature cleanup
354        let (temp_dir, repo_path) = create_test_repo().await;
355
356        // Initialize Cascade
357        initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string())).unwrap();
358
359        // Save original directory for restoration
360        let original_dir = env::current_dir().expect("Failed to get current directory");
361
362        // Change to repo directory - if this fails, the test environment is broken
363        env::set_current_dir(&repo_path).expect("Failed to change to test repository directory");
364
365        // Run doctor command
366        let result = run().await;
367
368        // Best-effort directory restoration
369        // May fail on Linux/CI if temp directory cleanup is in progress
370        let restore_result = env::set_current_dir(&original_dir);
371        if restore_result.is_err() {
372            eprintln!(
373                "Warning: Could not restore original directory (temp dir may be cleaning up)"
374            );
375        }
376
377        // Assert the result while temp_dir is still in scope
378        assert!(
379            result.is_ok(),
380            "Doctor command should succeed in initialized repo: {:?}",
381            result.err()
382        );
383
384        // Explicitly drop temp_dir at the end to ensure it stays alive
385        drop(temp_dir);
386    }
387}