cascade_cli/cli/commands/
setup.rs

1use crate::cli::output::Output;
2use crate::config::{get_repo_config_dir, initialize_repo, Settings};
3use crate::errors::{CascadeError, Result};
4use crate::git::{find_repository_root, GitRepository};
5use dialoguer::{theme::ColorfulTheme, Confirm, Input};
6use std::env;
7use tracing::{info, warn};
8
9/// Run the interactive setup wizard
10pub async fn run(force: bool) -> Result<()> {
11    Output::section("Welcome to Cascade CLI Setup!");
12    Output::divider();
13    Output::info("This wizard will help you configure Cascade for your repository.");
14    println!();
15
16    let current_dir = env::current_dir()
17        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
18
19    // Step 1: Find Git repository root
20    Output::progress("Step 1: Finding Git repository...");
21    let repo_root = find_repository_root(&current_dir).map_err(|_| {
22        CascadeError::config(
23            "No Git repository found. Please run this command from within a Git repository.",
24        )
25    })?;
26
27    Output::success(format!("Git repository found at: {}", repo_root.display()));
28
29    let git_repo = GitRepository::open(&repo_root)?;
30
31    // Step 2: Check if already initialized
32    let config_dir = get_repo_config_dir(&repo_root)?;
33    if config_dir.exists() && !force {
34        let reinitialize = Confirm::with_theme(&ColorfulTheme::default())
35            .with_prompt("Cascade is already initialized. Do you want to reconfigure?")
36            .default(false)
37            .interact()
38            .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
39
40        if !reinitialize {
41            Output::success("Setup cancelled. Run with --force to reconfigure.");
42            return Ok(());
43        }
44    }
45
46    // Step 3: Configure Git user settings
47    Output::progress("Step 2: Configuring Git user settings...");
48    configure_git_user(&git_repo).await?;
49
50    // Step 4: Detect Bitbucket from remotes
51    Output::progress("Step 3: Detecting Bitbucket configuration...");
52    let auto_config = detect_bitbucket_config(&git_repo)?;
53
54    if let Some((url, project, repo)) = &auto_config {
55        Output::success("Detected Bitbucket configuration:");
56        Output::sub_item(format!("Server: {url}"));
57        Output::sub_item(format!("Project: {project}"));
58        Output::sub_item(format!("Repository: {repo}"));
59    } else {
60        Output::warning("Could not auto-detect Bitbucket configuration");
61    }
62
63    // Step 5: Interactive configuration
64    Output::progress("Step 4: Configure Bitbucket settings");
65    let bitbucket_config = configure_bitbucket_interactive(auto_config).await?;
66
67    // Step 6: Initialize repository (using repo root, not current dir)
68    Output::progress("Step 5: Initializing Cascade");
69    initialize_repo(&repo_root, Some(bitbucket_config.url.clone()))?;
70
71    // Step 7: Save configuration
72    let config_path = config_dir.join("config.json");
73    let mut settings = Settings::load_from_file(&config_path).unwrap_or_default();
74
75    settings.bitbucket.url = bitbucket_config.url;
76    settings.bitbucket.project = bitbucket_config.project;
77    settings.bitbucket.repo = bitbucket_config.repo;
78    settings.bitbucket.token = bitbucket_config.token;
79
80    settings.save_to_file(&config_path)?;
81
82    // Step 8: Test connection (optional)
83    Output::progress("Step 6: Testing connection");
84    if let Some(ref token) = settings.bitbucket.token {
85        if !token.is_empty() {
86            match test_bitbucket_connection(&settings).await {
87                Ok(_) => {
88                    Output::success("Connection successful!");
89                }
90                Err(e) => {
91                    warn!("   ⚠️  Connection test failed: {}", e);
92                    Output::tip("You can test the connection later with: ca doctor");
93                }
94            }
95        } else {
96            Output::warning("No token provided - skipping connection test");
97        }
98    } else {
99        Output::warning("No token provided - skipping connection test");
100    }
101
102    // Step 9: Setup completions (optional)
103    Output::progress("Step 7: Shell completions");
104    let install_completions = Confirm::with_theme(&ColorfulTheme::default())
105        .with_prompt("Would you like to install shell completions?")
106        .default(true)
107        .interact()
108        .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
109
110    if install_completions {
111        match crate::cli::commands::completions::install_completions(None) {
112            Ok(_) => {
113                Output::success("Shell completions installed");
114            }
115            Err(e) => {
116                warn!("   ⚠️  Failed to install completions: {}", e);
117                Output::tip("You can install them later with: ca completions install");
118            }
119        }
120    }
121
122    // Step 10: Install Git hooks (recommended)
123    Output::progress("Step 8: Git hooks");
124    let install_hooks = Confirm::with_theme(&ColorfulTheme::default())
125        .with_prompt("Would you like to install Git hooks for enhanced workflow?")
126        .default(true)
127        .interact()
128        .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
129
130    if install_hooks {
131        match crate::cli::commands::hooks::install_essential().await {
132            Ok(_) => {
133                Output::success("Essential Git hooks installed");
134                Output::tip("Hooks installed: pre-push, commit-msg, prepare-commit-msg");
135                Output::tip(
136                    "Optional: Install post-commit hook with 'ca hooks install post-commit'",
137                );
138                Output::tip("See docs/HOOKS.md for details");
139            }
140            Err(e) => {
141                warn!("   ⚠️  Failed to install hooks: {}", e);
142                if e.to_string().contains("Git hooks directory not found") {
143                    Output::tip("This doesn't appear to be a Git repository.");
144                    println!("      Please ensure you're running this command from within a Git repository.");
145                    println!("      You can initialize git with: git init");
146                } else {
147                    Output::tip("You can install them later with: ca hooks install");
148                }
149            }
150        }
151    }
152
153    // Step 11: Configure PR description template (optional)
154    Output::progress("Step 9: PR Description Template");
155    let setup_template = Confirm::with_theme(&ColorfulTheme::default())
156        .with_prompt(
157            "Would you like to configure a PR description template? (will be used for ALL PRs)",
158        )
159        .default(false)
160        .interact()
161        .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
162
163    if setup_template {
164        configure_pr_template(&config_path).await?;
165    } else {
166        Output::tip("You can configure a PR template later with:");
167        Output::command_example("ca config set cascade.pr_description_template \"Your template\"");
168    }
169
170    // Success summary
171    Output::section("Setup Complete!");
172    Output::success("Cascade CLI is now fully configured for your repository.");
173    println!();
174    Output::info("Configuration includes:");
175    Output::bullet("✅ Git user settings (name and email)");
176    Output::bullet("✅ Bitbucket Server integration");
177    Output::bullet("✅ Essential Git hooks for enhanced workflow");
178    Output::bullet("✅ Shell completions (if selected)");
179    println!();
180    Output::tip("Next steps:");
181    Output::bullet("Create your first stack: ca stack create \"My Feature\"");
182    Output::bullet("Push commits to the stack: ca push");
183    Output::bullet("Submit for review: ca submit");
184    Output::bullet("Check status: ca status");
185    println!();
186    Output::tip("Learn more:");
187    Output::bullet("Run 'ca --help' for all commands");
188    Output::bullet("Run 'ca doctor' to verify your setup");
189    Output::bullet("Use 'ca --verbose <command>' for debug logging");
190    Output::bullet("Run 'ca hooks status' to check hook installation");
191    Output::bullet(
192        "Configure PR templates: ca config set cascade.pr_description_template \"template\"",
193    );
194    Output::bullet("Visit docs/HOOKS.md for hook details");
195    Output::bullet("Visit the documentation for advanced usage");
196
197    Ok(())
198}
199
200/// Configure Git user settings (name and email)
201async fn configure_git_user(git_repo: &GitRepository) -> Result<()> {
202    let theme = ColorfulTheme::default();
203
204    // Check current git configuration
205    let repo_path = git_repo.path();
206    let git_repo_inner = git2::Repository::open(repo_path)
207        .map_err(|e| CascadeError::config(format!("Could not open git repository: {e}")))?;
208
209    let mut current_name: Option<String> = None;
210    let mut current_email: Option<String> = None;
211
212    if let Ok(config) = git_repo_inner.config() {
213        // Check for user configuration
214        if let Ok(name) = config.get_string("user.name") {
215            if !name.trim().is_empty() {
216                current_name = Some(name);
217            }
218        }
219
220        if let Ok(email) = config.get_string("user.email") {
221            if !email.trim().is_empty() {
222                current_email = Some(email);
223            }
224        }
225    }
226
227    // Display current configuration status
228    match (&current_name, &current_email) {
229        (Some(name), Some(email)) => {
230            Output::success("Git user configuration found:");
231            Output::sub_item(format!("Name: {name}"));
232            Output::sub_item(format!("Email: {email}"));
233
234            let keep_current = Confirm::with_theme(&theme)
235                .with_prompt("Keep current Git user settings?")
236                .default(true)
237                .interact()
238                .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
239
240            if keep_current {
241                Output::success("Using existing Git user configuration");
242                return Ok(());
243            }
244        }
245        _ => {
246            if current_name.is_some() || current_email.is_some() {
247                Output::warning("Git user configuration incomplete:");
248                if let Some(name) = &current_name {
249                    Output::sub_item(format!("Name: {name}"));
250                } else {
251                    Output::sub_item("Name: not configured");
252                }
253                if let Some(email) = &current_email {
254                    Output::sub_item(format!("Email: {email}"));
255                } else {
256                    Output::sub_item("Email: not configured");
257                }
258            } else {
259                Output::warning("Git user not configured");
260                Output::info(
261                    "Git user name and email are required for commits and Cascade operations",
262                );
263            }
264        }
265    }
266
267    // Prompt for user information
268    println!("\n👤 Git User Configuration");
269    println!("   This information will be used for all git commits and Cascade operations.");
270
271    let name: String = Input::with_theme(&theme)
272        .with_prompt("Your name")
273        .with_initial_text(current_name.unwrap_or_default())
274        .validate_with(|input: &String| -> std::result::Result<(), &str> {
275            if input.trim().is_empty() {
276                Err("Name cannot be empty")
277            } else {
278                Ok(())
279            }
280        })
281        .interact_text()
282        .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
283
284    let email: String = Input::with_theme(&theme)
285        .with_prompt("Your email")
286        .with_initial_text(current_email.unwrap_or_default())
287        .validate_with(|input: &String| -> std::result::Result<(), &str> {
288            if input.trim().is_empty() {
289                Err("Email cannot be empty")
290            } else if !input.contains('@') {
291                Err("Please enter a valid email address")
292            } else {
293                Ok(())
294            }
295        })
296        .interact_text()
297        .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
298
299    // Ask about scope (global vs local)
300    let use_global = Confirm::with_theme(&theme)
301        .with_prompt("Set globally for all Git repositories? (otherwise only for this repository)")
302        .default(true)
303        .interact()
304        .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
305
306    // Set the configuration using git commands for reliability
307    let scope_flag = if use_global { "--global" } else { "--local" };
308
309    // Set user.name
310    let output = std::process::Command::new("git")
311        .args(["config", scope_flag, "user.name", &name])
312        .current_dir(repo_path)
313        .output()
314        .map_err(|e| CascadeError::config(format!("Failed to execute git config: {e}")))?;
315
316    if !output.status.success() {
317        let stderr = String::from_utf8_lossy(&output.stderr);
318        return Err(CascadeError::config(format!(
319            "Failed to set git user.name: {stderr}"
320        )));
321    }
322
323    // Set user.email
324    let output = std::process::Command::new("git")
325        .args(["config", scope_flag, "user.email", &email])
326        .current_dir(repo_path)
327        .output()
328        .map_err(|e| CascadeError::config(format!("Failed to execute git config: {e}")))?;
329
330    if !output.status.success() {
331        let stderr = String::from_utf8_lossy(&output.stderr);
332        return Err(CascadeError::config(format!(
333            "Failed to set git user.email: {stderr}"
334        )));
335    }
336
337    // Validate the configuration was set correctly
338    match git_repo.validate_git_user_config() {
339        Ok(_) => {
340            Output::success("Git user configuration updated successfully!");
341            if use_global {
342                Output::sub_item("Configuration applied globally for all Git repositories");
343            } else {
344                Output::sub_item("Configuration applied to this repository only");
345            }
346            Output::sub_item(format!("Name: {name}"));
347            Output::sub_item(format!("Email: {email}"));
348        }
349        Err(e) => {
350            Output::warning(format!("Configuration set but validation failed: {e}"));
351            Output::tip("You may need to check your git configuration manually");
352        }
353    }
354
355    Ok(())
356}
357
358#[derive(Debug)]
359struct BitbucketConfig {
360    url: String,
361    project: String,
362    repo: String,
363    token: Option<String>,
364}
365
366/// Detect Bitbucket configuration from Git remotes
367fn detect_bitbucket_config(git_repo: &GitRepository) -> Result<Option<(String, String, String)>> {
368    // Get the remote URL
369    let remote_url = match git_repo.get_remote_url("origin") {
370        Ok(url) => url,
371        Err(_) => return Ok(None),
372    };
373
374    // Parse different URL formats
375    if let Some(config) = parse_bitbucket_url(&remote_url) {
376        Ok(Some(config))
377    } else {
378        Ok(None)
379    }
380}
381
382/// Parse Bitbucket URL from various formats
383fn parse_bitbucket_url(url: &str) -> Option<(String, String, String)> {
384    // Handle SSH format: git@bitbucket.example.com:PROJECT/repo.git
385    if url.starts_with("git@") {
386        if let Some(parts) = url.split('@').nth(1) {
387            if let Some((host, path)) = parts.split_once(':') {
388                let base_url = format!("https://{host}");
389                if let Some((project, repo)) = path.split_once('/') {
390                    let repo_name = repo.strip_suffix(".git").unwrap_or(repo);
391                    return Some((base_url, project.to_string(), repo_name.to_string()));
392                }
393            }
394        }
395    }
396
397    // Handle HTTPS format: https://bitbucket.example.com/scm/PROJECT/repo.git
398    if url.starts_with("https://") {
399        if let Ok(parsed_url) = url::Url::parse(url) {
400            if let Some(host) = parsed_url.host_str() {
401                let base_url = format!("{}://{}", parsed_url.scheme(), host);
402                let path = parsed_url.path();
403
404                // Bitbucket Server format: /scm/PROJECT/repo.git
405                if path.starts_with("/scm/") {
406                    let path_parts: Vec<&str> =
407                        path.trim_start_matches("/scm/").split('/').collect();
408                    if path_parts.len() >= 2 {
409                        let project = path_parts[0];
410                        let repo = path_parts[1].strip_suffix(".git").unwrap_or(path_parts[1]);
411                        return Some((base_url, project.to_string(), repo.to_string()));
412                    }
413                }
414
415                // Generic format: /PROJECT/repo.git
416                let path_parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
417                if path_parts.len() >= 2 {
418                    let project = path_parts[0];
419                    let repo = path_parts[1].strip_suffix(".git").unwrap_or(path_parts[1]);
420                    return Some((base_url, project.to_string(), repo.to_string()));
421                }
422            }
423        }
424    }
425
426    None
427}
428
429/// Interactive Bitbucket configuration
430async fn configure_bitbucket_interactive(
431    auto_config: Option<(String, String, String)>,
432) -> Result<BitbucketConfig> {
433    let theme = ColorfulTheme::default();
434
435    // Server URL
436    let default_url = auto_config
437        .as_ref()
438        .map(|(url, _, _)| url.as_str())
439        .unwrap_or("");
440    let url: String = Input::with_theme(&theme)
441        .with_prompt("Bitbucket Server URL")
442        .with_initial_text(default_url)
443        .validate_with(|input: &String| -> std::result::Result<(), &str> {
444            if input.starts_with("http://") || input.starts_with("https://") {
445                Ok(())
446            } else {
447                Err("URL must start with http:// or https://")
448            }
449        })
450        .interact_text()
451        .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
452
453    // Project key
454    let default_project = auto_config
455        .as_ref()
456        .map(|(_, project, _)| project.as_str())
457        .unwrap_or("");
458    let project: String = Input::with_theme(&theme)
459        .with_prompt("Project key (usually uppercase)")
460        .with_initial_text(default_project)
461        .validate_with(|input: &String| -> std::result::Result<(), &str> {
462            if input.trim().is_empty() {
463                Err("Project key cannot be empty")
464            } else {
465                Ok(())
466            }
467        })
468        .interact_text()
469        .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
470
471    // Repository slug
472    let default_repo = auto_config
473        .as_ref()
474        .map(|(_, _, repo)| repo.as_str())
475        .unwrap_or("");
476    let repo: String = Input::with_theme(&theme)
477        .with_prompt("Repository slug")
478        .with_initial_text(default_repo)
479        .validate_with(|input: &String| -> std::result::Result<(), &str> {
480            if input.trim().is_empty() {
481                Err("Repository slug cannot be empty")
482            } else {
483                Ok(())
484            }
485        })
486        .interact_text()
487        .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
488
489    // Authentication token
490    println!("\n🔐 Authentication Setup");
491    println!("   Cascade needs a Personal Access Token to interact with Bitbucket.");
492    println!("   You can create one at: {url}/plugins/servlet/access-tokens/manage");
493    println!("   Required permissions: Repository Read, Repository Write");
494
495    let configure_token = Confirm::with_theme(&theme)
496        .with_prompt("Configure authentication token now?")
497        .default(true)
498        .interact()
499        .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
500
501    let token = if configure_token {
502        let token: String = Input::with_theme(&theme)
503            .with_prompt("Personal Access Token")
504            .interact_text()
505            .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
506
507        if token.trim().is_empty() {
508            None
509        } else {
510            Some(token.trim().to_string())
511        }
512    } else {
513        Output::tip("You can configure the token later with:");
514        Output::command_example("ca config set bitbucket.token YOUR_TOKEN");
515        None
516    };
517
518    Ok(BitbucketConfig {
519        url,
520        project,
521        repo,
522        token,
523    })
524}
525
526/// Test Bitbucket connection
527async fn test_bitbucket_connection(settings: &Settings) -> Result<()> {
528    use crate::bitbucket::BitbucketClient;
529
530    let client = BitbucketClient::new(&settings.bitbucket)?;
531
532    // Try to fetch repository info
533    match client.get_repository_info().await {
534        Ok(_) => {
535            info!("Successfully connected to Bitbucket");
536            Ok(())
537        }
538        Err(e) => Err(CascadeError::config(format!(
539            "Failed to connect to Bitbucket: {e}"
540        ))),
541    }
542}
543
544/// Configure PR description template interactively
545async fn configure_pr_template(config_path: &std::path::Path) -> Result<()> {
546    let theme = ColorfulTheme::default();
547
548    println!("   Configure a markdown template for PR descriptions.");
549    println!("   This template will be used for ALL PRs (overrides --description).");
550    println!("   You can use markdown formatting, variables, etc.");
551    println!("   ");
552    println!("   Example template:");
553    println!("   ## Summary");
554    println!("   Brief description of changes");
555    println!("   ");
556    println!("   ## Testing");
557    println!("   - [ ] Unit tests pass");
558    println!("   - [ ] Manual testing completed");
559
560    let use_example = Confirm::with_theme(&theme)
561        .with_prompt("Use the example template above?")
562        .default(true)
563        .interact()
564        .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
565
566    let template = if use_example {
567        Some("## Summary\nBrief description of changes\n\n## Testing\n- [ ] Unit tests pass\n- [ ] Manual testing completed\n\n## Checklist\n- [ ] Code review completed\n- [ ] Documentation updated".to_string())
568    } else {
569        let custom_template: String = Input::with_theme(&theme)
570            .with_prompt("Enter your PR description template (use \\n for line breaks)")
571            .allow_empty(true)
572            .interact_text()
573            .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
574
575        if custom_template.trim().is_empty() {
576            None
577        } else {
578            // Replace literal \n with actual newlines
579            Some(custom_template.replace("\\n", "\n"))
580        }
581    };
582
583    // Load and update settings
584    let mut settings = Settings::load_from_file(config_path)?;
585    settings.cascade.pr_description_template = template;
586    settings.save_to_file(config_path)?;
587
588    if settings.cascade.pr_description_template.is_some() {
589        Output::success("PR description template configured!");
590        Output::tip("This template will be used for ALL future PRs");
591        Output::tip(
592            "Edit later with: ca config set cascade.pr_description_template \"Your template\"",
593        );
594    } else {
595        Output::success("No template configured (will use --description or commit messages)");
596    }
597
598    Ok(())
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    #[test]
606    fn test_parse_bitbucket_ssh_url() {
607        let url = "git@bitbucket.example.com:MYPROJECT/my-repo.git";
608        let result = parse_bitbucket_url(url);
609        assert_eq!(
610            result,
611            Some((
612                "https://bitbucket.example.com".to_string(),
613                "MYPROJECT".to_string(),
614                "my-repo".to_string()
615            ))
616        );
617    }
618
619    #[test]
620    fn test_parse_bitbucket_https_url() {
621        let url = "https://bitbucket.example.com/scm/MYPROJECT/my-repo.git";
622        let result = parse_bitbucket_url(url);
623        assert_eq!(
624            result,
625            Some((
626                "https://bitbucket.example.com".to_string(),
627                "MYPROJECT".to_string(),
628                "my-repo".to_string()
629            ))
630        );
631    }
632
633    #[test]
634    fn test_parse_generic_https_url() {
635        let url = "https://git.example.com/MYPROJECT/my-repo.git";
636        let result = parse_bitbucket_url(url);
637        assert_eq!(
638            result,
639            Some((
640                "https://git.example.com".to_string(),
641                "MYPROJECT".to_string(),
642                "my-repo".to_string()
643            ))
644        );
645    }
646}