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}