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                // Default to interactive mode (yes=false); commands with --yes flag
48                // should use their own implementation
49                match handle_legacy_ccpm_migration(None, false).await {
50                    Ok(Some(path)) => path,
51                    Ok(None) => {
52                        return Err(anyhow::anyhow!(
53                            "No agpm.toml found in current directory or any parent directory. \
54                             Run 'agpm init' to create a new project."
55                        ));
56                    }
57                    Err(e) => return Err(e),
58                }
59            };
60            self.execute_from_path(manifest_path).await
61        }
62    }
63
64    /// Execute the command with a specific manifest path
65    fn execute_from_path(
66        self,
67        manifest_path: PathBuf,
68    ) -> impl std::future::Future<Output = Result<()>> + Send;
69}
70
71/// Common context for CLI commands that need manifest and project information
72#[derive(Debug)]
73pub struct CommandContext {
74    /// Parsed project manifest (agpm.toml)
75    pub manifest: Manifest,
76    /// Path to the manifest file
77    pub manifest_path: PathBuf,
78    /// Project root directory (containing agpm.toml)
79    pub project_dir: PathBuf,
80    /// Path to the lockfile (agpm.lock)
81    pub lockfile_path: PathBuf,
82}
83
84impl CommandContext {
85    /// Create a new command context from a manifest and project directory
86    pub fn new(manifest: Manifest, project_dir: PathBuf) -> Result<Self> {
87        let lockfile_path = project_dir.join("agpm.lock");
88        Ok(Self {
89            manifest,
90            manifest_path: project_dir.join("agpm.toml"),
91            project_dir,
92            lockfile_path,
93        })
94    }
95
96    /// Create a new command context from a manifest path
97    ///
98    /// # Errors
99    /// Returns an error if the manifest file doesn't exist or cannot be read
100    pub fn from_manifest_path(manifest_path: impl AsRef<Path>) -> Result<Self> {
101        let manifest_path = manifest_path.as_ref();
102
103        if !manifest_path.exists() {
104            return Err(anyhow::anyhow!("Manifest file {} not found", manifest_path.display()));
105        }
106
107        let project_dir = manifest_path
108            .parent()
109            .ok_or_else(|| anyhow::anyhow!("Invalid manifest path"))?
110            .to_path_buf();
111
112        let manifest = Manifest::load(manifest_path).with_context(|| {
113            format!("Failed to parse manifest file: {}", manifest_path.display())
114        })?;
115
116        let lockfile_path = project_dir.join("agpm.lock");
117
118        Ok(Self {
119            manifest,
120            manifest_path: manifest_path.to_path_buf(),
121            project_dir,
122            lockfile_path,
123        })
124    }
125
126    /// Reload the manifest from disk.
127    ///
128    /// This should be called after any operation that modifies the manifest file,
129    /// such as migration updating the tools configuration.
130    ///
131    /// # Errors
132    /// Returns an error if the manifest file cannot be loaded or parsed.
133    pub fn reload_manifest(&mut self) -> Result<()> {
134        self.manifest = Manifest::load(&self.manifest_path).with_context(|| {
135            format!("Failed to reload manifest file: {}", self.manifest_path.display())
136        })?;
137        Ok(())
138    }
139
140    /// Load an existing lockfile if it exists
141    ///
142    /// # Errors
143    /// Returns an error if the lockfile exists but cannot be parsed
144    pub fn load_lockfile(&self) -> Result<Option<crate::lockfile::LockFile>> {
145        if self.lockfile_path.exists() {
146            let lockfile =
147                crate::lockfile::LockFile::load(&self.lockfile_path).with_context(|| {
148                    format!("Failed to load lockfile: {}", self.lockfile_path.display())
149                })?;
150            Ok(Some(lockfile))
151        } else {
152            Ok(None)
153        }
154    }
155
156    /// Load an existing lockfile with automatic regeneration for invalid files
157    ///
158    /// If the lockfile exists but is invalid or corrupted, this method will
159    /// offer to automatically regenerate it. This provides a better user
160    /// experience by recovering from common lockfile issues.
161    ///
162    /// # Arguments
163    ///
164    /// * `can_regenerate` - Whether automatic regeneration should be offered
165    /// * `operation_name` - Name of the operation for error messages (e.g., "list")
166    ///
167    /// # Returns
168    ///
169    /// * `Ok(Some(lockfile))` - Successfully loaded or regenerated lockfile
170    /// * `Ok(None)` - No lockfile exists (not an error)
171    /// * `Err` - Critical error that cannot be recovered from
172    ///
173    /// # Behavior
174    ///
175    /// - **Interactive mode** (TTY): Prompts user with Y/n confirmation
176    /// - **Non-interactive mode** (CI/CD): Fails with helpful error message
177    /// - **Backup strategy**: Copies invalid lockfile to `agpm.lock.invalid` before regeneration
178    ///
179    /// # Examples
180    ///
181    /// ```no_run
182    /// # use anyhow::Result;
183    /// # use agpm_cli::cli::common::CommandContext;
184    /// # use agpm_cli::manifest::Manifest;
185    /// # use std::path::PathBuf;
186    /// # async fn example() -> Result<()> {
187    /// let manifest = Manifest::load(&PathBuf::from("agpm.toml"))?;
188    /// let project_dir = PathBuf::from(".");
189    /// let ctx = CommandContext::new(manifest, project_dir)?;
190    /// match ctx.load_lockfile_with_regeneration(true, "list") {
191    ///     Ok(Some(lockfile)) => println!("Loaded lockfile"),
192    ///     Ok(None) => println!("No lockfile found"),
193    ///     Err(e) => eprintln!("Error: {}", e),
194    /// }
195    /// # Ok(())
196    /// # }
197    /// ```
198    pub fn load_lockfile_with_regeneration(
199        &self,
200        can_regenerate: bool,
201        operation_name: &str,
202    ) -> Result<Option<crate::lockfile::LockFile>> {
203        // If lockfile doesn't exist, that's not an error
204        if !self.lockfile_path.exists() {
205            return Ok(None);
206        }
207
208        // Try to load the lockfile
209        match crate::lockfile::LockFile::load(&self.lockfile_path) {
210            Ok(mut lockfile) => {
211                // Also load and merge private lockfile if it exists
212                if let Ok(Some(private_lock)) =
213                    crate::lockfile::PrivateLockFile::load(&self.project_dir)
214                {
215                    lockfile.merge_private(&private_lock);
216                }
217                // If private lockfile fails to load or doesn't exist, we just skip it
218                // (it could be corrupted or from a different version)
219                Ok(Some(lockfile))
220            }
221            Err(e) => {
222                // Analyze the error to see if it's recoverable
223                let error_msg = e.to_string();
224                let can_auto_recover = can_regenerate
225                    && (error_msg.contains("Invalid TOML syntax")
226                        || error_msg.contains("Lockfile version")
227                        || error_msg.contains("missing field")
228                        || error_msg.contains("invalid type")
229                        || error_msg.contains("expected"));
230
231                if !can_auto_recover {
232                    // Not a recoverable error, return the original error
233                    return Err(e);
234                }
235
236                // This is a recoverable error, offer regeneration
237                let backup_path = self.lockfile_path.with_extension("lock.invalid");
238
239                // Create user-friendly message
240                let regenerate_message = format!(
241                    "The lockfile appears to be invalid or corrupted.\n\n\
242                     Error: {}\n\n\
243                     Note: The lockfile format is not yet stable as this is beta software.\n\n\
244                     The invalid lockfile will be backed up to: {}",
245                    error_msg,
246                    backup_path.display()
247                );
248
249                // Check if we're in interactive mode
250                if io::stdin().is_terminal() {
251                    // Interactive mode: prompt user
252                    println!("{}", regenerate_message);
253                    print!("Would you like to regenerate the lockfile automatically? [Y/n] ");
254                    // Unwrap justified: stdout flush only fails on catastrophic OS errors
255                    io::stdout().flush().unwrap();
256
257                    let mut input = String::new();
258                    match io::stdin().read_line(&mut input) {
259                        Ok(_) => {
260                            let response = input.trim().to_lowercase();
261                            if response.is_empty() || response == "y" || response == "yes" {
262                                // User agreed to regenerate
263                                self.backup_and_regenerate_lockfile(&backup_path, operation_name)?;
264                                Ok(None) // Return None so caller creates new lockfile
265                            } else {
266                                // User declined, return the original error
267                                Err(crate::core::AgpmError::InvalidLockfileError {
268                                    file: self.lockfile_path.display().to_string(),
269                                    reason: format!(
270                                        "{} (User declined automatic regeneration)",
271                                        error_msg
272                                    ),
273                                    can_regenerate: true,
274                                }
275                                .into())
276                            }
277                        }
278                        Err(_) => {
279                            // Failed to read input, fall back to non-interactive behavior
280                            Err(self.create_non_interactive_error(&error_msg, operation_name))
281                        }
282                    }
283                } else {
284                    // Non-interactive mode: fail with helpful message
285                    Err(self.create_non_interactive_error(&error_msg, operation_name))
286                }
287            }
288        }
289    }
290
291    /// Backup the invalid lockfile and display regeneration instructions
292    fn backup_and_regenerate_lockfile(
293        &self,
294        backup_path: &Path,
295        operation_name: &str,
296    ) -> Result<()> {
297        // Backup the invalid lockfile
298        if let Err(e) = std::fs::copy(&self.lockfile_path, backup_path) {
299            eprintln!("Warning: Failed to backup invalid lockfile: {}", e);
300        } else {
301            println!("✓ Backed up invalid lockfile to: {}", backup_path.display());
302        }
303
304        // Remove the invalid lockfile
305        if let Err(e) = std::fs::remove_file(&self.lockfile_path) {
306            return Err(anyhow::anyhow!("Failed to remove invalid lockfile: {}", e));
307        }
308
309        println!("✓ Removed invalid lockfile");
310        println!("Note: Run 'agpm install' to regenerate the lockfile");
311
312        // If this is not an install command, suggest running install
313        if operation_name != "install" {
314            println!("Alternatively, run 'agpm {} --regenerate' if available", operation_name);
315        }
316
317        Ok(())
318    }
319
320    /// Create a non-interactive error message for CI/CD environments
321    fn create_non_interactive_error(
322        &self,
323        error_msg: &str,
324        _operation_name: &str,
325    ) -> anyhow::Error {
326        let backup_path = self.lockfile_path.with_extension("lock.invalid");
327
328        crate::core::AgpmError::InvalidLockfileError {
329            file: self.lockfile_path.display().to_string(),
330            reason: format!(
331                "{}\n\n\
332                 To fix this issue:\n\
333                 1. Backup the invalid lockfile: cp agpm.lock {}\n\
334                 2. Remove the invalid lockfile: rm agpm.lock\n\
335                 3. Regenerate it: agpm install\n\n\
336                 Note: The lockfile format is not yet stable as this is beta software.",
337                error_msg,
338                backup_path.display()
339            ),
340            can_regenerate: true,
341        }
342        .into()
343    }
344
345    /// Save a lockfile to the project directory
346    ///
347    /// # Errors
348    /// Returns an error if the lockfile cannot be written
349    pub fn save_lockfile(&self, lockfile: &crate::lockfile::LockFile) -> Result<()> {
350        lockfile
351            .save(&self.lockfile_path)
352            .with_context(|| format!("Failed to save lockfile: {}", self.lockfile_path.display()))
353    }
354}
355
356/// Handle legacy CCPM files by offering interactive migration.
357///
358/// This function searches for ccpm.toml and ccpm.lock files in the current
359/// directory and parent directories. If found, it prompts the user to migrate
360/// and performs the migration if they accept.
361///
362/// # Behavior
363///
364/// - **Interactive mode**: Prompts user with Y/n confirmation (stdin is a TTY)
365/// - **Non-interactive mode**: Returns `Ok(None)` if stdin is not a TTY (e.g., CI/CD)
366/// - **Auto-accept mode**: When `yes` is true, accepts migration without prompting
367/// - **Search scope**: Traverses from current directory to filesystem root
368///
369/// # Arguments
370///
371/// * `from_dir` - Optional starting directory for the search
372/// * `yes` - When true, automatically accept migration prompts without user interaction
373///
374/// # Returns
375///
376/// - `Ok(Some(PathBuf))` with the path to agpm.toml if migration succeeded
377/// - `Ok(None)` if no legacy files were found OR user declined OR non-interactive mode
378/// - `Err` if migration failed
379///
380/// # Examples
381///
382/// ```no_run
383/// # use anyhow::Result;
384/// # async fn example() -> Result<()> {
385/// use agpm_cli::cli::common::handle_legacy_ccpm_migration;
386///
387/// // Interactive mode
388/// match handle_legacy_ccpm_migration(None, false).await? {
389///     Some(path) => println!("Migrated to: {}", path.display()),
390///     None => println!("No migration performed"),
391/// }
392///
393/// // Auto-accept mode (for CI/scripts)
394/// match handle_legacy_ccpm_migration(None, true).await? {
395///     Some(path) => println!("Migrated to: {}", path.display()),
396///     None => println!("No legacy files found"),
397/// }
398/// # Ok(())
399/// # }
400/// ```
401///
402/// # Errors
403///
404/// Returns an error if:
405/// - Unable to access current directory (when `from_dir` is None)
406/// - Unable to perform migration operations
407pub async fn handle_legacy_ccpm_migration(
408    from_dir: Option<PathBuf>,
409    yes: bool,
410) -> Result<Option<PathBuf>> {
411    let current_dir = match from_dir {
412        Some(dir) => dir,
413        None => std::env::current_dir()?,
414    };
415    let legacy_dir = find_legacy_ccpm_directory(&current_dir);
416
417    let Some(dir) = legacy_dir else {
418        return Ok(None);
419    };
420
421    // Check if we're in an interactive terminal (unless --yes flag is set)
422    if !yes && !std::io::stdin().is_terminal() {
423        // Non-interactive mode: Don't prompt, just inform and exit
424        eprintln!("{}", "Legacy CCPM files detected (non-interactive mode).".yellow());
425        eprintln!(
426            "Run {} to migrate manually, or use --yes to auto-accept.",
427            format!("agpm migrate --path {}", dir.display()).cyan()
428        );
429        return Ok(None);
430    }
431
432    // Found legacy files - prompt for migration (or auto-accept if --yes)
433    let ccpm_toml = dir.join("ccpm.toml");
434    let ccpm_lock = dir.join("ccpm.lock");
435
436    let mut files = Vec::new();
437    if ccpm_toml.exists() {
438        files.push("ccpm.toml");
439    }
440    if ccpm_lock.exists() {
441        files.push("ccpm.lock");
442    }
443
444    let files_str = files.join(" and ");
445
446    println!("{}", "Legacy CCPM files detected!".yellow().bold());
447    println!("{} {} found in {}", "→".cyan(), files_str, dir.display());
448    println!();
449
450    // Auto-accept if --yes flag is set, otherwise prompt
451    let should_migrate = if yes {
452        true
453    } else {
454        // Prompt user for migration
455        print!("{} ", "Would you like to migrate to AGPM now? [Y/n]:".green());
456        io::stdout().flush()?;
457
458        // Use async I/O for proper integration with Tokio runtime
459        let mut reader = BufReader::new(tokio::io::stdin());
460        let mut response = String::new();
461        reader.read_line(&mut response).await?;
462        let response = response.trim().to_lowercase();
463        response.is_empty() || response == "y" || response == "yes"
464    };
465
466    if should_migrate {
467        println!();
468        println!("{}", "🚀 Starting migration...".cyan());
469
470        // Perform the migration with automatic installation
471        let migrate_cmd = super::migrate::MigrateCommand::new(Some(dir.clone()), false, false);
472
473        migrate_cmd.execute().await?;
474
475        // Return the path to the newly created agpm.toml
476        Ok(Some(dir.join("agpm.toml")))
477    } else {
478        println!();
479        println!("{}", "Migration cancelled.".yellow());
480        println!(
481            "Run {} to migrate manually.",
482            format!("agpm migrate --path {}", dir.display()).cyan()
483        );
484        Ok(None)
485    }
486}
487
488/// Handle legacy format migration (old gitignore-managed → agpm/ subdirectory).
489///
490/// This function detects if a project has resources at old paths (not in agpm/
491/// subdirectory) and offers interactive migration to the new format.
492///
493/// # Behavior
494///
495/// - **Interactive mode**: Prompts user with Y/n confirmation (stdin is a TTY)
496/// - **Non-interactive mode**: Returns `Ok(false)` if stdin is not a TTY (e.g., CI/CD)
497/// - **Auto-accept mode**: When `yes` is true, accepts migration without prompting
498/// - **Detection**: Uses lockfile to identify AGPM-managed files at old paths
499///
500/// # Arguments
501///
502/// * `project_dir` - Path to the project directory
503/// * `yes` - When true, automatically accept migration prompts without user interaction
504///
505/// # Returns
506///
507/// - `Ok(true)` if migration was performed
508/// - `Ok(false)` if no migration needed OR user declined OR non-interactive mode
509/// - `Err` if migration failed
510///
511/// # Examples
512///
513/// ```no_run
514/// # use anyhow::Result;
515/// # async fn example() -> Result<()> {
516/// use agpm_cli::cli::common::handle_legacy_format_migration;
517/// use std::path::Path;
518///
519/// // Interactive mode
520/// let migrated = handle_legacy_format_migration(Path::new("."), false).await?;
521/// if migrated {
522///     println!("Format migration complete!");
523/// }
524///
525/// // Auto-accept mode (for CI/scripts)
526/// let migrated = handle_legacy_format_migration(Path::new("."), true).await?;
527/// # Ok(())
528/// # }
529/// ```
530pub async fn handle_legacy_format_migration(project_dir: &Path, yes: bool) -> Result<bool> {
531    use super::migrate::{detect_old_format, run_format_migration};
532
533    let detection = detect_old_format(project_dir);
534
535    if !detection.needs_migration() {
536        return Ok(false);
537    }
538
539    // Check if we're in an interactive terminal (unless --yes flag is set)
540    if !yes && !std::io::stdin().is_terminal() {
541        // Non-interactive mode: Don't prompt, just inform
542        eprintln!("{}", "Legacy AGPM format detected (non-interactive mode).".yellow());
543        eprintln!(
544            "Run {} to migrate manually, or use --yes to auto-accept.",
545            format!("agpm migrate --path {}", project_dir.display()).cyan()
546        );
547        return Ok(false);
548    }
549
550    // Show what was detected
551    println!("{}", "Legacy AGPM format detected!".yellow().bold());
552
553    if !detection.old_resource_paths.is_empty() {
554        println!(
555            "\n{} Found {} resources at old paths:",
556            "→".cyan(),
557            detection.old_resource_paths.len()
558        );
559        for path in &detection.old_resource_paths {
560            let rel = path.strip_prefix(project_dir).unwrap_or(path);
561            println!("    • {}", rel.display());
562        }
563    }
564
565    if detection.has_managed_gitignore_section {
566        println!("\n{} Found AGPM/CCPM managed section in .gitignore", "→".cyan());
567    }
568
569    println!();
570    println!(
571        "{}",
572        "The new format uses agpm/ subdirectories for easier gitignore management.".dimmed()
573    );
574    println!();
575
576    // Auto-accept if --yes flag is set, otherwise prompt
577    let should_migrate = if yes {
578        true
579    } else {
580        // Prompt user for migration
581        print!("{} ", "Would you like to migrate to the new format now? [Y/n]:".green());
582        io::stdout().flush()?;
583
584        // Use async I/O for proper integration with Tokio runtime
585        let mut reader = BufReader::new(tokio::io::stdin());
586        let mut response = String::new();
587        reader.read_line(&mut response).await?;
588        let response = response.trim().to_lowercase();
589        response.is_empty() || response == "y" || response == "yes"
590    };
591
592    if should_migrate {
593        println!();
594        run_format_migration(project_dir).await?;
595        Ok(true)
596    } else {
597        println!();
598        println!("{}", "Migration cancelled.".yellow());
599        println!(
600            "Run {} to migrate manually.",
601            format!("agpm migrate --path {}", project_dir.display()).cyan()
602        );
603        Ok(false)
604    }
605}
606
607/// Check for legacy CCPM files and return a migration message if found.
608///
609/// This function searches for ccpm.toml and ccpm.lock files in the current
610/// directory and parent directories, similar to how `find_manifest` works.
611/// If legacy files are found, it returns a helpful error message suggesting
612/// to run the migration command.
613///
614/// # Returns
615///
616/// - `Some(String)` with migration instructions if legacy files are found
617/// - `None` if no legacy files are detected
618#[must_use]
619pub fn check_for_legacy_ccpm_files() -> Option<String> {
620    check_for_legacy_ccpm_files_from(std::env::current_dir().ok()?)
621}
622
623/// Find the directory containing legacy CCPM files.
624///
625/// Searches for ccpm.toml or ccpm.lock starting from the given directory
626/// and walking up the directory tree.
627///
628/// # Returns
629///
630/// - `Some(PathBuf)` with the directory containing legacy files
631/// - `None` if no legacy files are found
632fn find_legacy_ccpm_directory(start_dir: &Path) -> Option<PathBuf> {
633    let mut dir = start_dir;
634
635    loop {
636        let ccpm_toml = dir.join("ccpm.toml");
637        let ccpm_lock = dir.join("ccpm.lock");
638
639        if ccpm_toml.exists() || ccpm_lock.exists() {
640            return Some(dir.to_path_buf());
641        }
642
643        dir = dir.parent()?;
644    }
645}
646
647/// Check for legacy CCPM files starting from a specific directory.
648///
649/// This is the internal implementation that allows for testing without
650/// changing the current working directory.
651fn check_for_legacy_ccpm_files_from(start_dir: PathBuf) -> Option<String> {
652    let current = start_dir;
653    let mut dir = current.as_path();
654
655    loop {
656        let ccpm_toml = dir.join("ccpm.toml");
657        let ccpm_lock = dir.join("ccpm.lock");
658
659        if ccpm_toml.exists() || ccpm_lock.exists() {
660            let mut files = Vec::new();
661            if ccpm_toml.exists() {
662                files.push("ccpm.toml");
663            }
664            if ccpm_lock.exists() {
665                files.push("ccpm.lock");
666            }
667
668            let files_str = files.join(" and ");
669            let location = if dir == current {
670                "current directory".to_string()
671            } else {
672                format!("parent directory: {}", dir.display())
673            };
674
675            return Some(format!(
676                "{}\n\n{} {} found in {}.\n{}\n  {}\n\n{}",
677                "Legacy CCPM files detected!".yellow().bold(),
678                "→".cyan(),
679                files_str,
680                location,
681                "Run the migration command to upgrade:".yellow(),
682                format!("agpm migrate --path {}", dir.display()).cyan().bold(),
683                "Or run 'agpm init' to create a new AGPM project.".dimmed()
684            ));
685        }
686
687        dir = dir.parent()?;
688    }
689}
690
691/// Determines the type of operation being performed for user-facing messages.
692///
693/// This enum distinguishes between install and update operations to provide
694/// appropriate feedback messages and exit codes. Used by shared helper functions
695/// like `display_dry_run_results()` and `display_no_changes()` to customize
696/// behavior based on the operation context.
697///
698/// # Examples
699///
700/// ```rust
701/// use agpm_cli::cli::common::OperationMode;
702///
703/// let mode = OperationMode::Install;
704/// // Used to determine appropriate "no changes" message:
705/// // Install: "No dependencies to install"
706/// // Update: "All dependencies are up to date!"
707/// ```
708#[derive(Debug, Clone, Copy, PartialEq, Eq)]
709pub enum OperationMode {
710    /// Fresh installation operation (agpm install)
711    Install,
712    /// Dependency update operation (agpm update)
713    Update,
714}
715
716/// Display dry-run results with rich categorization of changes.
717///
718/// Shows new resources, updated resources, and unchanged count.
719/// **IMPORTANT**: Returns an error (exit code 1) if changes are detected,
720/// making this suitable for CI validation workflows.
721///
722/// # Arguments
723///
724/// * `new_lockfile` - The lockfile that would be created
725/// * `existing_lockfile` - The current lockfile if it exists
726/// * `quiet` - Whether to suppress output
727///
728/// # Returns
729///
730/// * `Ok(())` - No changes detected (exit code 0)
731/// * `Err(...)` - Changes detected (exit code 1 for CI validation)
732///
733/// # CI/CD Usage
734///
735/// This function is designed for CI validation workflows where you want
736/// to detect if running install/update would make changes:
737///
738/// ```bash
739/// # CI pipeline check - fails if dependencies need updating
740/// agpm install --dry-run  # Exit code 1 if changes needed
741/// agpm update --dry-run   # Exit code 1 if updates available
742/// ```
743///
744/// # Examples
745///
746/// ```no_run
747/// # use anyhow::Result;
748/// # use agpm_cli::cli::common::display_dry_run_results;
749/// # use agpm_cli::lockfile::LockFile;
750/// # fn example() -> Result<()> {
751/// let new_lockfile = LockFile::new();
752/// let existing_lockfile = None;
753///
754/// // In CI: this will return Err if changes detected
755/// display_dry_run_results(
756///     &new_lockfile,
757///     existing_lockfile.as_ref(),
758///     false,
759/// )?;
760/// # Ok(())
761/// # }
762/// ```
763///
764/// # Output Format
765///
766/// When changes are detected, displays:
767/// - **New resources**: Resources that would be installed (green)
768/// - **Updated resources**: Resources that would be updated (yellow)
769/// - **Unchanged count**: Resources that are already up to date (dimmed)
770pub fn display_dry_run_results(
771    new_lockfile: &crate::lockfile::LockFile,
772    existing_lockfile: Option<&crate::lockfile::LockFile>,
773    quiet: bool,
774) -> Result<()> {
775    // 1. Categorize changes
776    let (new_resources, updated_resources, unchanged_count) =
777        categorize_resource_changes(new_lockfile, existing_lockfile);
778
779    // 2. Display results
780    let has_changes = !new_resources.is_empty() || !updated_resources.is_empty();
781    display_dry_run_output(&new_resources, &updated_resources, unchanged_count, quiet);
782
783    // 3. Return CI exit code
784    if has_changes {
785        Err(anyhow::anyhow!("Dry-run detected changes (exit 1)"))
786    } else {
787        Ok(())
788    }
789}
790
791/// Represents a new resource to be installed.
792#[derive(Debug, Clone)]
793struct NewResource {
794    resource_type: String,
795    name: String,
796    version: String,
797}
798
799/// Represents a resource being updated.
800#[derive(Debug, Clone)]
801struct UpdatedResource {
802    resource_type: String,
803    name: String,
804    old_version: String,
805    new_version: String,
806}
807
808/// Categorize resources into new, updated, and unchanged.
809///
810/// Compares a new lockfile against an existing lockfile to determine what has changed.
811/// Returns tuple of (new_resources, updated_resources, unchanged_count).
812fn categorize_resource_changes(
813    new_lockfile: &crate::lockfile::LockFile,
814    existing_lockfile: Option<&crate::lockfile::LockFile>,
815) -> (Vec<NewResource>, Vec<UpdatedResource>, usize) {
816    use crate::core::resource_iterator::ResourceIterator;
817
818    let mut new_resources = Vec::new();
819    let mut updated_resources = Vec::new();
820    let mut unchanged_count = 0;
821
822    // Compare lockfiles to find changes
823    if let Some(existing) = existing_lockfile {
824        ResourceIterator::for_each_resource(new_lockfile, |resource_type, new_entry| {
825            // Find corresponding entry in existing lockfile
826            if let Some((_, old_entry)) = ResourceIterator::find_resource_by_name_and_source(
827                existing,
828                &new_entry.name,
829                new_entry.source.as_deref(),
830            ) {
831                // Check if it was updated
832                if old_entry.resolved_commit == new_entry.resolved_commit {
833                    unchanged_count += 1;
834                } else {
835                    let old_version =
836                        old_entry.version.clone().unwrap_or_else(|| "latest".to_string());
837                    let new_version =
838                        new_entry.version.clone().unwrap_or_else(|| "latest".to_string());
839                    updated_resources.push(UpdatedResource {
840                        resource_type: resource_type.to_string(),
841                        name: new_entry.name.clone(),
842                        old_version,
843                        new_version,
844                    });
845                }
846            } else {
847                // New resource
848                new_resources.push(NewResource {
849                    resource_type: resource_type.to_string(),
850                    name: new_entry.name.clone(),
851                    version: new_entry.version.clone().unwrap_or_else(|| "latest".to_string()),
852                });
853            }
854        });
855    } else {
856        // No existing lockfile, everything is new
857        ResourceIterator::for_each_resource(new_lockfile, |resource_type, new_entry| {
858            new_resources.push(NewResource {
859                resource_type: resource_type.to_string(),
860                name: new_entry.name.clone(),
861                version: new_entry.version.clone().unwrap_or_else(|| "latest".to_string()),
862            });
863        });
864    }
865
866    (new_resources, updated_resources, unchanged_count)
867}
868
869/// Format and display dry-run results.
870///
871/// Displays new resources, updated resources, and unchanged count with rich formatting.
872/// Shows nothing if quiet mode is enabled.
873fn display_dry_run_output(
874    new_resources: &[NewResource],
875    updated_resources: &[UpdatedResource],
876    unchanged_count: usize,
877    quiet: bool,
878) {
879    if quiet {
880        return;
881    }
882
883    let has_changes = !new_resources.is_empty() || !updated_resources.is_empty();
884
885    if has_changes {
886        println!("{}", "Dry run - the following changes would be made:".yellow());
887        println!();
888
889        if !new_resources.is_empty() {
890            println!("{}", "New resources:".green().bold());
891            for resource in new_resources {
892                println!(
893                    "  {} {} ({})",
894                    "+".green(),
895                    resource.name.cyan(),
896                    format!("{} {}", resource.resource_type, resource.version).dimmed()
897                );
898            }
899            println!();
900        }
901
902        if !updated_resources.is_empty() {
903            println!("{}", "Updated resources:".yellow().bold());
904            for resource in updated_resources {
905                print!(
906                    "  {} {} {} → ",
907                    "~".yellow(),
908                    resource.name.cyan(),
909                    resource.old_version.yellow()
910                );
911                println!("{} ({})", resource.new_version.green(), resource.resource_type.dimmed());
912            }
913            println!();
914        }
915
916        if unchanged_count > 0 {
917            println!("{}", format!("{unchanged_count} unchanged resources").dimmed());
918        }
919
920        println!();
921        println!(
922            "{}",
923            format!(
924                "Total: {} new, {} updated, {} unchanged",
925                new_resources.len(),
926                updated_resources.len(),
927                unchanged_count
928            )
929            .bold()
930        );
931        println!();
932        println!("{}", "No files were modified (dry-run mode)".yellow());
933    } else {
934        println!("✓ {}", "No changes would be made".green());
935    }
936}
937
938/// Display "no changes" message appropriate for the operation mode.
939///
940/// Shows a message indicating no changes were made, with different messages
941/// depending on whether this was an install or update operation.
942///
943/// # Arguments
944///
945/// * `mode` - The operation mode (install or update)
946/// * `quiet` - Whether to suppress output
947///
948/// # Examples
949///
950/// ```no_run
951/// use agpm_cli::cli::common::{display_no_changes, OperationMode};
952///
953/// display_no_changes(OperationMode::Install, false);
954/// display_no_changes(OperationMode::Update, false);
955/// ```
956pub fn display_no_changes(mode: OperationMode, quiet: bool) {
957    if quiet {
958        return;
959    }
960
961    match mode {
962        OperationMode::Install => println!("No dependencies to install"),
963        OperationMode::Update => println!("All dependencies are up to date!"),
964    }
965}
966
967#[cfg(test)]
968mod tests {
969    use super::*;
970    use tempfile::TempDir;
971
972    #[test]
973    fn test_command_context_from_manifest_path() {
974        let temp_dir = TempDir::new().unwrap();
975        let manifest_path = temp_dir.path().join("agpm.toml");
976
977        // Create a test manifest
978        std::fs::write(
979            &manifest_path,
980            r#"
981[sources]
982test = "https://github.com/test/repo.git"
983
984[agents]
985"#,
986        )
987        .unwrap();
988
989        let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
990
991        assert_eq!(context.manifest_path, manifest_path);
992        assert_eq!(context.project_dir, temp_dir.path());
993        assert_eq!(context.lockfile_path, temp_dir.path().join("agpm.lock"));
994        assert!(context.manifest.sources.contains_key("test"));
995    }
996
997    #[test]
998    fn test_command_context_missing_manifest() {
999        let result = CommandContext::from_manifest_path("/nonexistent/agpm.toml");
1000        assert!(result.is_err());
1001        assert!(result.unwrap_err().to_string().contains("not found"));
1002    }
1003
1004    #[test]
1005    fn test_command_context_invalid_manifest() {
1006        let temp_dir = TempDir::new().unwrap();
1007        let manifest_path = temp_dir.path().join("agpm.toml");
1008
1009        // Create an invalid manifest
1010        std::fs::write(&manifest_path, "invalid toml {{").unwrap();
1011
1012        let result = CommandContext::from_manifest_path(&manifest_path);
1013        assert!(result.is_err());
1014        assert!(result.unwrap_err().to_string().contains("Failed to parse manifest"));
1015    }
1016
1017    #[test]
1018    fn test_load_lockfile_exists() {
1019        let temp_dir = TempDir::new().unwrap();
1020        let manifest_path = temp_dir.path().join("agpm.toml");
1021        let lockfile_path = temp_dir.path().join("agpm.lock");
1022
1023        // Create test files
1024        std::fs::write(&manifest_path, "[sources]\n").unwrap();
1025        std::fs::write(
1026            &lockfile_path,
1027            r#"
1028version = 1
1029
1030[[sources]]
1031name = "test"
1032url = "https://github.com/test/repo.git"
1033commit = "abc123"
1034fetched_at = "2024-01-01T00:00:00Z"
1035"#,
1036        )
1037        .unwrap();
1038
1039        let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
1040        let lockfile = context.load_lockfile().unwrap();
1041
1042        assert!(lockfile.is_some());
1043        let lockfile = lockfile.unwrap();
1044        assert_eq!(lockfile.sources.len(), 1);
1045        assert_eq!(lockfile.sources[0].name, "test");
1046    }
1047
1048    #[test]
1049    fn test_load_lockfile_not_exists() {
1050        let temp_dir = TempDir::new().unwrap();
1051        let manifest_path = temp_dir.path().join("agpm.toml");
1052
1053        std::fs::write(&manifest_path, "[sources]\n").unwrap();
1054
1055        let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
1056        let lockfile = context.load_lockfile().unwrap();
1057
1058        assert!(lockfile.is_none());
1059    }
1060
1061    #[test]
1062    fn test_save_lockfile() {
1063        let temp_dir = TempDir::new().unwrap();
1064        let manifest_path = temp_dir.path().join("agpm.toml");
1065
1066        std::fs::write(&manifest_path, "[sources]\n").unwrap();
1067
1068        let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
1069
1070        let lockfile = crate::lockfile::LockFile {
1071            version: 1,
1072            sources: vec![],
1073            agents: vec![],
1074            snippets: vec![],
1075            commands: vec![],
1076            scripts: vec![],
1077            hooks: vec![],
1078            mcp_servers: vec![],
1079            skills: vec![],
1080            manifest_hash: None,
1081            has_mutable_deps: None,
1082            resource_count: None,
1083        };
1084
1085        context.save_lockfile(&lockfile).unwrap();
1086
1087        assert!(context.lockfile_path.exists());
1088        let saved_content = std::fs::read_to_string(&context.lockfile_path).unwrap();
1089        assert!(saved_content.contains("version = 1"));
1090    }
1091
1092    #[test]
1093    fn test_check_for_legacy_ccpm_no_files() {
1094        let temp_dir = TempDir::new().unwrap();
1095        let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
1096        assert!(result.is_none());
1097    }
1098
1099    #[test]
1100    fn test_check_for_legacy_ccpm_toml_only() {
1101        let temp_dir = TempDir::new().unwrap();
1102        std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
1103
1104        let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
1105        assert!(result.is_some());
1106        let msg = result.unwrap();
1107        assert!(msg.contains("Legacy CCPM files detected"));
1108        assert!(msg.contains("ccpm.toml"));
1109        assert!(msg.contains("agpm migrate"));
1110    }
1111
1112    #[test]
1113    fn test_check_for_legacy_ccpm_lock_only() {
1114        let temp_dir = TempDir::new().unwrap();
1115        std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
1116
1117        let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
1118        assert!(result.is_some());
1119        let msg = result.unwrap();
1120        assert!(msg.contains("ccpm.lock"));
1121    }
1122
1123    #[test]
1124    fn test_check_for_legacy_ccpm_both_files() {
1125        let temp_dir = TempDir::new().unwrap();
1126        std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
1127        std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
1128
1129        let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
1130        assert!(result.is_some());
1131        let msg = result.unwrap();
1132        assert!(msg.contains("ccpm.toml and ccpm.lock"));
1133    }
1134
1135    #[test]
1136    fn test_find_legacy_ccpm_directory_no_files() {
1137        let temp_dir = TempDir::new().unwrap();
1138        let result = find_legacy_ccpm_directory(temp_dir.path());
1139        assert!(result.is_none());
1140    }
1141
1142    #[test]
1143    fn test_find_legacy_ccpm_directory_in_current_dir() {
1144        let temp_dir = TempDir::new().unwrap();
1145        std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
1146
1147        let result = find_legacy_ccpm_directory(temp_dir.path());
1148        assert!(result.is_some());
1149        assert_eq!(result.unwrap(), temp_dir.path());
1150    }
1151
1152    #[test]
1153    fn test_find_legacy_ccpm_directory_in_parent() {
1154        let temp_dir = TempDir::new().unwrap();
1155        let parent = temp_dir.path();
1156        let child = parent.join("subdir");
1157        std::fs::create_dir(&child).unwrap();
1158
1159        // Create legacy file in parent
1160        std::fs::write(parent.join("ccpm.toml"), "[sources]\n").unwrap();
1161
1162        // Search from child directory
1163        let result = find_legacy_ccpm_directory(&child);
1164        assert!(result.is_some());
1165        assert_eq!(result.unwrap(), parent);
1166    }
1167
1168    #[test]
1169    fn test_find_legacy_ccpm_directory_finds_lock_file() {
1170        let temp_dir = TempDir::new().unwrap();
1171        std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
1172
1173        let result = find_legacy_ccpm_directory(temp_dir.path());
1174        assert!(result.is_some());
1175        assert_eq!(result.unwrap(), temp_dir.path());
1176    }
1177
1178    #[tokio::test]
1179    async fn test_handle_legacy_ccpm_migration_no_files() -> Result<()> {
1180        let temp_dir = TempDir::new()?;
1181
1182        // Test directory with no legacy files
1183        let result = handle_legacy_ccpm_migration(Some(temp_dir.path().to_path_buf()), false).await;
1184
1185        assert!(result?.is_none());
1186        Ok(())
1187    }
1188
1189    #[cfg(test)]
1190    mod lockfile_regeneration_tests {
1191        use super::*;
1192        use crate::manifest::Manifest;
1193        use tempfile::TempDir;
1194
1195        #[test]
1196        fn test_load_lockfile_with_regeneration_valid_lockfile() {
1197            let temp_dir = TempDir::new().unwrap();
1198            let project_dir = temp_dir.path();
1199            let manifest_path = project_dir.join("agpm.toml");
1200            let lockfile_path = project_dir.join("agpm.lock");
1201
1202            // Create a minimal manifest
1203            let manifest_content = r#"[sources]
1204example = "https://github.com/example/repo.git"
1205
1206[agents]
1207test = { source = "example", path = "test.md", version = "v1.0.0" }
1208"#;
1209            std::fs::write(&manifest_path, manifest_content).unwrap();
1210
1211            // Create a valid lockfile
1212            let lockfile_content = r#"version = 1
1213
1214[[sources]]
1215name = "example"
1216url = "https://github.com/example/repo.git"
1217commit = "abc123def456789012345678901234567890abcd"
1218fetched_at = "2024-01-01T00:00:00Z"
1219
1220[[agents]]
1221name = "test"
1222source = "example"
1223path = "test.md"
1224version = "v1.0.0"
1225resolved_commit = "abc123def456789012345678901234567890abcd"
1226checksum = "sha256:examplechecksum"
1227installed_at = ".claude/agents/test.md"
1228"#;
1229            std::fs::write(&lockfile_path, lockfile_content).unwrap();
1230
1231            // Test loading valid lockfile
1232            let manifest = Manifest::load(&manifest_path).unwrap();
1233            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1234
1235            let result = ctx.load_lockfile_with_regeneration(true, "test").unwrap();
1236            assert!(result.is_some());
1237        }
1238
1239        #[test]
1240        fn test_load_lockfile_with_regeneration_invalid_toml() {
1241            let temp_dir = TempDir::new().unwrap();
1242            let project_dir = temp_dir.path();
1243            let manifest_path = project_dir.join("agpm.toml");
1244            let lockfile_path = project_dir.join("agpm.lock");
1245
1246            // Create a minimal manifest
1247            let manifest_content = r#"[sources]
1248example = "https://github.com/example/repo.git"
1249"#;
1250            std::fs::write(&manifest_path, manifest_content).unwrap();
1251
1252            // Create an invalid TOML lockfile
1253            std::fs::write(&lockfile_path, "invalid toml [[[").unwrap();
1254
1255            // Test loading invalid lockfile in non-interactive mode
1256            let manifest = Manifest::load(&manifest_path).unwrap();
1257            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1258
1259            // This should return an error in non-interactive mode
1260            let result = ctx.load_lockfile_with_regeneration(true, "test");
1261            assert!(result.is_err());
1262
1263            let error_msg = result.unwrap_err().to_string();
1264            assert!(error_msg.contains("Invalid or corrupted lockfile detected"));
1265            assert!(error_msg.contains("beta software"));
1266            assert!(error_msg.contains("cp agpm.lock"));
1267        }
1268
1269        #[test]
1270        fn test_load_lockfile_with_regeneration_missing_lockfile() {
1271            let temp_dir = TempDir::new().unwrap();
1272            let project_dir = temp_dir.path();
1273            let manifest_path = project_dir.join("agpm.toml");
1274
1275            // Create a minimal manifest
1276            let manifest_content = r#"[sources]
1277example = "https://github.com/example/repo.git"
1278"#;
1279            std::fs::write(&manifest_path, manifest_content).unwrap();
1280
1281            // Test loading non-existent lockfile
1282            let manifest = Manifest::load(&manifest_path).unwrap();
1283            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1284
1285            let result = ctx.load_lockfile_with_regeneration(true, "test").unwrap();
1286            assert!(result.is_none()); // Should return None for missing lockfile
1287        }
1288
1289        #[test]
1290        fn test_load_lockfile_with_regeneration_version_incompatibility() {
1291            let temp_dir = TempDir::new().unwrap();
1292            let project_dir = temp_dir.path();
1293            let manifest_path = project_dir.join("agpm.toml");
1294            let lockfile_path = project_dir.join("agpm.lock");
1295
1296            // Create a minimal manifest
1297            let manifest_content = r#"[sources]
1298example = "https://github.com/example/repo.git"
1299"#;
1300            std::fs::write(&manifest_path, manifest_content).unwrap();
1301
1302            // Create a lockfile with future version
1303            let lockfile_content = r#"version = 999
1304
1305[[sources]]
1306name = "example"
1307url = "https://github.com/example/repo.git"
1308commit = "abc123def456789012345678901234567890abcd"
1309fetched_at = "2024-01-01T00:00:00Z"
1310"#;
1311            std::fs::write(&lockfile_path, lockfile_content).unwrap();
1312
1313            // Test loading future version lockfile
1314            let manifest = Manifest::load(&manifest_path).unwrap();
1315            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1316
1317            let result = ctx.load_lockfile_with_regeneration(true, "test");
1318            assert!(result.is_err());
1319
1320            let error_msg = result.unwrap_err().to_string();
1321            assert!(error_msg.contains("version") || error_msg.contains("newer"));
1322        }
1323
1324        #[test]
1325        fn test_load_lockfile_with_regeneration_cannot_regenerate() {
1326            let temp_dir = TempDir::new().unwrap();
1327            let project_dir = temp_dir.path();
1328            let manifest_path = project_dir.join("agpm.toml");
1329            let lockfile_path = project_dir.join("agpm.lock");
1330
1331            // Create a minimal manifest
1332            let manifest_content = r#"[sources]
1333example = "https://github.com/example/repo.git"
1334"#;
1335            std::fs::write(&manifest_path, manifest_content).unwrap();
1336
1337            // Create an invalid TOML lockfile
1338            std::fs::write(&lockfile_path, "invalid toml [[[").unwrap();
1339
1340            // Test with can_regenerate = false
1341            let manifest = Manifest::load(&manifest_path).unwrap();
1342            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1343
1344            let result = ctx.load_lockfile_with_regeneration(false, "test");
1345            assert!(result.is_err());
1346
1347            // Should return the original error, not the enhanced one
1348            let error_msg = result.unwrap_err().to_string();
1349            assert!(!error_msg.contains("Invalid or corrupted lockfile detected"));
1350            assert!(
1351                error_msg.contains("Failed to load lockfile")
1352                    || error_msg.contains("Invalid TOML syntax")
1353            );
1354        }
1355
1356        #[test]
1357        fn test_backup_and_regenerate_lockfile() {
1358            let temp_dir = TempDir::new().unwrap();
1359            let project_dir = temp_dir.path();
1360            let manifest_path = project_dir.join("agpm.toml");
1361            let lockfile_path = project_dir.join("agpm.lock");
1362
1363            // Create a minimal manifest
1364            let manifest_content = r#"[sources]
1365example = "https://github.com/example/repo.git"
1366"#;
1367            std::fs::write(&manifest_path, manifest_content).unwrap();
1368
1369            // Create an invalid lockfile
1370            std::fs::write(&lockfile_path, "invalid content").unwrap();
1371
1372            // Test backup and regeneration
1373            let manifest = Manifest::load(&manifest_path).unwrap();
1374            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1375
1376            let backup_path = lockfile_path.with_extension("lock.invalid");
1377
1378            // This should backup the file and remove the original
1379            ctx.backup_and_regenerate_lockfile(&backup_path, "test").unwrap();
1380
1381            // Check that backup was created
1382            assert!(backup_path.exists());
1383            assert_eq!(std::fs::read_to_string(&backup_path).unwrap(), "invalid content");
1384
1385            // Check that original was removed
1386            assert!(!lockfile_path.exists());
1387        }
1388
1389        #[test]
1390        fn test_create_non_interactive_error() {
1391            let temp_dir = TempDir::new().unwrap();
1392            let project_dir = temp_dir.path();
1393            let manifest_path = project_dir.join("agpm.toml");
1394
1395            // Create a minimal manifest
1396            let manifest_content = r#"[sources]
1397example = "https://github.com/example/repo.git"
1398"#;
1399            std::fs::write(&manifest_path, manifest_content).unwrap();
1400
1401            // Test non-interactive error creation
1402            let manifest = Manifest::load(&manifest_path).unwrap();
1403            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1404
1405            let error = ctx.create_non_interactive_error("Invalid TOML syntax", "test");
1406            let error_msg = error.to_string();
1407
1408            assert!(error_msg.contains("Invalid TOML syntax"));
1409            assert!(error_msg.contains("beta software"));
1410            assert!(error_msg.contains("cp agpm.lock"));
1411            assert!(error_msg.contains("rm agpm.lock"));
1412            assert!(error_msg.contains("agpm install"));
1413        }
1414    }
1415
1416    /// Tests for CCPM and format migration functions.
1417    ///
1418    /// Note: These tests focus on non-interactive mode since mocking stdin
1419    /// with tokio::io::stdin() is complex. Interactive behavior is implicitly
1420    /// tested in CI environments which are non-TTY.
1421    mod migration_tests {
1422        use super::*;
1423        use tempfile::TempDir;
1424
1425        #[tokio::test]
1426        async fn test_handle_legacy_ccpm_migration_with_files_non_interactive() {
1427            // Tests run in non-TTY mode, so this tests the non-interactive path
1428            let temp_dir = TempDir::new().unwrap();
1429            let project_dir = temp_dir.path();
1430
1431            // Create legacy CCPM files
1432            std::fs::write(
1433                project_dir.join("ccpm.toml"),
1434                "[sources]\ntest = \"https://test.git\"\n",
1435            )
1436            .unwrap();
1437            std::fs::write(project_dir.join("ccpm.lock"), "version = 1\n").unwrap();
1438
1439            // In non-interactive mode (yes=false), should return None without migrating
1440            let result = handle_legacy_ccpm_migration(Some(project_dir.to_path_buf()), false).await;
1441            assert!(result.is_ok());
1442            assert!(result.unwrap().is_none());
1443
1444            // Files should NOT be migrated (non-interactive mode)
1445            assert!(project_dir.join("ccpm.toml").exists());
1446            assert!(!project_dir.join("agpm.toml").exists());
1447        }
1448
1449        #[tokio::test]
1450        async fn test_handle_legacy_format_migration_no_migration_needed() {
1451            let temp_dir = TempDir::new().unwrap();
1452            let project_dir = temp_dir.path();
1453
1454            // Create project with new format (files in agpm/ subdirectory)
1455            let agents_dir = project_dir.join(".claude/agents/agpm");
1456            std::fs::create_dir_all(&agents_dir).unwrap();
1457            std::fs::write(agents_dir.join("test.md"), "# Test Agent").unwrap();
1458
1459            // Create a lockfile with new paths
1460            let lockfile = r#"version = 1
1461
1462[[agents]]
1463name = "test"
1464source = "test"
1465path = "agents/test.md"
1466version = "v1.0.0"
1467resolved_commit = "abc123"
1468checksum = "sha256:abc"
1469context_checksum = "sha256:def"
1470installed_at = ".claude/agents/agpm/test.md"
1471dependencies = []
1472resource_type = "Agent"
1473tool = "claude-code"
1474"#;
1475            std::fs::write(project_dir.join("agpm.lock"), lockfile).unwrap();
1476
1477            // No migration needed
1478            let result = handle_legacy_format_migration(project_dir, false).await;
1479            assert!(result.is_ok());
1480            assert!(!result.unwrap()); // false = no migration performed
1481        }
1482
1483        #[tokio::test]
1484        async fn test_handle_legacy_format_migration_with_old_paths_non_interactive() {
1485            let temp_dir = TempDir::new().unwrap();
1486            let project_dir = temp_dir.path();
1487
1488            // Create resources at OLD paths (not in agpm/ subdirectory)
1489            let agents_dir = project_dir.join(".claude/agents");
1490            std::fs::create_dir_all(&agents_dir).unwrap();
1491            std::fs::write(agents_dir.join("test.md"), "# Test Agent").unwrap();
1492
1493            // Create a lockfile pointing to old paths
1494            let lockfile = r#"version = 1
1495
1496[[agents]]
1497name = "test"
1498source = "test"
1499path = "agents/test.md"
1500version = "v1.0.0"
1501resolved_commit = "abc123"
1502checksum = "sha256:abc"
1503context_checksum = "sha256:def"
1504installed_at = ".claude/agents/test.md"
1505dependencies = []
1506resource_type = "Agent"
1507tool = "claude-code"
1508"#;
1509            std::fs::write(project_dir.join("agpm.lock"), lockfile).unwrap();
1510
1511            // In non-interactive mode (yes=false), should return false without migrating
1512            let result = handle_legacy_format_migration(project_dir, false).await;
1513            assert!(result.is_ok());
1514            assert!(!result.unwrap()); // false = no migration performed
1515
1516            // Files should NOT be migrated (non-interactive mode)
1517            assert!(agents_dir.join("test.md").exists());
1518            assert!(!agents_dir.join("agpm/test.md").exists());
1519        }
1520
1521        #[tokio::test]
1522        async fn test_handle_legacy_format_migration_with_gitignore_section_non_interactive() {
1523            let temp_dir = TempDir::new().unwrap();
1524            let project_dir = temp_dir.path();
1525
1526            // Create .gitignore with managed section
1527            let gitignore = r#"# User entries
1528node_modules/
1529
1530# AGPM managed entries - do not edit below this line
1531.claude/agents/test.md
1532# End of AGPM managed entries
1533"#;
1534            std::fs::write(project_dir.join(".gitignore"), gitignore).unwrap();
1535
1536            // Create an empty lockfile (gitignore section alone triggers migration)
1537            std::fs::write(project_dir.join("agpm.lock"), "version = 1\n").unwrap();
1538
1539            // In non-interactive mode (yes=false), should return false without migrating
1540            let result = handle_legacy_format_migration(project_dir, false).await;
1541            assert!(result.is_ok());
1542            assert!(!result.unwrap()); // false = no migration performed
1543
1544            // .gitignore should NOT be modified (non-interactive mode)
1545            let content = std::fs::read_to_string(project_dir.join(".gitignore")).unwrap();
1546            assert!(content.contains("# AGPM managed entries"));
1547        }
1548
1549        #[tokio::test]
1550        async fn test_handle_legacy_format_migration_no_lockfile() {
1551            let temp_dir = TempDir::new().unwrap();
1552            let project_dir = temp_dir.path();
1553
1554            // Create resources at old paths but NO lockfile
1555            let agents_dir = project_dir.join(".claude/agents");
1556            std::fs::create_dir_all(&agents_dir).unwrap();
1557            std::fs::write(agents_dir.join("test.md"), "# Test Agent").unwrap();
1558
1559            // Without lockfile, can't detect AGPM-managed files
1560            // so no migration should be detected
1561            let result = handle_legacy_format_migration(project_dir, false).await;
1562            assert!(result.is_ok());
1563            assert!(!result.unwrap()); // false = no migration needed
1564        }
1565    }
1566}