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