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