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