Skip to main content

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    use tokio::sync::Mutex;
290
291    // These tests mutate process-global cwd via set_current_dir, so they must
292    // not run in parallel with each other.
293    static CWD_MUTEX: Mutex<()> = Mutex::const_new(());
294
295    async fn create_test_repo() -> (TempDir, std::path::PathBuf) {
296        let temp_dir = TempDir::new().unwrap();
297        let repo_path = temp_dir.path().to_path_buf();
298
299        // Initialize git repository
300        let repo = Repository::init(&repo_path).unwrap();
301
302        // Configure git user and default branch
303        let mut config = repo.config().unwrap();
304        config.set_str("user.name", "Test User").unwrap();
305        config.set_str("user.email", "test@example.com").unwrap();
306        config.set_str("init.defaultBranch", "main").unwrap();
307
308        // Create initial commit
309        let signature = Signature::now("Test User", "test@example.com").unwrap();
310        let tree_id = {
311            let mut index = repo.index().unwrap();
312            index.write_tree().unwrap()
313        };
314        let tree = repo.find_tree(tree_id).unwrap();
315
316        let commit_oid = repo
317            .commit(
318                None, // Create initial commit without updating HEAD
319                &signature,
320                &signature,
321                "Initial commit",
322                &tree,
323                &[],
324            )
325            .unwrap();
326
327        // Create main branch and set HEAD
328        let commit = repo.find_commit(commit_oid).unwrap();
329        repo.branch("main", &commit, false).unwrap();
330        repo.set_head("refs/heads/main").unwrap();
331        repo.checkout_head(None).unwrap();
332
333        (temp_dir, repo_path)
334    }
335
336    #[tokio::test]
337    async fn test_doctor_uninitialized() {
338        let _lock = CWD_MUTEX.lock().await;
339
340        let (_temp_dir, repo_path) = create_test_repo().await;
341
342        let original_dir = env::current_dir().unwrap();
343        env::set_current_dir(&repo_path).unwrap();
344
345        let result = run().await;
346
347        let _ = env::set_current_dir(&original_dir);
348
349        if let Err(e) = &result {
350            eprintln!("Doctor command failed: {e}");
351        }
352        assert!(result.is_ok());
353    }
354
355    #[tokio::test]
356    async fn test_doctor_initialized() {
357        let _lock = CWD_MUTEX.lock().await;
358
359        let (_temp_dir, repo_path) = create_test_repo().await;
360
361        // Initialize Cascade
362        initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string())).unwrap();
363
364        let original_dir = env::current_dir().expect("Failed to get current directory");
365        env::set_current_dir(&repo_path).expect("Failed to change to test repository directory");
366
367        let result = run().await;
368
369        let _ = env::set_current_dir(&original_dir);
370
371        assert!(
372            result.is_ok(),
373            "Doctor command should succeed in initialized repo: {:?}",
374            result.err()
375        );
376    }
377}