cascade_cli/cli/commands/
setup.rs1use 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
9pub 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 Output::progress("Step 1: Finding Git repository...");
21 let repo_root = find_repository_root(¤t_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 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 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 Output::progress("Step 3: Configure Bitbucket settings");
61 let bitbucket_config = configure_bitbucket_interactive(auto_config).await?;
62
63 Output::progress("Step 4: Initializing Cascade");
65 initialize_repo(&repo_root, Some(bitbucket_config.url.clone()))?;
66
67 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 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 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 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 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 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
198fn detect_bitbucket_config(git_repo: &GitRepository) -> Result<Option<(String, String, String)>> {
200 let remote_url = match git_repo.get_remote_url("origin") {
202 Ok(url) => url,
203 Err(_) => return Ok(None),
204 };
205
206 if let Some(config) = parse_bitbucket_url(&remote_url) {
208 Ok(Some(config))
209 } else {
210 Ok(None)
211 }
212}
213
214fn parse_bitbucket_url(url: &str) -> Option<(String, String, String)> {
216 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 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 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 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
261async fn configure_bitbucket_interactive(
263 auto_config: Option<(String, String, String)>,
264) -> Result<BitbucketConfig> {
265 let theme = ColorfulTheme::default();
266
267 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 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 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 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
358async fn test_bitbucket_connection(settings: &Settings) -> Result<()> {
360 use crate::bitbucket::BitbucketClient;
361
362 let client = BitbucketClient::new(&settings.bitbucket)?;
363
364 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
376async 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 Some(custom_template.replace("\\n", "\n"))
412 }
413 };
414
415 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}