cascade_cli/cli/commands/
setup.rs

1use crate::config::{get_repo_config_dir, initialize_repo, Settings};
2use crate::errors::{CascadeError, Result};
3use crate::git::{find_repository_root, GitRepository};
4use dialoguer::{theme::ColorfulTheme, Confirm, Input};
5use std::env;
6use tracing::{info, warn};
7
8/// Run the interactive setup wizard
9pub async fn run(force: bool) -> Result<()> {
10    println!("🌊 Welcome to Cascade CLI Setup!");
11    println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
12    println!("This wizard will help you configure Cascade for your repository.\n");
13
14    let current_dir = env::current_dir()
15        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
16
17    // Step 1: Find Git repository root
18    println!("šŸ” Step 1: Finding Git repository...");
19    let repo_root = find_repository_root(&current_dir).map_err(|_| {
20        CascadeError::config(
21            "No Git repository found. Please run this command from within a Git repository.",
22        )
23    })?;
24
25    println!("   āœ… Git repository found at: {}", repo_root.display());
26
27    let git_repo = GitRepository::open(&repo_root)?;
28
29    // Step 2: Check if already initialized
30    let config_dir = get_repo_config_dir(&repo_root)?;
31    if config_dir.exists() && !force {
32        let reinitialize = Confirm::with_theme(&ColorfulTheme::default())
33            .with_prompt("Cascade is already initialized. Do you want to reconfigure?")
34            .default(false)
35            .interact()
36            .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
37
38        if !reinitialize {
39            println!("āœ… Setup cancelled. Run with --force to reconfigure.");
40            return Ok(());
41        }
42    }
43
44    // Step 3: Detect Bitbucket from remotes
45    println!("\nšŸ” Step 2: Detecting Bitbucket configuration...");
46    let auto_config = detect_bitbucket_config(&git_repo)?;
47
48    if let Some((url, project, repo)) = &auto_config {
49        println!("   āœ… Detected Bitbucket configuration:");
50        println!("      Server: {url}");
51        println!("      Project: {project}");
52        println!("      Repository: {repo}");
53    } else {
54        println!("   āš ļø  Could not auto-detect Bitbucket configuration");
55    }
56
57    // Step 4: Interactive configuration
58    println!("\nāš™ļø  Step 3: Configure Bitbucket settings...");
59    let bitbucket_config = configure_bitbucket_interactive(auto_config).await?;
60
61    // Step 5: Initialize repository (using repo root, not current dir)
62    println!("\nšŸš€ Step 4: Initializing Cascade...");
63    initialize_repo(&repo_root, Some(bitbucket_config.url.clone()))?;
64
65    // Step 6: Save configuration
66    let config_path = config_dir.join("config.json");
67    let mut settings = Settings::load_from_file(&config_path).unwrap_or_default();
68
69    settings.bitbucket.url = bitbucket_config.url;
70    settings.bitbucket.project = bitbucket_config.project;
71    settings.bitbucket.repo = bitbucket_config.repo;
72    settings.bitbucket.token = bitbucket_config.token;
73
74    settings.save_to_file(&config_path)?;
75
76    // Step 7: Test connection (optional)
77    println!("\nšŸ”Œ Step 5: Testing connection...");
78    if let Some(ref token) = settings.bitbucket.token {
79        if !token.is_empty() {
80            match test_bitbucket_connection(&settings).await {
81                Ok(_) => {
82                    println!("   āœ… Connection successful!");
83                }
84                Err(e) => {
85                    warn!("   āš ļø  Connection test failed: {}", e);
86                    println!("   šŸ’” You can test the connection later with: ca doctor");
87                }
88            }
89        } else {
90            println!("   āš ļø  No token provided - skipping connection test");
91        }
92    } else {
93        println!("   āš ļø  No token provided - skipping connection test");
94    }
95
96    // Step 8: Setup completions (optional)
97    println!("\nšŸš€ Step 6: Shell completions...");
98    let install_completions = Confirm::with_theme(&ColorfulTheme::default())
99        .with_prompt("Would you like to install shell completions?")
100        .default(true)
101        .interact()
102        .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
103
104    if install_completions {
105        match crate::cli::commands::completions::install_completions(None) {
106            Ok(_) => {
107                println!("   āœ… Shell completions installed");
108            }
109            Err(e) => {
110                warn!("   āš ļø  Failed to install completions: {}", e);
111                println!("   šŸ’” You can install them later with: ca completions install");
112            }
113        }
114    }
115
116    // Step 9: Install Git hooks (recommended)
117    println!("\nšŸŖ Step 7: Git hooks...");
118    let install_hooks = Confirm::with_theme(&ColorfulTheme::default())
119        .with_prompt("Would you like to install Git hooks for enhanced workflow?")
120        .default(true)
121        .interact()
122        .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
123
124    if install_hooks {
125        match crate::cli::commands::hooks::install_essential().await {
126            Ok(_) => {
127                println!("   āœ… Essential Git hooks installed");
128                println!("   šŸ’” Hooks installed: pre-push, commit-msg, prepare-commit-msg");
129                println!(
130                    "   šŸ’” Optional: Install post-commit hook with 'ca hooks install post-commit'"
131                );
132                println!("   šŸ“š See docs/HOOKS.md for details");
133            }
134            Err(e) => {
135                warn!("   āš ļø  Failed to install hooks: {}", e);
136                if e.to_string().contains("Git hooks directory not found") {
137                    println!("   šŸ’” This doesn't appear to be a Git repository.");
138                    println!("      Please ensure you're running this command from within a Git repository.");
139                    println!("      You can initialize git with: git init");
140                } else {
141                    println!("   šŸ’” You can install them later with: ca hooks install");
142                }
143            }
144        }
145    }
146
147    // Step 10: Configure PR description template (optional)
148    println!("\nšŸ“ Step 8: PR Description Template...");
149    let setup_template = Confirm::with_theme(&ColorfulTheme::default())
150        .with_prompt(
151            "Would you like to configure a PR description template? (will be used for ALL PRs)",
152        )
153        .default(false)
154        .interact()
155        .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
156
157    if setup_template {
158        configure_pr_template(&config_path).await?;
159    } else {
160        println!("   šŸ’” You can configure a PR template later with:");
161        println!("      ca config set cascade.pr_description_template \"Your template\"");
162    }
163
164    // Success summary
165    println!("\nšŸŽ‰ Setup Complete!");
166    println!("━━━━━━━━━━━━━━━━━");
167    println!("Cascade CLI is now configured for your repository.");
168    println!();
169    println!("šŸ’” Next steps:");
170    println!("   1. Create your first stack: ca stack create \"My Feature\"");
171    println!("   2. Push commits to the stack: ca push");
172    println!("   3. Submit for review: ca submit");
173    println!("   4. Check status: ca status");
174    println!();
175    println!("šŸ“š Learn more:");
176    println!("   • Run 'ca --help' for all commands");
177    println!("   • Run 'ca doctor' to verify your setup");
178    println!("   • Use 'ca --verbose <command>' for debug logging");
179    println!("   • Run 'ca hooks status' to check hook installation");
180    println!(
181        "   • Configure PR templates: ca config set cascade.pr_description_template \"template\""
182    );
183    println!("   • Visit docs/HOOKS.md for hook details");
184    println!("   • Visit the documentation for advanced usage");
185
186    Ok(())
187}
188
189#[derive(Debug)]
190struct BitbucketConfig {
191    url: String,
192    project: String,
193    repo: String,
194    token: Option<String>,
195}
196
197/// Detect Bitbucket configuration from Git remotes
198fn detect_bitbucket_config(git_repo: &GitRepository) -> Result<Option<(String, String, String)>> {
199    // Get the remote URL
200    let remote_url = match git_repo.get_remote_url("origin") {
201        Ok(url) => url,
202        Err(_) => return Ok(None),
203    };
204
205    // Parse different URL formats
206    if let Some(config) = parse_bitbucket_url(&remote_url) {
207        Ok(Some(config))
208    } else {
209        Ok(None)
210    }
211}
212
213/// Parse Bitbucket URL from various formats
214fn parse_bitbucket_url(url: &str) -> Option<(String, String, String)> {
215    // Handle SSH format: git@bitbucket.example.com:PROJECT/repo.git
216    if url.starts_with("git@") {
217        if let Some(parts) = url.split('@').nth(1) {
218            if let Some((host, path)) = parts.split_once(':') {
219                let base_url = format!("https://{host}");
220                if let Some((project, repo)) = path.split_once('/') {
221                    let repo_name = repo.strip_suffix(".git").unwrap_or(repo);
222                    return Some((base_url, project.to_string(), repo_name.to_string()));
223                }
224            }
225        }
226    }
227
228    // Handle HTTPS format: https://bitbucket.example.com/scm/PROJECT/repo.git
229    if url.starts_with("https://") {
230        if let Ok(parsed_url) = url::Url::parse(url) {
231            if let Some(host) = parsed_url.host_str() {
232                let base_url = format!("{}://{}", parsed_url.scheme(), host);
233                let path = parsed_url.path();
234
235                // Bitbucket Server format: /scm/PROJECT/repo.git
236                if path.starts_with("/scm/") {
237                    let path_parts: Vec<&str> =
238                        path.trim_start_matches("/scm/").split('/').collect();
239                    if path_parts.len() >= 2 {
240                        let project = path_parts[0];
241                        let repo = path_parts[1].strip_suffix(".git").unwrap_or(path_parts[1]);
242                        return Some((base_url, project.to_string(), repo.to_string()));
243                    }
244                }
245
246                // Generic format: /PROJECT/repo.git
247                let path_parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
248                if path_parts.len() >= 2 {
249                    let project = path_parts[0];
250                    let repo = path_parts[1].strip_suffix(".git").unwrap_or(path_parts[1]);
251                    return Some((base_url, project.to_string(), repo.to_string()));
252                }
253            }
254        }
255    }
256
257    None
258}
259
260/// Interactive Bitbucket configuration
261async fn configure_bitbucket_interactive(
262    auto_config: Option<(String, String, String)>,
263) -> Result<BitbucketConfig> {
264    let theme = ColorfulTheme::default();
265
266    // Server URL
267    let default_url = auto_config
268        .as_ref()
269        .map(|(url, _, _)| url.as_str())
270        .unwrap_or("");
271    let url: String = Input::with_theme(&theme)
272        .with_prompt("Bitbucket Server URL")
273        .with_initial_text(default_url)
274        .validate_with(|input: &String| -> std::result::Result<(), &str> {
275            if input.starts_with("http://") || input.starts_with("https://") {
276                Ok(())
277            } else {
278                Err("URL must start with http:// or https://")
279            }
280        })
281        .interact_text()
282        .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
283
284    // Project key
285    let default_project = auto_config
286        .as_ref()
287        .map(|(_, project, _)| project.as_str())
288        .unwrap_or("");
289    let project: String = Input::with_theme(&theme)
290        .with_prompt("Project key (usually uppercase)")
291        .with_initial_text(default_project)
292        .validate_with(|input: &String| -> std::result::Result<(), &str> {
293            if input.trim().is_empty() {
294                Err("Project key cannot be empty")
295            } else {
296                Ok(())
297            }
298        })
299        .interact_text()
300        .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
301
302    // Repository slug
303    let default_repo = auto_config
304        .as_ref()
305        .map(|(_, _, repo)| repo.as_str())
306        .unwrap_or("");
307    let repo: String = Input::with_theme(&theme)
308        .with_prompt("Repository slug")
309        .with_initial_text(default_repo)
310        .validate_with(|input: &String| -> std::result::Result<(), &str> {
311            if input.trim().is_empty() {
312                Err("Repository slug cannot be empty")
313            } else {
314                Ok(())
315            }
316        })
317        .interact_text()
318        .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
319
320    // Authentication token
321    println!("\nšŸ” Authentication Setup");
322    println!("   Cascade needs a Personal Access Token to interact with Bitbucket.");
323    println!("   You can create one at: {url}/plugins/servlet/access-tokens/manage");
324    println!("   Required permissions: Repository Read, Repository Write");
325
326    let configure_token = Confirm::with_theme(&theme)
327        .with_prompt("Configure authentication token now?")
328        .default(true)
329        .interact()
330        .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
331
332    let token = if configure_token {
333        let token: String = Input::with_theme(&theme)
334            .with_prompt("Personal Access Token")
335            .interact_text()
336            .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
337
338        if token.trim().is_empty() {
339            None
340        } else {
341            Some(token.trim().to_string())
342        }
343    } else {
344        println!("   šŸ’” You can configure the token later with:");
345        println!("      ca config set bitbucket.token YOUR_TOKEN");
346        None
347    };
348
349    Ok(BitbucketConfig {
350        url,
351        project,
352        repo,
353        token,
354    })
355}
356
357/// Test Bitbucket connection
358async fn test_bitbucket_connection(settings: &Settings) -> Result<()> {
359    use crate::bitbucket::BitbucketClient;
360
361    let client = BitbucketClient::new(&settings.bitbucket)?;
362
363    // Try to fetch repository info
364    match client.get_repository_info().await {
365        Ok(_) => {
366            info!("Successfully connected to Bitbucket");
367            Ok(())
368        }
369        Err(e) => Err(CascadeError::config(format!(
370            "Failed to connect to Bitbucket: {e}"
371        ))),
372    }
373}
374
375/// Configure PR description template interactively
376async fn configure_pr_template(config_path: &std::path::Path) -> Result<()> {
377    let theme = ColorfulTheme::default();
378
379    println!("   Configure a markdown template for PR descriptions.");
380    println!("   This template will be used for ALL PRs (overrides --description).");
381    println!("   You can use markdown formatting, variables, etc.");
382    println!("   ");
383    println!("   Example template:");
384    println!("   ## Summary");
385    println!("   Brief description of changes");
386    println!("   ");
387    println!("   ## Testing");
388    println!("   - [ ] Unit tests pass");
389    println!("   - [ ] Manual testing completed");
390
391    let use_example = Confirm::with_theme(&theme)
392        .with_prompt("Use the example template above?")
393        .default(true)
394        .interact()
395        .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
396
397    let template = if use_example {
398        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())
399    } else {
400        let custom_template: String = Input::with_theme(&theme)
401            .with_prompt("Enter your PR description template (use \\n for line breaks)")
402            .allow_empty(true)
403            .interact_text()
404            .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
405
406        if custom_template.trim().is_empty() {
407            None
408        } else {
409            // Replace literal \n with actual newlines
410            Some(custom_template.replace("\\n", "\n"))
411        }
412    };
413
414    // Load and update settings
415    let mut settings = Settings::load_from_file(config_path)?;
416    settings.cascade.pr_description_template = template;
417    settings.save_to_file(config_path)?;
418
419    if settings.cascade.pr_description_template.is_some() {
420        println!("   āœ… PR description template configured!");
421        println!("   šŸ’” This template will be used for ALL future PRs");
422        println!("   šŸ’” Edit later with: ca config set cascade.pr_description_template \"Your template\"");
423    } else {
424        println!("   āœ… No template configured (will use --description or commit messages)");
425    }
426
427    Ok(())
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn test_parse_bitbucket_ssh_url() {
436        let url = "git@bitbucket.example.com:MYPROJECT/my-repo.git";
437        let result = parse_bitbucket_url(url);
438        assert_eq!(
439            result,
440            Some((
441                "https://bitbucket.example.com".to_string(),
442                "MYPROJECT".to_string(),
443                "my-repo".to_string()
444            ))
445        );
446    }
447
448    #[test]
449    fn test_parse_bitbucket_https_url() {
450        let url = "https://bitbucket.example.com/scm/MYPROJECT/my-repo.git";
451        let result = parse_bitbucket_url(url);
452        assert_eq!(
453            result,
454            Some((
455                "https://bitbucket.example.com".to_string(),
456                "MYPROJECT".to_string(),
457                "my-repo".to_string()
458            ))
459        );
460    }
461
462    #[test]
463    fn test_parse_generic_https_url() {
464        let url = "https://git.example.com/MYPROJECT/my-repo.git";
465        let result = parse_bitbucket_url(url);
466        assert_eq!(
467            result,
468            Some((
469                "https://git.example.com".to_string(),
470                "MYPROJECT".to_string(),
471                "my-repo".to_string()
472            ))
473        );
474    }
475}