agpm_cli/cli/
common.rs

1//! Common utilities and shared functionality for CLI commands.
2//!
3//! This module provides reusable infrastructure for install and update commands:
4//!
5//! # Core Components
6//!
7//! - **`CommandExecutor` trait**: Standardized command execution pattern
8//! - **`CommandContext`**: Manifest and lockfile management utilities
9//! - **`OperationMode` enum**: Distinguishes install vs update for shared logic
10//!
11//! # Shared Display Helpers
12//!
13//! - **`display_dry_run_results()`**: Rich dry-run output with CI exit codes
14//! - **`display_no_changes()`**: Context-appropriate "no changes" messages
15//!
16//! # Legacy Support
17//!
18//! - **`handle_legacy_ccpm_migration()`**: CCPM to AGPM migration utilities
19//! - **`check_for_legacy_ccpm_files()`**: Detects old CCPM installations
20//!
21//! # Lockfile Management
22//!
23//! - **`load_lockfile()`**: Loads lockfile with automatic regeneration
24//! - **`save_lockfile()`**: Saves lockfile with error handling
25//! - **`backup_and_regenerate_lockfile()`**: Recovery from corrupted lockfiles
26
27use anyhow::{Context, Result};
28use colored::Colorize;
29use std::io::{self, IsTerminal, Write};
30use std::path::{Path, PathBuf};
31use tokio::io::{AsyncBufReadExt, BufReader};
32
33use crate::manifest::{Manifest, find_manifest};
34
35/// Common trait for CLI command execution pattern
36pub trait CommandExecutor: Sized {
37    /// Execute the command, finding the manifest automatically
38    fn execute(self) -> impl std::future::Future<Output = Result<()>> + Send
39    where
40        Self: Send,
41    {
42        async move {
43            let manifest_path = if let Ok(path) = find_manifest() {
44                path
45            } else {
46                // Check if legacy CCPM files exist and offer interactive migration
47                match handle_legacy_ccpm_migration().await {
48                    Ok(Some(path)) => path,
49                    Ok(None) => {
50                        return Err(anyhow::anyhow!(
51                            "No agpm.toml found in current directory or any parent directory. \
52                             Run 'agpm init' to create a new project."
53                        ));
54                    }
55                    Err(e) => return Err(e),
56                }
57            };
58            self.execute_from_path(manifest_path).await
59        }
60    }
61
62    /// Execute the command with a specific manifest path
63    fn execute_from_path(
64        self,
65        manifest_path: PathBuf,
66    ) -> impl std::future::Future<Output = Result<()>> + Send;
67}
68
69/// Common context for CLI commands that need manifest and project information
70#[derive(Debug)]
71pub struct CommandContext {
72    /// Parsed project manifest (agpm.toml)
73    pub manifest: Manifest,
74    /// Path to the manifest file
75    pub manifest_path: PathBuf,
76    /// Project root directory (containing agpm.toml)
77    pub project_dir: PathBuf,
78    /// Path to the lockfile (agpm.lock)
79    pub lockfile_path: PathBuf,
80}
81
82impl CommandContext {
83    /// Create a new command context from a manifest and project directory
84    pub fn new(manifest: Manifest, project_dir: PathBuf) -> Result<Self> {
85        let lockfile_path = project_dir.join("agpm.lock");
86        Ok(Self {
87            manifest,
88            manifest_path: project_dir.join("agpm.toml"),
89            project_dir,
90            lockfile_path,
91        })
92    }
93
94    /// Create a new command context from a manifest path
95    ///
96    /// # Errors
97    /// Returns an error if the manifest file doesn't exist or cannot be read
98    pub fn from_manifest_path(manifest_path: impl AsRef<Path>) -> Result<Self> {
99        let manifest_path = manifest_path.as_ref();
100
101        if !manifest_path.exists() {
102            return Err(anyhow::anyhow!("Manifest file {} not found", manifest_path.display()));
103        }
104
105        let project_dir = manifest_path
106            .parent()
107            .ok_or_else(|| anyhow::anyhow!("Invalid manifest path"))?
108            .to_path_buf();
109
110        let manifest = Manifest::load(manifest_path).with_context(|| {
111            format!("Failed to parse manifest file: {}", manifest_path.display())
112        })?;
113
114        let lockfile_path = project_dir.join("agpm.lock");
115
116        Ok(Self {
117            manifest,
118            manifest_path: manifest_path.to_path_buf(),
119            project_dir,
120            lockfile_path,
121        })
122    }
123
124    /// Load an existing lockfile if it exists
125    ///
126    /// # Errors
127    /// Returns an error if the lockfile exists but cannot be parsed
128    pub fn load_lockfile(&self) -> Result<Option<crate::lockfile::LockFile>> {
129        if self.lockfile_path.exists() {
130            let lockfile =
131                crate::lockfile::LockFile::load(&self.lockfile_path).with_context(|| {
132                    format!("Failed to load lockfile: {}", self.lockfile_path.display())
133                })?;
134            Ok(Some(lockfile))
135        } else {
136            Ok(None)
137        }
138    }
139
140    /// Load an existing lockfile with automatic regeneration for invalid files
141    ///
142    /// If the lockfile exists but is invalid or corrupted, this method will
143    /// offer to automatically regenerate it. This provides a better user
144    /// experience by recovering from common lockfile issues.
145    ///
146    /// # Arguments
147    ///
148    /// * `can_regenerate` - Whether automatic regeneration should be offered
149    /// * `operation_name` - Name of the operation for error messages (e.g., "list")
150    ///
151    /// # Returns
152    ///
153    /// * `Ok(Some(lockfile))` - Successfully loaded or regenerated lockfile
154    /// * `Ok(None)` - No lockfile exists (not an error)
155    /// * `Err` - Critical error that cannot be recovered from
156    ///
157    /// # Behavior
158    ///
159    /// - **Interactive mode** (TTY): Prompts user with Y/n confirmation
160    /// - **Non-interactive mode** (CI/CD): Fails with helpful error message
161    /// - **Backup strategy**: Copies invalid lockfile to `agpm.lock.invalid` before regeneration
162    ///
163    /// # Examples
164    ///
165    /// ```no_run
166    /// # use anyhow::Result;
167    /// # use agpm_cli::cli::common::CommandContext;
168    /// # use agpm_cli::manifest::Manifest;
169    /// # use std::path::PathBuf;
170    /// # async fn example() -> Result<()> {
171    /// let manifest = Manifest::load(&PathBuf::from("agpm.toml"))?;
172    /// let project_dir = PathBuf::from(".");
173    /// let ctx = CommandContext::new(manifest, project_dir)?;
174    /// match ctx.load_lockfile_with_regeneration(true, "list") {
175    ///     Ok(Some(lockfile)) => println!("Loaded lockfile"),
176    ///     Ok(None) => println!("No lockfile found"),
177    ///     Err(e) => eprintln!("Error: {}", e),
178    /// }
179    /// # Ok(())
180    /// # }
181    /// ```
182    pub fn load_lockfile_with_regeneration(
183        &self,
184        can_regenerate: bool,
185        operation_name: &str,
186    ) -> Result<Option<crate::lockfile::LockFile>> {
187        // If lockfile doesn't exist, that's not an error
188        if !self.lockfile_path.exists() {
189            return Ok(None);
190        }
191
192        // Try to load the lockfile
193        match crate::lockfile::LockFile::load(&self.lockfile_path) {
194            Ok(lockfile) => Ok(Some(lockfile)),
195            Err(e) => {
196                // Analyze the error to see if it's recoverable
197                let error_msg = e.to_string();
198                let can_auto_recover = can_regenerate
199                    && (error_msg.contains("Invalid TOML syntax")
200                        || error_msg.contains("Lockfile version")
201                        || error_msg.contains("missing field")
202                        || error_msg.contains("invalid type")
203                        || error_msg.contains("expected"));
204
205                if !can_auto_recover {
206                    // Not a recoverable error, return the original error
207                    return Err(e);
208                }
209
210                // This is a recoverable error, offer regeneration
211                let backup_path = self.lockfile_path.with_extension("lock.invalid");
212
213                // Create user-friendly message
214                let regenerate_message = format!(
215                    "The lockfile appears to be invalid or corrupted.\n\n\
216                     Error: {}\n\n\
217                     Note: The lockfile format is not yet stable as this is beta software.\n\n\
218                     The invalid lockfile will be backed up to: {}",
219                    error_msg,
220                    backup_path.display()
221                );
222
223                // Check if we're in interactive mode
224                if io::stdin().is_terminal() {
225                    // Interactive mode: prompt user
226                    println!("{}", regenerate_message);
227                    print!("Would you like to regenerate the lockfile automatically? [Y/n] ");
228                    io::stdout().flush().unwrap();
229
230                    let mut input = String::new();
231                    match io::stdin().read_line(&mut input) {
232                        Ok(_) => {
233                            let response = input.trim().to_lowercase();
234                            if response.is_empty() || response == "y" || response == "yes" {
235                                // User agreed to regenerate
236                                self.backup_and_regenerate_lockfile(&backup_path, operation_name)?;
237                                Ok(None) // Return None so caller creates new lockfile
238                            } else {
239                                // User declined, return the original error
240                                Err(crate::core::AgpmError::InvalidLockfileError {
241                                    file: self.lockfile_path.display().to_string(),
242                                    reason: format!(
243                                        "{} (User declined automatic regeneration)",
244                                        error_msg
245                                    ),
246                                    can_regenerate: true,
247                                }
248                                .into())
249                            }
250                        }
251                        Err(_) => {
252                            // Failed to read input, fall back to non-interactive behavior
253                            Err(self.create_non_interactive_error(&error_msg, operation_name))
254                        }
255                    }
256                } else {
257                    // Non-interactive mode: fail with helpful message
258                    Err(self.create_non_interactive_error(&error_msg, operation_name))
259                }
260            }
261        }
262    }
263
264    /// Backup the invalid lockfile and display regeneration instructions
265    fn backup_and_regenerate_lockfile(
266        &self,
267        backup_path: &Path,
268        operation_name: &str,
269    ) -> Result<()> {
270        // Backup the invalid lockfile
271        if let Err(e) = std::fs::copy(&self.lockfile_path, backup_path) {
272            eprintln!("Warning: Failed to backup invalid lockfile: {}", e);
273        } else {
274            println!("✓ Backed up invalid lockfile to: {}", backup_path.display());
275        }
276
277        // Remove the invalid lockfile
278        if let Err(e) = std::fs::remove_file(&self.lockfile_path) {
279            return Err(anyhow::anyhow!("Failed to remove invalid lockfile: {}", e));
280        }
281
282        println!("✓ Removed invalid lockfile");
283        println!("Note: Run 'agpm install' to regenerate the lockfile");
284
285        // If this is not an install command, suggest running install
286        if operation_name != "install" {
287            println!("Alternatively, run 'agpm {} --regenerate' if available", operation_name);
288        }
289
290        Ok(())
291    }
292
293    /// Create a non-interactive error message for CI/CD environments
294    fn create_non_interactive_error(
295        &self,
296        error_msg: &str,
297        _operation_name: &str,
298    ) -> anyhow::Error {
299        let backup_path = self.lockfile_path.with_extension("lock.invalid");
300
301        crate::core::AgpmError::InvalidLockfileError {
302            file: self.lockfile_path.display().to_string(),
303            reason: format!(
304                "{}\n\n\
305                 To fix this issue:\n\
306                 1. Backup the invalid lockfile: cp agpm.lock {}\n\
307                 2. Remove the invalid lockfile: rm agpm.lock\n\
308                 3. Regenerate it: agpm install\n\n\
309                 Note: The lockfile format is not yet stable as this is beta software.",
310                error_msg,
311                backup_path.display()
312            ),
313            can_regenerate: true,
314        }
315        .into()
316    }
317
318    /// Save a lockfile to the project directory
319    ///
320    /// # Errors
321    /// Returns an error if the lockfile cannot be written
322    pub fn save_lockfile(&self, lockfile: &crate::lockfile::LockFile) -> Result<()> {
323        lockfile
324            .save(&self.lockfile_path)
325            .with_context(|| format!("Failed to save lockfile: {}", self.lockfile_path.display()))
326    }
327}
328
329/// Handle legacy CCPM files by offering interactive migration.
330///
331/// This function searches for ccpm.toml and ccpm.lock files in the current
332/// directory and parent directories. If found, it prompts the user to migrate
333/// and performs the migration if they accept.
334///
335/// # Behavior
336///
337/// - **Interactive mode**: Prompts user with Y/n confirmation (stdin is a TTY)
338/// - **Non-interactive mode**: Returns `Ok(None)` if stdin is not a TTY (e.g., CI/CD)
339/// - **Search scope**: Traverses from current directory to filesystem root
340///
341/// # Returns
342///
343/// - `Ok(Some(PathBuf))` with the path to agpm.toml if migration succeeded
344/// - `Ok(None)` if no legacy files were found OR user declined OR non-interactive mode
345/// - `Err` if migration failed
346///
347/// # Examples
348///
349/// ```no_run
350/// # use anyhow::Result;
351/// # async fn example() -> Result<()> {
352/// use agpm_cli::cli::common::handle_legacy_ccpm_migration;
353///
354/// match handle_legacy_ccpm_migration().await? {
355///     Some(path) => println!("Migrated to: {}", path.display()),
356///     None => println!("No migration performed"),
357/// }
358/// # Ok(())
359/// # }
360/// ```
361///
362/// # Errors
363///
364/// Returns an error if:
365/// - Unable to access current directory
366/// - Unable to perform migration operations
367pub async fn handle_legacy_ccpm_migration() -> Result<Option<PathBuf>> {
368    let current_dir = std::env::current_dir()?;
369    let legacy_dir = find_legacy_ccpm_directory(&current_dir);
370
371    let Some(dir) = legacy_dir else {
372        return Ok(None);
373    };
374
375    // Check if we're in an interactive terminal
376    if !std::io::stdin().is_terminal() {
377        // Non-interactive mode: Don't prompt, just inform and exit
378        eprintln!("{}", "Legacy CCPM files detected (non-interactive mode).".yellow());
379        eprintln!(
380            "Run {} to migrate manually.",
381            format!("agpm migrate --path {}", dir.display()).cyan()
382        );
383        return Ok(None);
384    }
385
386    // Found legacy files - prompt for migration
387    let ccpm_toml = dir.join("ccpm.toml");
388    let ccpm_lock = dir.join("ccpm.lock");
389
390    let mut files = Vec::new();
391    if ccpm_toml.exists() {
392        files.push("ccpm.toml");
393    }
394    if ccpm_lock.exists() {
395        files.push("ccpm.lock");
396    }
397
398    let files_str = files.join(" and ");
399
400    println!("{}", "Legacy CCPM files detected!".yellow().bold());
401    println!("{} {} found in {}", "→".cyan(), files_str, dir.display());
402    println!();
403
404    // Prompt user for migration
405    print!("{} ", "Would you like to migrate to AGPM now? [Y/n]:".green());
406    io::stdout().flush()?;
407
408    // Use async I/O for proper integration with Tokio runtime
409    let mut reader = BufReader::new(tokio::io::stdin());
410    let mut response = String::new();
411    reader.read_line(&mut response).await?;
412    let response = response.trim().to_lowercase();
413
414    if response.is_empty() || response == "y" || response == "yes" {
415        println!();
416        println!("{}", "🚀 Starting migration...".cyan());
417
418        // Perform the migration with automatic installation
419        let migrate_cmd = super::migrate::MigrateCommand::new(Some(dir.clone()), false, false);
420
421        migrate_cmd.execute().await?;
422
423        // Return the path to the newly created agpm.toml
424        Ok(Some(dir.join("agpm.toml")))
425    } else {
426        println!();
427        println!("{}", "Migration cancelled.".yellow());
428        println!(
429            "Run {} to migrate manually.",
430            format!("agpm migrate --path {}", dir.display()).cyan()
431        );
432        Ok(None)
433    }
434}
435
436/// Check for legacy CCPM files and return a migration message if found.
437///
438/// This function searches for ccpm.toml and ccpm.lock files in the current
439/// directory and parent directories, similar to how `find_manifest` works.
440/// If legacy files are found, it returns a helpful error message suggesting
441/// to run the migration command.
442///
443/// # Returns
444///
445/// - `Some(String)` with migration instructions if legacy files are found
446/// - `None` if no legacy files are detected
447#[must_use]
448pub fn check_for_legacy_ccpm_files() -> Option<String> {
449    check_for_legacy_ccpm_files_from(std::env::current_dir().ok()?)
450}
451
452/// Find the directory containing legacy CCPM files.
453///
454/// Searches for ccpm.toml or ccpm.lock starting from the given directory
455/// and walking up the directory tree.
456///
457/// # Returns
458///
459/// - `Some(PathBuf)` with the directory containing legacy files
460/// - `None` if no legacy files are found
461fn find_legacy_ccpm_directory(start_dir: &Path) -> Option<PathBuf> {
462    let mut dir = start_dir;
463
464    loop {
465        let ccpm_toml = dir.join("ccpm.toml");
466        let ccpm_lock = dir.join("ccpm.lock");
467
468        if ccpm_toml.exists() || ccpm_lock.exists() {
469            return Some(dir.to_path_buf());
470        }
471
472        dir = dir.parent()?;
473    }
474}
475
476/// Check for legacy CCPM files starting from a specific directory.
477///
478/// This is the internal implementation that allows for testing without
479/// changing the current working directory.
480fn check_for_legacy_ccpm_files_from(start_dir: PathBuf) -> Option<String> {
481    let current = start_dir;
482    let mut dir = current.as_path();
483
484    loop {
485        let ccpm_toml = dir.join("ccpm.toml");
486        let ccpm_lock = dir.join("ccpm.lock");
487
488        if ccpm_toml.exists() || ccpm_lock.exists() {
489            let mut files = Vec::new();
490            if ccpm_toml.exists() {
491                files.push("ccpm.toml");
492            }
493            if ccpm_lock.exists() {
494                files.push("ccpm.lock");
495            }
496
497            let files_str = files.join(" and ");
498            let location = if dir == current {
499                "current directory".to_string()
500            } else {
501                format!("parent directory: {}", dir.display())
502            };
503
504            return Some(format!(
505                "{}\n\n{} {} found in {}.\n{}\n  {}\n\n{}",
506                "Legacy CCPM files detected!".yellow().bold(),
507                "→".cyan(),
508                files_str,
509                location,
510                "Run the migration command to upgrade:".yellow(),
511                format!("agpm migrate --path {}", dir.display()).cyan().bold(),
512                "Or run 'agpm init' to create a new AGPM project.".dimmed()
513            ));
514        }
515
516        dir = dir.parent()?;
517    }
518}
519
520/// Determines the type of operation being performed for user-facing messages.
521///
522/// This enum distinguishes between install and update operations to provide
523/// appropriate feedback messages and exit codes. Used by shared helper functions
524/// like `display_dry_run_results()` and `display_no_changes()` to customize
525/// behavior based on the operation context.
526///
527/// # Examples
528///
529/// ```rust
530/// use agpm_cli::cli::common::OperationMode;
531///
532/// let mode = OperationMode::Install;
533/// // Used to determine appropriate "no changes" message:
534/// // Install: "No dependencies to install"
535/// // Update: "All dependencies are up to date!"
536/// ```
537#[derive(Debug, Clone, Copy, PartialEq, Eq)]
538pub enum OperationMode {
539    /// Fresh installation operation (agpm install)
540    Install,
541    /// Dependency update operation (agpm update)
542    Update,
543}
544
545/// Display dry-run results with rich categorization of changes.
546///
547/// Shows new resources, updated resources, and unchanged count.
548/// **IMPORTANT**: Returns an error (exit code 1) if changes are detected,
549/// making this suitable for CI validation workflows.
550///
551/// # Arguments
552///
553/// * `new_lockfile` - The lockfile that would be created
554/// * `existing_lockfile` - The current lockfile if it exists
555/// * `quiet` - Whether to suppress output
556///
557/// # Returns
558///
559/// * `Ok(())` - No changes detected (exit code 0)
560/// * `Err(...)` - Changes detected (exit code 1 for CI validation)
561///
562/// # CI/CD Usage
563///
564/// This function is designed for CI validation workflows where you want
565/// to detect if running install/update would make changes:
566///
567/// ```bash
568/// # CI pipeline check - fails if dependencies need updating
569/// agpm install --dry-run  # Exit code 1 if changes needed
570/// agpm update --dry-run   # Exit code 1 if updates available
571/// ```
572///
573/// # Examples
574///
575/// ```no_run
576/// # use anyhow::Result;
577/// # use agpm_cli::cli::common::display_dry_run_results;
578/// # use agpm_cli::lockfile::LockFile;
579/// # fn example() -> Result<()> {
580/// let new_lockfile = LockFile::new();
581/// let existing_lockfile = None;
582///
583/// // In CI: this will return Err if changes detected
584/// display_dry_run_results(
585///     &new_lockfile,
586///     existing_lockfile.as_ref(),
587///     false,
588/// )?;
589/// # Ok(())
590/// # }
591/// ```
592///
593/// # Output Format
594///
595/// When changes are detected, displays:
596/// - **New resources**: Resources that would be installed (green)
597/// - **Updated resources**: Resources that would be updated (yellow)
598/// - **Unchanged count**: Resources that are already up to date (dimmed)
599pub fn display_dry_run_results(
600    new_lockfile: &crate::lockfile::LockFile,
601    existing_lockfile: Option<&crate::lockfile::LockFile>,
602    quiet: bool,
603) -> Result<()> {
604    // 1. Categorize changes
605    let (new_resources, updated_resources, unchanged_count) =
606        categorize_resource_changes(new_lockfile, existing_lockfile);
607
608    // 2. Display results
609    let has_changes = !new_resources.is_empty() || !updated_resources.is_empty();
610    display_dry_run_output(&new_resources, &updated_resources, unchanged_count, quiet);
611
612    // 3. Return CI exit code
613    if has_changes {
614        Err(anyhow::anyhow!("Dry-run detected changes (exit 1)"))
615    } else {
616        Ok(())
617    }
618}
619
620/// Represents a new resource to be installed.
621#[derive(Debug, Clone)]
622struct NewResource {
623    resource_type: String,
624    name: String,
625    version: String,
626}
627
628/// Represents a resource being updated.
629#[derive(Debug, Clone)]
630struct UpdatedResource {
631    resource_type: String,
632    name: String,
633    old_version: String,
634    new_version: String,
635}
636
637/// Categorize resources into new, updated, and unchanged.
638///
639/// Compares a new lockfile against an existing lockfile to determine what has changed.
640/// Returns tuple of (new_resources, updated_resources, unchanged_count).
641fn categorize_resource_changes(
642    new_lockfile: &crate::lockfile::LockFile,
643    existing_lockfile: Option<&crate::lockfile::LockFile>,
644) -> (Vec<NewResource>, Vec<UpdatedResource>, usize) {
645    use crate::core::resource_iterator::ResourceIterator;
646
647    let mut new_resources = Vec::new();
648    let mut updated_resources = Vec::new();
649    let mut unchanged_count = 0;
650
651    // Compare lockfiles to find changes
652    if let Some(existing) = existing_lockfile {
653        ResourceIterator::for_each_resource(new_lockfile, |resource_type, new_entry| {
654            // Find corresponding entry in existing lockfile
655            if let Some((_, old_entry)) = ResourceIterator::find_resource_by_name_and_source(
656                existing,
657                &new_entry.name,
658                new_entry.source.as_deref(),
659            ) {
660                // Check if it was updated
661                if old_entry.resolved_commit == new_entry.resolved_commit {
662                    unchanged_count += 1;
663                } else {
664                    let old_version =
665                        old_entry.version.clone().unwrap_or_else(|| "latest".to_string());
666                    let new_version =
667                        new_entry.version.clone().unwrap_or_else(|| "latest".to_string());
668                    updated_resources.push(UpdatedResource {
669                        resource_type: resource_type.to_string(),
670                        name: new_entry.name.clone(),
671                        old_version,
672                        new_version,
673                    });
674                }
675            } else {
676                // New resource
677                new_resources.push(NewResource {
678                    resource_type: resource_type.to_string(),
679                    name: new_entry.name.clone(),
680                    version: new_entry.version.clone().unwrap_or_else(|| "latest".to_string()),
681                });
682            }
683        });
684    } else {
685        // No existing lockfile, everything is new
686        ResourceIterator::for_each_resource(new_lockfile, |resource_type, new_entry| {
687            new_resources.push(NewResource {
688                resource_type: resource_type.to_string(),
689                name: new_entry.name.clone(),
690                version: new_entry.version.clone().unwrap_or_else(|| "latest".to_string()),
691            });
692        });
693    }
694
695    (new_resources, updated_resources, unchanged_count)
696}
697
698/// Format and display dry-run results.
699///
700/// Displays new resources, updated resources, and unchanged count with rich formatting.
701/// Shows nothing if quiet mode is enabled.
702fn display_dry_run_output(
703    new_resources: &[NewResource],
704    updated_resources: &[UpdatedResource],
705    unchanged_count: usize,
706    quiet: bool,
707) {
708    if quiet {
709        return;
710    }
711
712    let has_changes = !new_resources.is_empty() || !updated_resources.is_empty();
713
714    if has_changes {
715        println!("{}", "Dry run - the following changes would be made:".yellow());
716        println!();
717
718        if !new_resources.is_empty() {
719            println!("{}", "New resources:".green().bold());
720            for resource in new_resources {
721                println!(
722                    "  {} {} ({})",
723                    "+".green(),
724                    resource.name.cyan(),
725                    format!("{} {}", resource.resource_type, resource.version).dimmed()
726                );
727            }
728            println!();
729        }
730
731        if !updated_resources.is_empty() {
732            println!("{}", "Updated resources:".yellow().bold());
733            for resource in updated_resources {
734                print!(
735                    "  {} {} {} → ",
736                    "~".yellow(),
737                    resource.name.cyan(),
738                    resource.old_version.yellow()
739                );
740                println!("{} ({})", resource.new_version.green(), resource.resource_type.dimmed());
741            }
742            println!();
743        }
744
745        if unchanged_count > 0 {
746            println!("{}", format!("{unchanged_count} unchanged resources").dimmed());
747        }
748
749        println!();
750        println!(
751            "{}",
752            format!(
753                "Total: {} new, {} updated, {} unchanged",
754                new_resources.len(),
755                updated_resources.len(),
756                unchanged_count
757            )
758            .bold()
759        );
760        println!();
761        println!("{}", "No files were modified (dry-run mode)".yellow());
762    } else {
763        println!("✓ {}", "No changes would be made".green());
764    }
765}
766
767/// Display "no changes" message appropriate for the operation mode.
768///
769/// Shows a message indicating no changes were made, with different messages
770/// depending on whether this was an install or update operation.
771///
772/// # Arguments
773///
774/// * `mode` - The operation mode (install or update)
775/// * `quiet` - Whether to suppress output
776///
777/// # Examples
778///
779/// ```no_run
780/// use agpm_cli::cli::common::{display_no_changes, OperationMode};
781///
782/// display_no_changes(OperationMode::Install, false);
783/// display_no_changes(OperationMode::Update, false);
784/// ```
785pub fn display_no_changes(mode: OperationMode, quiet: bool) {
786    if quiet {
787        return;
788    }
789
790    match mode {
791        OperationMode::Install => println!("No dependencies to install"),
792        OperationMode::Update => println!("All dependencies are up to date!"),
793    }
794}
795
796#[cfg(test)]
797mod tests {
798    use super::*;
799    use tempfile::TempDir;
800
801    #[test]
802    fn test_command_context_from_manifest_path() {
803        let temp_dir = TempDir::new().unwrap();
804        let manifest_path = temp_dir.path().join("agpm.toml");
805
806        // Create a test manifest
807        std::fs::write(
808            &manifest_path,
809            r#"
810[sources]
811test = "https://github.com/test/repo.git"
812
813[agents]
814"#,
815        )
816        .unwrap();
817
818        let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
819
820        assert_eq!(context.manifest_path, manifest_path);
821        assert_eq!(context.project_dir, temp_dir.path());
822        assert_eq!(context.lockfile_path, temp_dir.path().join("agpm.lock"));
823        assert!(context.manifest.sources.contains_key("test"));
824    }
825
826    #[test]
827    fn test_command_context_missing_manifest() {
828        let result = CommandContext::from_manifest_path("/nonexistent/agpm.toml");
829        assert!(result.is_err());
830        assert!(result.unwrap_err().to_string().contains("not found"));
831    }
832
833    #[test]
834    fn test_command_context_invalid_manifest() {
835        let temp_dir = TempDir::new().unwrap();
836        let manifest_path = temp_dir.path().join("agpm.toml");
837
838        // Create an invalid manifest
839        std::fs::write(&manifest_path, "invalid toml {{").unwrap();
840
841        let result = CommandContext::from_manifest_path(&manifest_path);
842        assert!(result.is_err());
843        assert!(result.unwrap_err().to_string().contains("Failed to parse manifest"));
844    }
845
846    #[test]
847    fn test_load_lockfile_exists() {
848        let temp_dir = TempDir::new().unwrap();
849        let manifest_path = temp_dir.path().join("agpm.toml");
850        let lockfile_path = temp_dir.path().join("agpm.lock");
851
852        // Create test files
853        std::fs::write(&manifest_path, "[sources]\n").unwrap();
854        std::fs::write(
855            &lockfile_path,
856            r#"
857version = 1
858
859[[sources]]
860name = "test"
861url = "https://github.com/test/repo.git"
862commit = "abc123"
863fetched_at = "2024-01-01T00:00:00Z"
864"#,
865        )
866        .unwrap();
867
868        let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
869        let lockfile = context.load_lockfile().unwrap();
870
871        assert!(lockfile.is_some());
872        let lockfile = lockfile.unwrap();
873        assert_eq!(lockfile.sources.len(), 1);
874        assert_eq!(lockfile.sources[0].name, "test");
875    }
876
877    #[test]
878    fn test_load_lockfile_not_exists() {
879        let temp_dir = TempDir::new().unwrap();
880        let manifest_path = temp_dir.path().join("agpm.toml");
881
882        std::fs::write(&manifest_path, "[sources]\n").unwrap();
883
884        let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
885        let lockfile = context.load_lockfile().unwrap();
886
887        assert!(lockfile.is_none());
888    }
889
890    #[test]
891    fn test_save_lockfile() {
892        let temp_dir = TempDir::new().unwrap();
893        let manifest_path = temp_dir.path().join("agpm.toml");
894
895        std::fs::write(&manifest_path, "[sources]\n").unwrap();
896
897        let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
898
899        let lockfile = crate::lockfile::LockFile {
900            version: 1,
901            sources: vec![],
902            agents: vec![],
903            snippets: vec![],
904            commands: vec![],
905            scripts: vec![],
906            hooks: vec![],
907            mcp_servers: vec![],
908        };
909
910        context.save_lockfile(&lockfile).unwrap();
911
912        assert!(context.lockfile_path.exists());
913        let saved_content = std::fs::read_to_string(&context.lockfile_path).unwrap();
914        assert!(saved_content.contains("version = 1"));
915    }
916
917    #[test]
918    fn test_check_for_legacy_ccpm_no_files() {
919        let temp_dir = TempDir::new().unwrap();
920        let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
921        assert!(result.is_none());
922    }
923
924    #[test]
925    fn test_check_for_legacy_ccpm_toml_only() {
926        let temp_dir = TempDir::new().unwrap();
927        std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
928
929        let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
930        assert!(result.is_some());
931        let msg = result.unwrap();
932        assert!(msg.contains("Legacy CCPM files detected"));
933        assert!(msg.contains("ccpm.toml"));
934        assert!(msg.contains("agpm migrate"));
935    }
936
937    #[test]
938    fn test_check_for_legacy_ccpm_lock_only() {
939        let temp_dir = TempDir::new().unwrap();
940        std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
941
942        let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
943        assert!(result.is_some());
944        let msg = result.unwrap();
945        assert!(msg.contains("ccpm.lock"));
946    }
947
948    #[test]
949    fn test_check_for_legacy_ccpm_both_files() {
950        let temp_dir = TempDir::new().unwrap();
951        std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
952        std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
953
954        let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
955        assert!(result.is_some());
956        let msg = result.unwrap();
957        assert!(msg.contains("ccpm.toml and ccpm.lock"));
958    }
959
960    #[test]
961    fn test_find_legacy_ccpm_directory_no_files() {
962        let temp_dir = TempDir::new().unwrap();
963        let result = find_legacy_ccpm_directory(temp_dir.path());
964        assert!(result.is_none());
965    }
966
967    #[test]
968    fn test_find_legacy_ccpm_directory_in_current_dir() {
969        let temp_dir = TempDir::new().unwrap();
970        std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
971
972        let result = find_legacy_ccpm_directory(temp_dir.path());
973        assert!(result.is_some());
974        assert_eq!(result.unwrap(), temp_dir.path());
975    }
976
977    #[test]
978    fn test_find_legacy_ccpm_directory_in_parent() {
979        let temp_dir = TempDir::new().unwrap();
980        let parent = temp_dir.path();
981        let child = parent.join("subdir");
982        std::fs::create_dir(&child).unwrap();
983
984        // Create legacy file in parent
985        std::fs::write(parent.join("ccpm.toml"), "[sources]\n").unwrap();
986
987        // Search from child directory
988        let result = find_legacy_ccpm_directory(&child);
989        assert!(result.is_some());
990        assert_eq!(result.unwrap(), parent);
991    }
992
993    #[test]
994    fn test_find_legacy_ccpm_directory_finds_lock_file() {
995        let temp_dir = TempDir::new().unwrap();
996        std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
997
998        let result = find_legacy_ccpm_directory(temp_dir.path());
999        assert!(result.is_some());
1000        assert_eq!(result.unwrap(), temp_dir.path());
1001    }
1002
1003    #[tokio::test]
1004    async fn test_handle_legacy_ccpm_migration_no_files() {
1005        let temp_dir = TempDir::new().unwrap();
1006        let original_dir = std::env::current_dir().unwrap();
1007
1008        // Change to temp directory with no legacy files
1009        std::env::set_current_dir(temp_dir.path()).unwrap();
1010
1011        let result = handle_legacy_ccpm_migration().await;
1012
1013        // Restore original directory
1014        std::env::set_current_dir(original_dir).unwrap();
1015
1016        assert!(result.is_ok());
1017        assert!(result.unwrap().is_none());
1018    }
1019
1020    #[cfg(test)]
1021    mod lockfile_regeneration_tests {
1022        use super::*;
1023        use crate::manifest::Manifest;
1024        use tempfile::TempDir;
1025
1026        #[test]
1027        fn test_load_lockfile_with_regeneration_valid_lockfile() {
1028            let temp_dir = TempDir::new().unwrap();
1029            let project_dir = temp_dir.path();
1030            let manifest_path = project_dir.join("agpm.toml");
1031            let lockfile_path = project_dir.join("agpm.lock");
1032
1033            // Create a minimal manifest
1034            let manifest_content = r#"[sources]
1035example = "https://github.com/example/repo.git"
1036
1037[agents]
1038test = { source = "example", path = "test.md", version = "v1.0.0" }
1039"#;
1040            std::fs::write(&manifest_path, manifest_content).unwrap();
1041
1042            // Create a valid lockfile
1043            let lockfile_content = r#"version = 1
1044
1045[[sources]]
1046name = "example"
1047url = "https://github.com/example/repo.git"
1048commit = "abc123def456789012345678901234567890abcd"
1049fetched_at = "2024-01-01T00:00:00Z"
1050
1051[[agents]]
1052name = "test"
1053source = "example"
1054path = "test.md"
1055version = "v1.0.0"
1056resolved_commit = "abc123def456789012345678901234567890abcd"
1057checksum = "sha256:examplechecksum"
1058installed_at = ".claude/agents/test.md"
1059"#;
1060            std::fs::write(&lockfile_path, lockfile_content).unwrap();
1061
1062            // Test loading valid lockfile
1063            let manifest = Manifest::load(&manifest_path).unwrap();
1064            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1065
1066            let result = ctx.load_lockfile_with_regeneration(true, "test").unwrap();
1067            assert!(result.is_some());
1068        }
1069
1070        #[test]
1071        fn test_load_lockfile_with_regeneration_invalid_toml() {
1072            let temp_dir = TempDir::new().unwrap();
1073            let project_dir = temp_dir.path();
1074            let manifest_path = project_dir.join("agpm.toml");
1075            let lockfile_path = project_dir.join("agpm.lock");
1076
1077            // Create a minimal manifest
1078            let manifest_content = r#"[sources]
1079example = "https://github.com/example/repo.git"
1080"#;
1081            std::fs::write(&manifest_path, manifest_content).unwrap();
1082
1083            // Create an invalid TOML lockfile
1084            std::fs::write(&lockfile_path, "invalid toml [[[").unwrap();
1085
1086            // Test loading invalid lockfile in non-interactive mode
1087            let manifest = Manifest::load(&manifest_path).unwrap();
1088            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1089
1090            // This should return an error in non-interactive mode
1091            let result = ctx.load_lockfile_with_regeneration(true, "test");
1092            assert!(result.is_err());
1093
1094            let error_msg = result.unwrap_err().to_string();
1095            assert!(error_msg.contains("Invalid or corrupted lockfile detected"));
1096            assert!(error_msg.contains("beta software"));
1097            assert!(error_msg.contains("cp agpm.lock"));
1098        }
1099
1100        #[test]
1101        fn test_load_lockfile_with_regeneration_missing_lockfile() {
1102            let temp_dir = TempDir::new().unwrap();
1103            let project_dir = temp_dir.path();
1104            let manifest_path = project_dir.join("agpm.toml");
1105
1106            // Create a minimal manifest
1107            let manifest_content = r#"[sources]
1108example = "https://github.com/example/repo.git"
1109"#;
1110            std::fs::write(&manifest_path, manifest_content).unwrap();
1111
1112            // Test loading non-existent lockfile
1113            let manifest = Manifest::load(&manifest_path).unwrap();
1114            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1115
1116            let result = ctx.load_lockfile_with_regeneration(true, "test").unwrap();
1117            assert!(result.is_none()); // Should return None for missing lockfile
1118        }
1119
1120        #[test]
1121        fn test_load_lockfile_with_regeneration_version_incompatibility() {
1122            let temp_dir = TempDir::new().unwrap();
1123            let project_dir = temp_dir.path();
1124            let manifest_path = project_dir.join("agpm.toml");
1125            let lockfile_path = project_dir.join("agpm.lock");
1126
1127            // Create a minimal manifest
1128            let manifest_content = r#"[sources]
1129example = "https://github.com/example/repo.git"
1130"#;
1131            std::fs::write(&manifest_path, manifest_content).unwrap();
1132
1133            // Create a lockfile with future version
1134            let lockfile_content = r#"version = 999
1135
1136[[sources]]
1137name = "example"
1138url = "https://github.com/example/repo.git"
1139commit = "abc123def456789012345678901234567890abcd"
1140fetched_at = "2024-01-01T00:00:00Z"
1141"#;
1142            std::fs::write(&lockfile_path, lockfile_content).unwrap();
1143
1144            // Test loading future version lockfile
1145            let manifest = Manifest::load(&manifest_path).unwrap();
1146            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1147
1148            let result = ctx.load_lockfile_with_regeneration(true, "test");
1149            assert!(result.is_err());
1150
1151            let error_msg = result.unwrap_err().to_string();
1152            assert!(error_msg.contains("version") || error_msg.contains("newer"));
1153        }
1154
1155        #[test]
1156        fn test_load_lockfile_with_regeneration_cannot_regenerate() {
1157            let temp_dir = TempDir::new().unwrap();
1158            let project_dir = temp_dir.path();
1159            let manifest_path = project_dir.join("agpm.toml");
1160            let lockfile_path = project_dir.join("agpm.lock");
1161
1162            // Create a minimal manifest
1163            let manifest_content = r#"[sources]
1164example = "https://github.com/example/repo.git"
1165"#;
1166            std::fs::write(&manifest_path, manifest_content).unwrap();
1167
1168            // Create an invalid TOML lockfile
1169            std::fs::write(&lockfile_path, "invalid toml [[[").unwrap();
1170
1171            // Test with can_regenerate = false
1172            let manifest = Manifest::load(&manifest_path).unwrap();
1173            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1174
1175            let result = ctx.load_lockfile_with_regeneration(false, "test");
1176            assert!(result.is_err());
1177
1178            // Should return the original error, not the enhanced one
1179            let error_msg = result.unwrap_err().to_string();
1180            assert!(!error_msg.contains("Invalid or corrupted lockfile detected"));
1181            assert!(
1182                error_msg.contains("Failed to load lockfile")
1183                    || error_msg.contains("Invalid TOML syntax")
1184            );
1185        }
1186
1187        #[test]
1188        fn test_backup_and_regenerate_lockfile() {
1189            let temp_dir = TempDir::new().unwrap();
1190            let project_dir = temp_dir.path();
1191            let manifest_path = project_dir.join("agpm.toml");
1192            let lockfile_path = project_dir.join("agpm.lock");
1193
1194            // Create a minimal manifest
1195            let manifest_content = r#"[sources]
1196example = "https://github.com/example/repo.git"
1197"#;
1198            std::fs::write(&manifest_path, manifest_content).unwrap();
1199
1200            // Create an invalid lockfile
1201            std::fs::write(&lockfile_path, "invalid content").unwrap();
1202
1203            // Test backup and regeneration
1204            let manifest = Manifest::load(&manifest_path).unwrap();
1205            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1206
1207            let backup_path = lockfile_path.with_extension("lock.invalid");
1208
1209            // This should backup the file and remove the original
1210            ctx.backup_and_regenerate_lockfile(&backup_path, "test").unwrap();
1211
1212            // Check that backup was created
1213            assert!(backup_path.exists());
1214            assert_eq!(std::fs::read_to_string(&backup_path).unwrap(), "invalid content");
1215
1216            // Check that original was removed
1217            assert!(!lockfile_path.exists());
1218        }
1219
1220        #[test]
1221        fn test_create_non_interactive_error() {
1222            let temp_dir = TempDir::new().unwrap();
1223            let project_dir = temp_dir.path();
1224            let manifest_path = project_dir.join("agpm.toml");
1225
1226            // Create a minimal manifest
1227            let manifest_content = r#"[sources]
1228example = "https://github.com/example/repo.git"
1229"#;
1230            std::fs::write(&manifest_path, manifest_content).unwrap();
1231
1232            // Test non-interactive error creation
1233            let manifest = Manifest::load(&manifest_path).unwrap();
1234            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1235
1236            let error = ctx.create_non_interactive_error("Invalid TOML syntax", "test");
1237            let error_msg = error.to_string();
1238
1239            assert!(error_msg.contains("Invalid TOML syntax"));
1240            assert!(error_msg.contains("beta software"));
1241            assert!(error_msg.contains("cp agpm.lock"));
1242            assert!(error_msg.contains("rm agpm.lock"));
1243            assert!(error_msg.contains("agpm install"));
1244        }
1245    }
1246
1247    // Note: Testing interactive behavior (user input) requires mocking stdin,
1248    // which is complex with tokio::io::stdin(). The non-interactive TTY check
1249    // will be automatically triggered in CI environments, providing implicit
1250    // integration testing.
1251}