cascade_cli/cli/commands/
setup.rs

1use crate::config::{get_repo_config_dir, initialize_repo, Settings};
2use crate::errors::{CascadeError, Result};
3use crate::git::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: Check Git repository
18    println!("šŸ” Step 1: Checking Git repository...");
19    let git_repo = match GitRepository::open(&current_dir) {
20        Ok(repo) => {
21            println!("   āœ… Git repository found");
22            repo
23        }
24        Err(_) => {
25            return Err(CascadeError::config(
26                "No Git repository found. Please run this command from within a Git repository.",
27            ));
28        }
29    };
30
31    // Step 2: Check if already initialized
32    let config_dir = get_repo_config_dir(&current_dir)?;
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            println!("āœ… Setup cancelled. Run with --force to reconfigure.");
42            return Ok(());
43        }
44    }
45
46    // Step 3: Detect Bitbucket from remotes
47    println!("\nšŸ” 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        println!("   āœ… Detected Bitbucket configuration:");
52        println!("      Server: {url}");
53        println!("      Project: {project}");
54        println!("      Repository: {repo}");
55    } else {
56        println!("   āš ļø  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
64    println!("\nšŸš€ Step 4: Initializing Cascade...");
65    initialize_repo(&current_dir, 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: cc 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: cc 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_with_options(false, true, true, false).await {
128            Ok(_) => {
129                println!("   āœ… Essential Git hooks installed");
130                println!("   šŸ’” Hooks installed: pre-push, commit-msg, prepare-commit-msg");
131                println!("   šŸ“š See docs/HOOKS.md for details");
132            }
133            Err(e) => {
134                warn!("   āš ļø  Failed to install hooks: {}", e);
135                println!("   šŸ’” You can install them later with: cc hooks install");
136            }
137        }
138    }
139
140    // Success summary
141    println!("\nšŸŽ‰ Setup Complete!");
142    println!("━━━━━━━━━━━━━━━━━");
143    println!("Cascade CLI is now configured for your repository.");
144    println!();
145    println!("šŸ’” Next steps:");
146    println!("   1. Create your first stack: cc stack create \"My Feature\"");
147    println!("   2. Push commits to the stack: cc push");
148    println!("   3. Submit for review: cc submit");
149    println!("   4. Check status: cc status");
150    println!();
151    println!("šŸ“š Learn more:");
152    println!("   • Run 'cc --help' for all commands");
153    println!("   • Run 'cc doctor' to verify your setup");
154    println!("   • Run 'cc hooks status' to check hook installation");
155    println!("   • Visit docs/HOOKS.md for hook details");
156    println!("   • Visit the documentation for advanced usage");
157
158    Ok(())
159}
160
161#[derive(Debug)]
162struct BitbucketConfig {
163    url: String,
164    project: String,
165    repo: String,
166    token: Option<String>,
167}
168
169/// Detect Bitbucket configuration from Git remotes
170fn detect_bitbucket_config(git_repo: &GitRepository) -> Result<Option<(String, String, String)>> {
171    // Get the remote URL
172    let remote_url = match git_repo.get_remote_url("origin") {
173        Ok(url) => url,
174        Err(_) => return Ok(None),
175    };
176
177    // Parse different URL formats
178    if let Some(config) = parse_bitbucket_url(&remote_url) {
179        Ok(Some(config))
180    } else {
181        Ok(None)
182    }
183}
184
185/// Parse Bitbucket URL from various formats
186fn parse_bitbucket_url(url: &str) -> Option<(String, String, String)> {
187    // Handle SSH format: git@bitbucket.example.com:PROJECT/repo.git
188    if url.starts_with("git@") {
189        if let Some(parts) = url.split('@').nth(1) {
190            if let Some((host, path)) = parts.split_once(':') {
191                let base_url = format!("https://{host}");
192                if let Some((project, repo)) = path.split_once('/') {
193                    let repo_name = repo.strip_suffix(".git").unwrap_or(repo);
194                    return Some((base_url, project.to_string(), repo_name.to_string()));
195                }
196            }
197        }
198    }
199
200    // Handle HTTPS format: https://bitbucket.example.com/scm/PROJECT/repo.git
201    if url.starts_with("https://") {
202        if let Ok(parsed_url) = url::Url::parse(url) {
203            if let Some(host) = parsed_url.host_str() {
204                let base_url = format!("{}://{}", parsed_url.scheme(), host);
205                let path = parsed_url.path();
206
207                // Bitbucket Server format: /scm/PROJECT/repo.git
208                if path.starts_with("/scm/") {
209                    let path_parts: Vec<&str> =
210                        path.trim_start_matches("/scm/").split('/').collect();
211                    if path_parts.len() >= 2 {
212                        let project = path_parts[0];
213                        let repo = path_parts[1].strip_suffix(".git").unwrap_or(path_parts[1]);
214                        return Some((base_url, project.to_string(), repo.to_string()));
215                    }
216                }
217
218                // Generic format: /PROJECT/repo.git
219                let path_parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
220                if path_parts.len() >= 2 {
221                    let project = path_parts[0];
222                    let repo = path_parts[1].strip_suffix(".git").unwrap_or(path_parts[1]);
223                    return Some((base_url, project.to_string(), repo.to_string()));
224                }
225            }
226        }
227    }
228
229    None
230}
231
232/// Interactive Bitbucket configuration
233async fn configure_bitbucket_interactive(
234    auto_config: Option<(String, String, String)>,
235) -> Result<BitbucketConfig> {
236    let theme = ColorfulTheme::default();
237
238    // Server URL
239    let default_url = auto_config
240        .as_ref()
241        .map(|(url, _, _)| url.as_str())
242        .unwrap_or("");
243    let url: String = Input::with_theme(&theme)
244        .with_prompt("Bitbucket Server URL")
245        .with_initial_text(default_url)
246        .validate_with(|input: &String| -> std::result::Result<(), &str> {
247            if input.starts_with("http://") || input.starts_with("https://") {
248                Ok(())
249            } else {
250                Err("URL must start with http:// or https://")
251            }
252        })
253        .interact_text()
254        .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
255
256    // Project key
257    let default_project = auto_config
258        .as_ref()
259        .map(|(_, project, _)| project.as_str())
260        .unwrap_or("");
261    let project: String = Input::with_theme(&theme)
262        .with_prompt("Project key (usually uppercase)")
263        .with_initial_text(default_project)
264        .validate_with(|input: &String| -> std::result::Result<(), &str> {
265            if input.trim().is_empty() {
266                Err("Project key cannot be empty")
267            } else {
268                Ok(())
269            }
270        })
271        .interact_text()
272        .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
273
274    // Repository slug
275    let default_repo = auto_config
276        .as_ref()
277        .map(|(_, _, repo)| repo.as_str())
278        .unwrap_or("");
279    let repo: String = Input::with_theme(&theme)
280        .with_prompt("Repository slug")
281        .with_initial_text(default_repo)
282        .validate_with(|input: &String| -> std::result::Result<(), &str> {
283            if input.trim().is_empty() {
284                Err("Repository slug cannot be empty")
285            } else {
286                Ok(())
287            }
288        })
289        .interact_text()
290        .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
291
292    // Authentication token
293    println!("\nšŸ” Authentication Setup");
294    println!("   Cascade needs a Personal Access Token to interact with Bitbucket.");
295    println!("   You can create one at: {url}/plugins/servlet/access-tokens/manage");
296    println!("   Required permissions: Repository Read, Repository Write");
297
298    let configure_token = Confirm::with_theme(&theme)
299        .with_prompt("Configure authentication token now?")
300        .default(true)
301        .interact()
302        .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
303
304    let token = if configure_token {
305        let token: String = Input::with_theme(&theme)
306            .with_prompt("Personal Access Token")
307            .interact_text()
308            .map_err(|e| CascadeError::config(format!("Input error: {e}")))?;
309
310        if token.trim().is_empty() {
311            None
312        } else {
313            Some(token.trim().to_string())
314        }
315    } else {
316        println!("   šŸ’” You can configure the token later with:");
317        println!("      cc config set bitbucket.token YOUR_TOKEN");
318        None
319    };
320
321    Ok(BitbucketConfig {
322        url,
323        project,
324        repo,
325        token,
326    })
327}
328
329/// Test Bitbucket connection
330async fn test_bitbucket_connection(settings: &Settings) -> Result<()> {
331    use crate::bitbucket::BitbucketClient;
332
333    let client = BitbucketClient::new(&settings.bitbucket)?;
334
335    // Try to fetch repository info
336    match client.get_repository_info().await {
337        Ok(_) => {
338            info!("Successfully connected to Bitbucket");
339            Ok(())
340        }
341        Err(e) => Err(CascadeError::config(format!(
342            "Failed to connect to Bitbucket: {e}"
343        ))),
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn test_parse_bitbucket_ssh_url() {
353        let url = "git@bitbucket.example.com:MYPROJECT/my-repo.git";
354        let result = parse_bitbucket_url(url);
355        assert_eq!(
356            result,
357            Some((
358                "https://bitbucket.example.com".to_string(),
359                "MYPROJECT".to_string(),
360                "my-repo".to_string()
361            ))
362        );
363    }
364
365    #[test]
366    fn test_parse_bitbucket_https_url() {
367        let url = "https://bitbucket.example.com/scm/MYPROJECT/my-repo.git";
368        let result = parse_bitbucket_url(url);
369        assert_eq!(
370            result,
371            Some((
372                "https://bitbucket.example.com".to_string(),
373                "MYPROJECT".to_string(),
374                "my-repo".to_string()
375            ))
376        );
377    }
378
379    #[test]
380    fn test_parse_generic_https_url() {
381        let url = "https://git.example.com/MYPROJECT/my-repo.git";
382        let result = parse_bitbucket_url(url);
383        assert_eq!(
384            result,
385            Some((
386                "https://git.example.com".to_string(),
387                "MYPROJECT".to_string(),
388                "my-repo".to_string()
389            ))
390        );
391    }
392}