sublime_cli_tools/commands/clone.rs
1//! Clone command implementation.
2//!
3//! This module implements the repository cloning functionality with automatic
4//! workspace setup. It provides a seamless developer onboarding experience by
5//! cloning a Git repository and automatically initializing or validating
6//! workspace configuration.
7//!
8//! # What
9//!
10//! Provides the `clone` command that:
11//! - Clones Git repositories (HTTPS and SSH)
12//! - Shows progress during clone operations
13//! - Supports shallow clones with --depth flag
14//! - Supports --force to overwrite existing directories
15//! - Automatically detects workspace configuration (Story 11.3)
16//! - Validates existing configuration (Story 11.3)
17//! - Initializes workspace if no configuration exists (Story 11.4)
18//!
19//! # How
20//!
21//! The command follows this flow:
22//! 1. Parse URL and determine destination directory
23//! 2. Validate destination doesn't exist (unless --force)
24//! 3. Remove destination if --force is set
25//! 4. Clone repository with progress indication
26//! 5. Detect workspace configuration (Story 11.3)
27//! 6. Validate configuration or run init (Story 11.3/11.4)
28//! 7. Display success message with next steps (Story 11.4)
29//!
30//! # Why
31//!
32//! Cloning with automatic setup:
33//! - Reduces onboarding friction for new developers
34//! - Ensures consistent workspace setup
35//! - Validates configuration immediately
36//! - Provides clear feedback and next steps
37//!
38//! # Examples
39//!
40//! ```bash
41//! # Clone to default location (repository name)
42//! workspace clone https://github.com/org/repo.git
43//!
44//! # Clone to specific directory
45//! workspace clone https://github.com/org/repo.git ./my-dir
46//!
47//! # Clone with configuration overrides
48//! workspace clone https://github.com/org/repo.git \
49//! --strategy independent \
50//! --environments "dev,staging,prod"
51//!
52//! # Force clone (remove existing directory)
53//! workspace clone https://github.com/org/repo.git --force
54//!
55//! # Shallow clone
56//! workspace clone https://github.com/org/repo.git --depth 1
57//! ```
58
59use crate::cli::commands::{CloneArgs, InitArgs};
60use crate::error::{CliError, Result};
61use crate::output::OutputFormat;
62use crate::output::progress::ProgressBar;
63use regex::Regex;
64use std::fmt;
65use std::path::{Path, PathBuf};
66use std::sync::{Arc, Mutex};
67use sublime_git_tools::Repo;
68use sublime_pkg_tools::config::PackageToolsConfig;
69use sublime_pkg_tools::types::VersioningStrategy;
70use sublime_standard_tools::config::Configurable;
71use sublime_standard_tools::filesystem::{AsyncFileSystem, FileSystemManager};
72use tracing::{debug, info};
73
74/// Represents the result of a validation operation.
75///
76/// Contains the overall validation status, the detected strategy (if any),
77/// and a list of individual validation checks.
78///
79/// # Examples
80///
81/// ```rust,ignore
82/// let result = ValidationResult {
83/// is_valid: false,
84/// strategy: Some(VersioningStrategy::Independent),
85/// checks: vec![
86/// ValidationCheck {
87/// name: "Configuration file".to_string(),
88/// passed: true,
89/// error: None,
90/// suggestion: None,
91/// },
92/// ValidationCheck {
93/// name: "Changeset directory".to_string(),
94/// passed: false,
95/// error: Some("Directory does not exist".to_string()),
96/// suggestion: Some("Run 'workspace init --force'".to_string()),
97/// },
98/// ],
99/// };
100/// ```
101#[derive(Debug, Clone)]
102pub struct ValidationResult {
103 /// Whether all validation checks passed.
104 pub is_valid: bool,
105
106 /// The versioning strategy detected from configuration, if available.
107 pub strategy: Option<VersioningStrategy>,
108
109 /// List of individual validation checks performed.
110 pub checks: Vec<ValidationCheck>,
111}
112
113/// Represents an individual validation check.
114///
115/// Each check has a name, a pass/fail status, and optional error message
116/// and suggestion for fixing the issue.
117///
118/// # Examples
119///
120/// ```rust,ignore
121/// let check = ValidationCheck {
122/// name: "Configuration file".to_string(),
123/// passed: false,
124/// error: Some("File not found".to_string()),
125/// suggestion: Some("Run 'workspace init' to create configuration".to_string()),
126/// };
127/// ```
128#[derive(Debug, Clone)]
129pub struct ValidationCheck {
130 /// The name of the validation check.
131 pub name: String,
132
133 /// Whether the check passed.
134 pub passed: bool,
135
136 /// Optional error message if the check failed.
137 pub error: Option<String>,
138
139 /// Optional suggestion for fixing the issue.
140 pub suggestion: Option<String>,
141}
142
143impl fmt::Display for ValidationResult {
144 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145 writeln!(f, "Validation Results:")?;
146 writeln!(f)?;
147
148 if let Some(strategy) = &self.strategy {
149 let strategy_str = match strategy {
150 VersioningStrategy::Independent => "independent",
151 VersioningStrategy::Unified => "unified",
152 };
153 writeln!(f, "Strategy: {strategy_str}")?;
154 writeln!(f)?;
155 }
156
157 writeln!(f, "Checks:")?;
158 for check in &self.checks {
159 let status = if check.passed { "✓" } else { "✗" };
160 writeln!(f, " {status} {}", check.name)?;
161
162 if let Some(error) = &check.error {
163 writeln!(f, " Error: {error}")?;
164 }
165
166 if let Some(suggestion) = &check.suggestion {
167 writeln!(f, " Suggestion: {suggestion}")?;
168 }
169 }
170
171 writeln!(f)?;
172 let overall = if self.is_valid { "VALID" } else { "INVALID" };
173 writeln!(f, "Overall Status: {overall}")?;
174
175 Ok(())
176 }
177}
178
179/// Helper function to validate directory existence.
180///
181/// Creates a `ValidationCheck` for a directory.
182///
183/// # Arguments
184///
185/// * `fs` - The filesystem manager
186/// * `root` - The root directory
187/// * `path` - The relative path to check
188/// * `name` - Display name for the check
189async fn validate_directory(
190 fs: &FileSystemManager,
191 root: &Path,
192 path: &str,
193 name: &str,
194) -> ValidationCheck {
195 let dir = root.join(path);
196 let exists = fs.exists(&dir).await;
197 ValidationCheck {
198 name: name.to_string(),
199 passed: exists,
200 error: if exists { None } else { Some(format!("Directory '{path}' does not exist")) },
201 suggestion: if exists {
202 None
203 } else {
204 Some("Run 'workspace init --force' to create directory".to_string())
205 },
206 }
207}
208
209/// Validates workspace configuration and directory structure.
210///
211/// Performs comprehensive validation including:
212/// 1. Configuration file existence and loading
213/// 2. Configuration structure validation using `Configurable::validate()`
214/// 3. Required directory existence checks (.changesets/, .changesets/history/, .workspace-backups/)
215/// 4. .gitignore entries verification
216///
217/// # Arguments
218///
219/// * `root` - The root directory of the workspace to validate
220///
221/// # Returns
222///
223/// Returns a `ValidationResult` containing:
224/// - Overall validation status (passed/failed)
225/// - Detected versioning strategy
226/// - Individual check results with errors and suggestions
227///
228/// # Errors
229///
230/// Returns an error if:
231/// - File system operations fail
232/// - Cannot read .gitignore file
233///
234/// # Examples
235///
236/// ```rust,ignore
237/// let result = validate_workspace(Path::new("./my-repo")).await?;
238/// if result.is_valid {
239/// println!("Workspace is valid!");
240/// } else {
241/// println!("Validation failed:");
242/// for check in &result.checks {
243/// if !check.passed {
244/// println!(" - {}: {}", check.name, check.error.as_ref().unwrap());
245/// }
246/// }
247/// }
248/// ```
249pub async fn validate_workspace(root: &Path) -> Result<ValidationResult> {
250 let mut checks = Vec::new();
251 let fs = FileSystemManager::new();
252
253 // Check 1: Load and validate configuration
254 let config_result = crate::commands::find_and_load_config(root, None).await?;
255
256 let (config_check, config_opt) = match config_result {
257 Some(config) => {
258 // Config loaded, now validate it using the Configurable trait
259 match config.validate() {
260 Ok(()) => (
261 ValidationCheck {
262 name: "Configuration file".to_string(),
263 passed: true,
264 error: None,
265 suggestion: None,
266 },
267 Some(config),
268 ),
269 Err(e) => (
270 ValidationCheck {
271 name: "Configuration validation".to_string(),
272 passed: false,
273 error: Some(format!("Validation failed: {e}")),
274 suggestion: Some(
275 "Fix configuration errors or run 'workspace init --force'".to_string(),
276 ),
277 },
278 Some(config),
279 ),
280 }
281 }
282 None => (
283 ValidationCheck {
284 name: "Configuration file".to_string(),
285 passed: false,
286 error: Some("Configuration file not found".to_string()),
287 suggestion: Some("Run 'workspace init' to create configuration".to_string()),
288 },
289 None,
290 ),
291 };
292 checks.push(config_check);
293
294 // Check 2: Changeset directory
295 let changeset_path = config_opt.as_ref().map_or(".changesets", |c| c.changeset.path.as_str());
296 checks.push(validate_directory(&fs, root, changeset_path, "Changeset directory").await);
297
298 // Check 3: History directory
299 let history_path =
300 config_opt.as_ref().map_or(".changesets/history", |c| c.changeset.history_path.as_str());
301 checks.push(validate_directory(&fs, root, history_path, "History directory").await);
302
303 // Check 4: Backup directory
304 let backup_path =
305 config_opt.as_ref().map_or(".workspace-backups", |c| c.upgrade.backup.backup_dir.as_str());
306 checks.push(validate_directory(&fs, root, backup_path, "Backup directory").await);
307
308 // Check 5: .gitignore entries
309 let gitignore_path = root.join(".gitignore");
310 let gitignore_check = if fs.exists(&gitignore_path).await {
311 let content = fs.read_file_string(&gitignore_path).await.map_err(|e| {
312 CliError::io(format!(
313 "Failed to read .gitignore file at {}: {e}",
314 gitignore_path.display()
315 ))
316 })?;
317
318 let has_changesets = content.contains(changeset_path);
319 let has_backups = content.contains(backup_path);
320
321 ValidationCheck {
322 name: ".gitignore entries".to_string(),
323 passed: has_changesets && has_backups,
324 error: if !has_changesets || !has_backups {
325 Some("Missing .gitignore entries for workspace directories".to_string())
326 } else {
327 None
328 },
329 suggestion: if !has_changesets || !has_backups {
330 Some("Run 'workspace init --force' to update .gitignore".to_string())
331 } else {
332 None
333 },
334 }
335 } else {
336 ValidationCheck {
337 name: ".gitignore file".to_string(),
338 passed: false,
339 error: Some(".gitignore file does not exist".to_string()),
340 suggestion: Some("Create .gitignore with workspace directories".to_string()),
341 }
342 };
343 checks.push(gitignore_check);
344
345 // Determine overall validity
346 let is_valid = checks.iter().all(|c| c.passed);
347
348 // Extract strategy if configuration loaded successfully
349 let strategy = config_opt.map(|c| c.version.strategy);
350
351 Ok(ValidationResult { is_valid, strategy, checks })
352}
353
354/// Determines the destination directory for cloning.
355///
356/// Extracts the repository name from the URL and uses it as the destination
357/// if no explicit destination was provided. Supports both HTTPS and SSH URLs.
358///
359/// # URL Formats Supported
360///
361/// - HTTPS: `https://github.com/org/repo.git` → `repo`
362/// - HTTPS (no .git): `https://github.com/org/repo` → `repo`
363/// - SSH: `git@github.com:org/repo.git` → `repo`
364/// - SSH (no .git): `git@github.com:org/repo` → `repo`
365///
366/// # Arguments
367///
368/// * `url` - The repository URL to clone from
369/// * `destination` - Optional explicit destination directory
370///
371/// # Returns
372///
373/// Returns the destination `PathBuf` that should be used for cloning.
374///
375/// # Errors
376///
377/// Returns an error if:
378/// - The URL format is invalid
379/// - Cannot extract repository name from URL
380///
381/// # Examples
382///
383/// ```rust,ignore
384/// // HTTPS URL
385/// let dest = determine_destination(
386/// "https://github.com/org/repo.git",
387/// None
388/// )?;
389/// assert_eq!(dest, PathBuf::from("repo"));
390///
391/// // SSH URL
392/// let dest = determine_destination(
393/// "git@github.com:org/repo.git",
394/// None
395/// )?;
396/// assert_eq!(dest, PathBuf::from("repo"));
397///
398/// // Explicit destination takes precedence
399/// let dest = determine_destination(
400/// "https://github.com/org/repo.git",
401/// Some(&PathBuf::from("my-dir"))
402/// )?;
403/// assert_eq!(dest, PathBuf::from("my-dir"));
404/// ```
405pub(crate) fn determine_destination(url: &str, destination: Option<&PathBuf>) -> Result<PathBuf> {
406 // If destination is explicitly provided, use it
407 if let Some(dest) = destination {
408 return Ok(dest.clone());
409 }
410
411 // Extract repository name from URL
412 // Supports:
413 // - HTTPS: https://github.com/org/repo.git
414 // - SSH: git@github.com:org/repo.git
415 // - Without .git extension
416
417 // Try HTTPS format first: https://.../org/repo.git or https://.../org/repo
418 let https_regex = Regex::new(r"https?://[^/]+/.*/([^/]+?)(\.git)?$")
419 .map_err(|e| CliError::validation(format!("Invalid URL regex: {e}")))?;
420
421 if let Some(captures) = https_regex.captures(url)
422 && let Some(repo_name) = captures.get(1)
423 {
424 return Ok(PathBuf::from(repo_name.as_str()));
425 }
426
427 // Try SSH format: git@host:org/repo.git or git@host:org/repo
428 let ssh_regex = Regex::new(r"^[^@]+@[^:]+:.*/([^/]+?)(\.git)?$")
429 .map_err(|e| CliError::validation(format!("Invalid URL regex: {e}")))?;
430
431 if let Some(captures) = ssh_regex.captures(url)
432 && let Some(repo_name) = captures.get(1)
433 {
434 return Ok(PathBuf::from(repo_name.as_str()));
435 }
436
437 // If we couldn't parse the URL, return an error
438 Err(CliError::validation(format!(
439 "Unable to determine repository name from URL: {url}. \
440 Please provide an explicit destination directory."
441 )))
442}
443
444/// Validates that the destination directory is suitable for cloning.
445///
446/// Checks that the destination either doesn't exist, or can be removed
447/// if the --force flag is set.
448///
449/// # Arguments
450///
451/// * `destination` - The destination directory path
452/// * `force` - Whether to allow overwriting existing directory
453///
454/// # Returns
455///
456/// Returns `Ok(())` if the destination is valid, or an error otherwise.
457///
458/// # Errors
459///
460/// Returns an error if:
461/// - Destination exists and --force is not set
462/// - Cannot access destination path
463///
464/// # Examples
465///
466/// ```rust,ignore
467/// // New directory - should succeed
468/// validate_destination(Path::new("./new-dir"), false)?;
469///
470/// // Existing directory without force - should fail
471/// let result = validate_destination(Path::new("./existing"), false);
472/// assert!(result.is_err());
473///
474/// // Existing directory with force - should succeed
475/// validate_destination(Path::new("./existing"), true)?;
476/// ```
477pub(crate) fn validate_destination(destination: &Path, force: bool) -> Result<()> {
478 // Check if destination exists
479 if destination.exists() {
480 if !force {
481 return Err(CliError::validation(format!(
482 "Destination already exists: {}. Use --force to overwrite.",
483 destination.display()
484 )));
485 }
486
487 // With --force, we'll allow removal (this will be handled by the execute function)
488 // Just validate that it's accessible
489 if !destination.is_dir() {
490 return Err(CliError::validation(format!(
491 "Destination exists but is not a directory: {}",
492 destination.display()
493 )));
494 }
495 }
496
497 Ok(())
498}
499
500/// Clones a Git repository with progress indication.
501///
502/// This function performs the actual Git clone operation using `sublime_git_tools`,
503/// displaying progress feedback to the user via a spinner. It supports both
504/// full and shallow clones.
505///
506/// # Arguments
507///
508/// * `url` - The repository URL to clone from
509/// * `destination` - The local path where the repository should be cloned
510/// * `depth` - Optional depth for shallow clone (None for full clone)
511/// * `format` - Output format to control progress display
512///
513/// # Returns
514///
515/// Returns the cloned `Repo` instance on success.
516///
517/// # Errors
518///
519/// Returns an error if:
520/// - Network connection fails
521/// - Authentication fails (for private repositories)
522/// - Insufficient disk space
523/// - Permission denied
524/// - Invalid URL or destination
525///
526/// # Examples
527///
528/// ```rust,ignore
529/// // Full clone
530/// let repo = clone_with_progress(
531/// "https://github.com/org/repo.git",
532/// Path::new("./repo"),
533/// None,
534/// OutputFormat::Human
535/// )?;
536///
537/// // Shallow clone
538/// let repo = clone_with_progress(
539/// "https://github.com/org/repo.git",
540/// Path::new("./repo"),
541/// Some(1),
542/// OutputFormat::Human
543/// )?;
544/// ```
545///
546/// # Implementation Notes
547///
548/// Uses `Repo::clone_with_progress()` from `sublime_git_tools` which supports
549/// progress callbacks for real-time progress tracking. The progress bar displays:
550/// - Receiving objects with percentage
551/// - Current/total object counts
552/// - Completion status
553pub(crate) fn clone_with_progress(
554 url: &str,
555 destination: &Path,
556 depth: Option<u32>,
557 format: OutputFormat,
558) -> Result<Repo> {
559 let destination_str = destination.to_str().ok_or_else(|| {
560 CliError::validation(format!("Invalid destination path: {}", destination.display()))
561 })?;
562
563 // Convert depth from u32 to i32 if specified
564 let depth_i32 = if let Some(depth_value) = depth {
565 Some(i32::try_from(depth_value).map_err(|_| {
566 CliError::validation(format!(
567 "Depth value {} is too large (maximum: {})",
568 depth_value,
569 i32::MAX
570 ))
571 })?)
572 } else {
573 None
574 };
575
576 // Create progress bar for tracking clone progress
577 // Start with 0 total - will be updated when we get the first progress callback
578 let progress = Arc::new(Mutex::new(ProgressBar::new_with_format(0, format)));
579 let progress_clone = Arc::clone(&progress);
580
581 // Start with a spinner message until we know the total
582 {
583 let pb = progress
584 .lock()
585 .map_err(|e| CliError::execution(format!("Failed to lock progress bar: {e}")))?;
586 pb.set_message(format!("Cloning repository from {url}..."));
587 }
588
589 // Perform clone operation with progress tracking
590 let result =
591 Repo::clone_with_progress(url, destination_str, depth_i32, move |current, total| {
592 if let Ok(pb) = progress_clone.lock()
593 && total > 0
594 {
595 // Update progress bar with current progress
596 // Casting is intentional: converting object counts to percentage display
597 #[allow(
598 clippy::cast_precision_loss,
599 clippy::cast_possible_truncation,
600 clippy::cast_sign_loss
601 )]
602 let percentage = (current as f64 / total as f64 * 100.0) as u64;
603 pb.set_position(current as u64);
604 pb.set_message(format!("Receiving objects: {percentage}% ({current}/{total})"));
605 }
606 });
607
608 // Handle result
609 let repo = match result {
610 Ok(repo) => {
611 // Clone succeeded
612 let pb = progress
613 .lock()
614 .map_err(|e| CliError::execution(format!("Failed to lock progress bar: {e}")))?;
615 pb.finish_with_message("✓ Clone complete");
616 repo
617 }
618 Err(e) => {
619 // Clone failed
620 let pb = progress
621 .lock()
622 .map_err(|e| CliError::execution(format!("Failed to lock progress bar: {e}")))?;
623 pb.abandon_with_message("✗ Clone failed");
624 return Err(map_git_error(&e, url));
625 }
626 };
627
628 Ok(repo)
629}
630
631/// Maps Git errors to user-friendly CLI errors with actionable messages.
632///
633/// Analyzes the Git error and provides context-specific error messages
634/// with suggestions for fixing common issues.
635///
636/// # Arguments
637///
638/// * `error` - The Git error from `sublime_git_tools`
639/// * `url` - The repository URL that was being cloned
640///
641/// # Returns
642///
643/// Returns a `CliError` with a user-friendly message and suggestions.
644///
645/// # Error Categories
646///
647/// - Network errors: Connection failed, timeout, DNS resolution
648/// - Authentication errors: Invalid credentials, SSH key issues, missing token
649/// - Disk space errors: Insufficient space, quota exceeded
650/// - Permission errors: Access denied, read-only filesystem
651/// - Repository errors: Not found, invalid URL, empty repository
652///
653/// # Examples
654///
655/// ```rust,ignore
656/// let repo = Repo::clone(url, dest).map_err(|e| map_git_error(e, url))?;
657/// ```
658fn map_git_error(error: &sublime_git_tools::RepoError, url: &str) -> CliError {
659 use sublime_git_tools::RepoError;
660
661 let error_msg = error.to_string();
662 let lowercase_msg = error_msg.to_lowercase();
663
664 // Network errors
665 if lowercase_msg.contains("failed to resolve")
666 || lowercase_msg.contains("could not resolve")
667 || lowercase_msg.contains("name or service not known")
668 {
669 return CliError::git(format!(
670 "Network error: Could not resolve host for URL: {url}\n\
671 \n\
672 Suggestions:\n\
673 - Check your internet connection\n\
674 - Verify the repository URL is correct\n\
675 - Check if the hostname is accessible"
676 ));
677 }
678
679 if lowercase_msg.contains("connection") || lowercase_msg.contains("timeout") {
680 return CliError::git(format!(
681 "Network error: Connection failed for URL: {url}\n\
682 \n\
683 Suggestions:\n\
684 - Check your internet connection\n\
685 - Verify you can access the repository host\n\
686 - Try again later if the service is temporarily unavailable"
687 ));
688 }
689
690 // Authentication errors
691 if lowercase_msg.contains("authentication")
692 || lowercase_msg.contains("credential")
693 || lowercase_msg.contains("permission denied")
694 {
695 let suggestion = if url.starts_with("git@") || url.contains("ssh://") {
696 "Suggestions:\n\
697 - Ensure your SSH key is added to your SSH agent\n\
698 - Verify your SSH key is registered with the git hosting service\n\
699 - Check SSH key permissions (should be 600 for private key)\n\
700 - Try: ssh -T git@<hostname> to test SSH connection"
701 } else {
702 "Suggestions:\n\
703 - Verify you have access to this repository\n\
704 - For private repositories, ensure authentication is configured\n\
705 - Check if your access token or credentials are valid\n\
706 - Try using SSH URL instead of HTTPS"
707 };
708
709 return CliError::git(format!(
710 "Authentication error: Failed to authenticate for URL: {url}\n\
711 \n\
712 {suggestion}"
713 ));
714 }
715
716 // Disk space errors
717 if lowercase_msg.contains("no space")
718 || lowercase_msg.contains("disk full")
719 || lowercase_msg.contains("quota exceeded")
720 {
721 return CliError::io(
722 "Disk space error: Insufficient disk space to clone repository\n\
723 \n\
724 Suggestions:\n\
725 - Free up disk space\n\
726 - Choose a different destination with more space\n\
727 - Consider using --depth 1 for a shallow clone (requires less space)"
728 .to_string(),
729 );
730 }
731
732 // Repository not found
733 if lowercase_msg.contains("not found")
734 || lowercase_msg.contains("repository not found")
735 || lowercase_msg.contains("404")
736 {
737 return CliError::git(format!(
738 "Repository error: Repository not found: {url}\n\
739 \n\
740 Suggestions:\n\
741 - Verify the repository URL is correct\n\
742 - Check if the repository exists\n\
743 - Ensure you have access to the repository if it's private"
744 ));
745 }
746
747 // Generic git error with context
748 match &error {
749 RepoError::CloneRepoFailure(e) => CliError::git(format!(
750 "Failed to clone repository from {url}: {error}\n\
751 \n\
752 Original error: {e}"
753 )),
754 _ => CliError::git(format!("Git operation failed: {error}")),
755 }
756}
757
758/// Executes the clone command.
759///
760/// This function orchestrates the complete clone workflow including:
761/// 1. Determining the destination directory
762/// 2. Validating the destination
763/// 3. Removing existing destination if --force is set
764/// 4. Cloning the repository with progress
765/// 5. Detecting and validating workspace configuration (Story 11.3)
766/// 6. Initializing workspace if needed (Story 11.4)
767///
768/// # Arguments
769///
770/// * `args` - Clone command arguments containing URL, destination, and options
771/// * `output` - Output handler for consistent formatting across all commands
772/// * `root` - Root directory for the workspace
773/// * `config_path` - Optional path to configuration file
774///
775/// # Returns
776///
777/// Returns `Ok(())` on successful clone and setup, or an error if any step fails.
778///
779/// # Errors
780///
781/// Returns an error if:
782/// - URL parsing fails
783/// - Destination validation fails
784/// - Clone operation fails (network, auth, disk space, etc.)
785/// - Workspace setup fails (Stories 11.3/11.4)
786///
787/// # Examples
788///
789/// ```rust,ignore
790/// use sublime_cli_tools::output::{Output, OutputFormat};
791/// use std::io;
792///
793/// let args = CloneArgs {
794/// url: "https://github.com/org/repo.git".to_string(),
795/// destination: None,
796/// force: false,
797/// depth: None,
798/// // ... other fields
799/// };
800///
801/// let output = Output::new(OutputFormat::Human, io::stdout(), false);
802/// execute_clone(&args, &output, Path::new("."), None).await?;
803/// ```
804pub async fn execute_clone(
805 args: &CloneArgs,
806 output: &crate::output::Output,
807 root: &Path,
808 config_path: Option<&Path>,
809) -> Result<()> {
810 // Step 1: Determine destination directory
811 let destination = determine_destination(&args.url, args.destination.as_ref())?;
812
813 // Step 2: Make destination relative to root (respecting --root global option)
814 // Absolute paths are used as-is, relative paths are joined with root
815 let final_destination =
816 if destination.is_absolute() { destination } else { root.join(destination) };
817
818 debug!("Clone destination: {} (root: {})", final_destination.display(), root.display());
819
820 // Step 3: Validate destination
821 validate_destination(&final_destination, args.force)?;
822
823 // Step 4: Remove existing destination if --force is set
824 if args.force && final_destination.exists() {
825 let fs = FileSystemManager::new();
826 fs.remove(&final_destination).await.map_err(|e| {
827 CliError::io(format!(
828 "Failed to remove existing destination {}: {}",
829 final_destination.display(),
830 e
831 ))
832 })?;
833 }
834
835 // Step 5: Clone repository with progress
836 debug!("Cloning repository from {} to {}", args.url, final_destination.display());
837 let _repo = clone_with_progress(&args.url, &final_destination, args.depth, output.format())?;
838 info!("Repository cloned successfully to {}", final_destination.display());
839
840 // Step 6: Detect workspace configuration (Story 11.3)
841 // Pass config_path parameter to allow custom config file usage
842 debug!("Detecting workspace configuration in cloned repository");
843 let config_opt = crate::commands::find_and_load_config(&final_destination, config_path).await?;
844
845 // Step 7: Handle based on configuration existence (Story 11.4)
846 if let Some(_config) = config_opt {
847 info!("Workspace configuration detected");
848
849 // Configuration exists - validate it (unless --skip-validation)
850 let validated = if args.skip_validation {
851 debug!("Skipping validation (--skip-validation flag)");
852 false
853 } else {
854 debug!("Validating workspace configuration");
855 output.info("Validating workspace configuration...")?;
856
857 let validation = validate_workspace(&final_destination).await?;
858
859 if !validation.is_valid {
860 // Validation failed - report errors
861 return Err(CliError::validation(format!(
862 "Workspace configuration validation failed:\n\n{validation}"
863 )));
864 }
865
866 info!("Workspace configuration validated successfully");
867
868 // Output validation success details
869 output.success("Workspace configuration is valid")?;
870
871 if let Some(strategy) = &validation.strategy {
872 let strategy_str = match strategy {
873 VersioningStrategy::Independent => "independent",
874 VersioningStrategy::Unified => "unified",
875 };
876 output.info(&format!(" Strategy: {strategy_str}"))?;
877 }
878
879 output.blank_line()?;
880 output.info(" All validation checks passed:")?;
881 for check in &validation.checks {
882 if check.passed {
883 output.info(&format!(" ✓ {}", check.name))?;
884 }
885 }
886 output.blank_line()?;
887
888 true
889 };
890
891 // Output clone completion (without init)
892 output_clone_complete_internal(&final_destination, validated, output)?;
893 } else {
894 info!("No workspace configuration found, initializing workspace");
895
896 // No configuration - run init automatically
897 output.info("No workspace configuration found. Starting initialization...")?;
898 output.blank_line()?;
899
900 // Convert CloneArgs to InitArgs with configuration merge
901 // Note: No workspace config exists yet, so we only use CLI args + defaults
902 let init_args = convert_to_init_args(args, None);
903
904 // Execute init command
905 debug!("Executing init to create workspace configuration");
906
907 // For JSON output modes, we need to suppress init's output and only output clone's
908 // comprehensive JSON. For Human mode, we let init output and then add clone's message.
909 match output.format() {
910 OutputFormat::Json | OutputFormat::JsonCompact => {
911 // Create a quiet output for init to prevent double JSON output
912 use std::io::Cursor;
913 let buffer = Cursor::new(Vec::new());
914 let init_output = crate::output::Output::new(
915 OutputFormat::Quiet,
916 Box::new(buffer),
917 output.no_color(),
918 );
919
920 crate::commands::init::execute_init(
921 &init_args,
922 &init_output,
923 &final_destination,
924 None,
925 )
926 .await?;
927 }
928 OutputFormat::Human | OutputFormat::Quiet => {
929 // For Human and Quiet modes, let init output normally
930 crate::commands::init::execute_init(&init_args, output, &final_destination, None)
931 .await?;
932 }
933 }
934
935 // Output clone completion (with init)
936 output_clone_complete_with_init_internal(&final_destination, output)?;
937 }
938
939 Ok(())
940}
941
942/// Converts CloneArgs to InitArgs with configuration merge logic.
943///
944/// Implements the merge priority: CLI args > workspace config > defaults.
945///
946/// This ensures that:
947/// - CLI arguments always take precedence when explicitly provided
948/// - Workspace configuration is used when no CLI arg is provided
949/// - Sensible defaults are used as final fallback
950///
951/// # Arguments
952///
953/// * `args` - Clone command arguments
954/// * `workspace_config` - Optional workspace configuration from package.json
955///
956/// # Returns
957///
958/// Returns an `InitArgs` structure with merged configuration values.
959///
960/// # Errors
961///
962/// Returns an error if the merge results in invalid configuration.
963///
964/// # Examples
965///
966/// ```rust,ignore
967/// // With CLI args only (no workspace config)
968/// let clone_args = CloneArgs {
969/// strategy: Some("independent".to_string()),
970/// environments: Some(vec!["dev".to_string(), "prod".to_string()]),
971/// ..Default::default()
972/// };
973/// let init_args = convert_to_init_args(&clone_args, None)?;
974/// assert_eq!(init_args.strategy, Some("independent".to_string()));
975///
976/// // With workspace config fallback
977/// let workspace_config = PackageToolsConfig::default();
978/// let init_args = convert_to_init_args(&clone_args, Some(&workspace_config))?;
979/// ```
980pub(crate) fn convert_to_init_args(
981 args: &CloneArgs,
982 workspace_config: Option<&PackageToolsConfig>,
983) -> InitArgs {
984 // Merge changeset_path: CLI > workspace > default
985 let changeset_path = if let Some(ref path) = args.changeset_path {
986 PathBuf::from(path)
987 } else if let Some(config) = workspace_config {
988 PathBuf::from(&config.changeset.path)
989 } else {
990 PathBuf::from(".changesets")
991 };
992
993 // Merge environments: CLI > workspace > default
994 let environments = if args.environments.is_some() {
995 args.environments.clone()
996 } else {
997 workspace_config.map(|config| config.changeset.available_environments.clone())
998 };
999
1000 // Merge default_env: CLI > workspace > default
1001 let default_env = if args.default_env.is_some() {
1002 args.default_env.clone()
1003 } else {
1004 workspace_config.map(|config| config.changeset.default_environments.clone())
1005 };
1006
1007 // Merge strategy: CLI > workspace > default
1008 let strategy = if args.strategy.is_some() {
1009 args.strategy.clone()
1010 } else {
1011 workspace_config.map(|config| match config.version.strategy {
1012 sublime_pkg_tools::types::VersioningStrategy::Independent => "independent".to_string(),
1013 sublime_pkg_tools::types::VersioningStrategy::Unified => "unified".to_string(),
1014 })
1015 };
1016
1017 // Merge registry: CLI > workspace > default
1018 let registry = if let Some(ref reg) = args.registry {
1019 reg.clone()
1020 } else if let Some(config) = workspace_config {
1021 config.upgrade.registry.default_registry.clone()
1022 } else {
1023 "https://registry.npmjs.org".to_string()
1024 };
1025
1026 // Merge config_format: CLI > workspace > default
1027 let config_format = if args.config_format.is_some() {
1028 args.config_format.clone()
1029 } else {
1030 None // Will use init's default prompts
1031 };
1032
1033 InitArgs {
1034 changeset_path,
1035 environments,
1036 default_env,
1037 strategy,
1038 registry,
1039 config_format,
1040 force: false, // Never force during clone
1041 non_interactive: args.non_interactive,
1042 }
1043}
1044
1045/// Outputs clone completion message (without init) using Output methods.
1046///
1047/// # Arguments
1048///
1049/// * `destination` - Destination directory path
1050/// * `validated` - Whether workspace configuration was validated
1051/// * `output` - Output handler for consistent formatting
1052///
1053/// # Errors
1054///
1055/// Returns an error if output writing fails.
1056///
1057/// # Examples
1058///
1059/// ```rust,ignore
1060/// use sublime_cli_tools::output::{Output, OutputFormat};
1061/// use std::io;
1062///
1063/// let output = Output::new(OutputFormat::Human, io::stdout(), false);
1064/// output_clone_complete_internal(Path::new("./repo"), true, &output)?;
1065/// ```
1066fn output_clone_complete_internal(
1067 destination: &Path,
1068 validated: bool,
1069 output: &crate::output::Output,
1070) -> Result<()> {
1071 match output.format() {
1072 OutputFormat::Human => {
1073 output.blank_line()?;
1074 output.success("Clone completed successfully!")?;
1075 output.blank_line()?;
1076 output.info(&format!(" Location: {}", destination.display()))?;
1077 output.blank_line()?;
1078 output.info("Next steps:")?;
1079 output.info(&format!(" cd {}", destination.display()))?;
1080 output.info(" workspace changeset add # Create a changeset")?;
1081 output.info(" workspace bump --dry-run # Preview version bump")?;
1082 output.blank_line()?;
1083 Ok(())
1084 }
1085 OutputFormat::Json | OutputFormat::JsonCompact => {
1086 #[derive(serde::Serialize)]
1087 #[allow(non_snake_case)]
1088 struct CloneResponse {
1089 success: bool,
1090 destination: String,
1091 outcome: CloneOutcome,
1092 }
1093
1094 #[derive(serde::Serialize)]
1095 #[allow(non_snake_case)]
1096 enum CloneOutcome {
1097 ExistingConfigValidated,
1098 ExistingConfigUnvalidated,
1099 }
1100
1101 let response = CloneResponse {
1102 success: true,
1103 destination: destination.display().to_string(),
1104 outcome: if validated {
1105 CloneOutcome::ExistingConfigValidated
1106 } else {
1107 CloneOutcome::ExistingConfigUnvalidated
1108 },
1109 };
1110
1111 let json_response = crate::output::JsonResponse::success(response);
1112 output.json(&json_response)?;
1113 Ok(())
1114 }
1115 OutputFormat::Quiet => {
1116 output.plain(&format!("Clone completed: {}", destination.display()))?;
1117 Ok(())
1118 }
1119 }
1120}
1121
1122/// Outputs clone completion message (with init) using Output methods.
1123///
1124/// # Arguments
1125///
1126/// * `destination` - Destination directory path
1127/// * `output` - Output handler for consistent formatting
1128///
1129/// # Errors
1130///
1131/// Returns an error if output writing fails.
1132///
1133/// # Examples
1134///
1135/// ```rust,ignore
1136/// use sublime_cli_tools::output::{Output, OutputFormat};
1137/// use std::io;
1138///
1139/// let output = Output::new(OutputFormat::Human, io::stdout(), false);
1140/// output_clone_complete_with_init_internal(Path::new("./repo"), &output)?;
1141/// ```
1142fn output_clone_complete_with_init_internal(
1143 destination: &Path,
1144 output: &crate::output::Output,
1145) -> Result<()> {
1146 match output.format() {
1147 OutputFormat::Human => {
1148 output.blank_line()?;
1149 output.success("Clone and initialization completed successfully!")?;
1150 output.blank_line()?;
1151 output.info(&format!(" Location: {}", destination.display()))?;
1152 output.blank_line()?;
1153 output.info("Next steps:")?;
1154 output.info(&format!(" cd {}", destination.display()))?;
1155 output.info(" workspace changeset add # Create your first changeset")?;
1156 output.info(" workspace bump --dry-run # Preview version bump")?;
1157 output.blank_line()?;
1158 Ok(())
1159 }
1160 OutputFormat::Json | OutputFormat::JsonCompact => {
1161 #[derive(serde::Serialize)]
1162 #[allow(non_snake_case)]
1163 struct CloneResponse {
1164 success: bool,
1165 destination: String,
1166 outcome: CloneOutcome,
1167 }
1168
1169 #[derive(serde::Serialize)]
1170 #[allow(non_snake_case)]
1171 enum CloneOutcome {
1172 NewWorkspaceInitialized,
1173 }
1174
1175 let response = CloneResponse {
1176 success: true,
1177 destination: destination.display().to_string(),
1178 outcome: CloneOutcome::NewWorkspaceInitialized,
1179 };
1180
1181 let json_response = crate::output::JsonResponse::success(response);
1182 output.json(&json_response)?;
1183 Ok(())
1184 }
1185 OutputFormat::Quiet => {
1186 output.plain(&format!("Clone and init completed: {}", destination.display()))?;
1187 Ok(())
1188 }
1189 }
1190}