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/// Handle missing gitignore entries by offering to add them interactively.
968///
969/// When missing gitignore entries are detected, this function offers to add
970/// the standard AGPM managed paths section to the project's `.gitignore` file.
971/// It follows the same interactive pattern as the legacy migration handlers.
972///
973/// # Behavior
974///
975/// - **Interactive mode** (TTY): Prompts user with Y/n confirmation
976/// - **Non-interactive mode** (CI/CD): Prints warning and returns
977/// - **Auto-accept mode**: When `yes` is true, adds entries without prompting
978///
979/// # Arguments
980///
981/// * `validation` - The `ConfigValidation` result containing missing entries
982/// * `project_dir` - Path to the project directory
983/// * `yes` - When true, automatically accept without prompting
984///
985/// # Returns
986///
987/// - `Ok(true)` if entries were added successfully
988/// - `Ok(false)` if no entries were added (user declined, non-interactive, or empty)
989/// - `Err` if write operation failed
990///
991/// # Examples
992///
993/// ```no_run
994/// # use anyhow::Result;
995/// # async fn example() -> Result<()> {
996/// use agpm_cli::cli::common::handle_missing_gitignore_entries;
997/// use agpm_cli::installer::ConfigValidation;
998/// use std::path::Path;
999///
1000/// let validation = ConfigValidation::default();
1001///
1002/// // Interactive mode
1003/// let added = handle_missing_gitignore_entries(&validation, Path::new("."), false).await?;
1004///
1005/// // Auto-accept mode (for CI/scripts with --yes flag)
1006/// let added = handle_missing_gitignore_entries(&validation, Path::new("."), true).await?;
1007/// # Ok(())
1008/// # }
1009/// ```
1010pub async fn handle_missing_gitignore_entries(
1011    validation: &crate::installer::ConfigValidation,
1012    project_dir: &Path,
1013    yes: bool,
1014) -> Result<bool> {
1015    use super::migrate::{AGPM_MANAGED_PATHS, AGPM_MANAGED_PATHS_END};
1016    use tokio::io::AsyncWriteExt;
1017
1018    // Early return if no missing entries
1019    if validation.missing_gitignore_entries.is_empty() {
1020        return Ok(false);
1021    }
1022
1023    let missing = &validation.missing_gitignore_entries;
1024    let gitignore_path = project_dir.join(".gitignore");
1025
1026    // Check if managed section already exists (avoid duplicates)
1027    if gitignore_path.exists() {
1028        if let Ok(content) = tokio::fs::read_to_string(&gitignore_path).await {
1029            if content.contains(AGPM_MANAGED_PATHS) {
1030                // Section exists but validation still found missing entries
1031                // This means user has partial entries - just warn, don't modify
1032                eprintln!("\n{}", "Warning: Missing gitignore entries detected:".yellow());
1033                for entry in missing {
1034                    eprintln!("  {}", entry);
1035                }
1036                eprintln!(
1037                    "\nThe {} section exists but may need manual updates.",
1038                    AGPM_MANAGED_PATHS.cyan()
1039                );
1040                return Ok(false);
1041            }
1042        }
1043    }
1044
1045    // Check if we're in an interactive terminal (unless --yes flag is set)
1046    if !yes && !std::io::stdin().is_terminal() {
1047        // Non-interactive mode: print warning and return
1048        eprintln!("\n{}", "Missing gitignore entries detected:".yellow());
1049        for entry in missing {
1050            eprintln!("  {}", entry);
1051        }
1052        eprintln!("\nRun with {} to add them automatically, or add manually.", "--yes".cyan());
1053        return Ok(false);
1054    }
1055
1056    // Show what we found
1057    println!("\n{}", "Missing .gitignore entries detected:".yellow().bold());
1058    for entry in missing {
1059        println!("  {} {}", "→".cyan(), entry);
1060    }
1061    println!();
1062
1063    // Auto-accept if --yes flag is set, otherwise prompt
1064    let should_add = if yes {
1065        true
1066    } else {
1067        print!("{} ", "Would you like to add them now? [Y/n]:".green());
1068        io::stdout().flush()?;
1069
1070        let mut reader = BufReader::new(tokio::io::stdin());
1071        let mut response = String::new();
1072        reader.read_line(&mut response).await?;
1073        let response = response.trim().to_lowercase();
1074        response.is_empty() || response == "y" || response == "yes"
1075    };
1076
1077    if !should_add {
1078        println!("{}", "Skipped adding gitignore entries.".yellow());
1079        return Ok(false);
1080    }
1081
1082    // Build the managed section content
1083    let mut content = String::new();
1084
1085    // Add newline separator if appending to existing file
1086    if gitignore_path.exists() {
1087        let existing = tokio::fs::read_to_string(&gitignore_path).await.unwrap_or_default();
1088        if !existing.is_empty() && !existing.ends_with('\n') {
1089            content.push('\n');
1090        }
1091        content.push('\n');
1092    }
1093
1094    content.push_str(AGPM_MANAGED_PATHS);
1095    content.push('\n');
1096    content.push_str(".claude/*/agpm/\n");
1097    content.push_str(".opencode/*/agpm/\n");
1098    content.push_str(".agpm/\n");
1099    content.push_str("agpm.private.toml\n");
1100    content.push_str("agpm.private.lock\n");
1101    content.push_str(AGPM_MANAGED_PATHS_END);
1102    content.push('\n');
1103
1104    // Append to .gitignore (creates if doesn't exist)
1105    let mut file = tokio::fs::OpenOptions::new()
1106        .create(true)
1107        .append(true)
1108        .open(&gitignore_path)
1109        .await
1110        .context("Failed to open .gitignore for writing")?;
1111
1112    file.write_all(content.as_bytes()).await.context("Failed to write to .gitignore")?;
1113
1114    // Ensure data is flushed to disk
1115    file.sync_all().await.context("Failed to sync .gitignore")?;
1116
1117    println!("{} Added AGPM managed paths section to .gitignore", "✓".green());
1118
1119    Ok(true)
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124    use super::*;
1125    use tempfile::TempDir;
1126
1127    #[test]
1128    fn test_command_context_from_manifest_path() {
1129        let temp_dir = TempDir::new().unwrap();
1130        let manifest_path = temp_dir.path().join("agpm.toml");
1131
1132        // Create a test manifest
1133        std::fs::write(
1134            &manifest_path,
1135            r#"
1136[sources]
1137test = "https://github.com/test/repo.git"
1138
1139[agents]
1140"#,
1141        )
1142        .unwrap();
1143
1144        let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
1145
1146        assert_eq!(context.manifest_path, manifest_path);
1147        assert_eq!(context.project_dir, temp_dir.path());
1148        assert_eq!(context.lockfile_path, temp_dir.path().join("agpm.lock"));
1149        assert!(context.manifest.sources.contains_key("test"));
1150    }
1151
1152    #[test]
1153    fn test_command_context_missing_manifest() {
1154        let result = CommandContext::from_manifest_path("/nonexistent/agpm.toml");
1155        assert!(result.is_err());
1156        assert!(result.unwrap_err().to_string().contains("not found"));
1157    }
1158
1159    #[test]
1160    fn test_command_context_invalid_manifest() {
1161        let temp_dir = TempDir::new().unwrap();
1162        let manifest_path = temp_dir.path().join("agpm.toml");
1163
1164        // Create an invalid manifest
1165        std::fs::write(&manifest_path, "invalid toml {{").unwrap();
1166
1167        let result = CommandContext::from_manifest_path(&manifest_path);
1168        assert!(result.is_err());
1169        assert!(result.unwrap_err().to_string().contains("Failed to parse manifest"));
1170    }
1171
1172    #[test]
1173    fn test_load_lockfile_exists() {
1174        let temp_dir = TempDir::new().unwrap();
1175        let manifest_path = temp_dir.path().join("agpm.toml");
1176        let lockfile_path = temp_dir.path().join("agpm.lock");
1177
1178        // Create test files
1179        std::fs::write(&manifest_path, "[sources]\n").unwrap();
1180        std::fs::write(
1181            &lockfile_path,
1182            r#"
1183version = 1
1184
1185[[sources]]
1186name = "test"
1187url = "https://github.com/test/repo.git"
1188commit = "abc123"
1189fetched_at = "2024-01-01T00:00:00Z"
1190"#,
1191        )
1192        .unwrap();
1193
1194        let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
1195        let lockfile = context.load_lockfile().unwrap();
1196
1197        assert!(lockfile.is_some());
1198        let lockfile = lockfile.unwrap();
1199        assert_eq!(lockfile.sources.len(), 1);
1200        assert_eq!(lockfile.sources[0].name, "test");
1201    }
1202
1203    #[test]
1204    fn test_load_lockfile_not_exists() {
1205        let temp_dir = TempDir::new().unwrap();
1206        let manifest_path = temp_dir.path().join("agpm.toml");
1207
1208        std::fs::write(&manifest_path, "[sources]\n").unwrap();
1209
1210        let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
1211        let lockfile = context.load_lockfile().unwrap();
1212
1213        assert!(lockfile.is_none());
1214    }
1215
1216    #[test]
1217    fn test_save_lockfile() {
1218        let temp_dir = TempDir::new().unwrap();
1219        let manifest_path = temp_dir.path().join("agpm.toml");
1220
1221        std::fs::write(&manifest_path, "[sources]\n").unwrap();
1222
1223        let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
1224
1225        let lockfile = crate::lockfile::LockFile {
1226            version: 1,
1227            sources: vec![],
1228            agents: vec![],
1229            snippets: vec![],
1230            commands: vec![],
1231            scripts: vec![],
1232            hooks: vec![],
1233            mcp_servers: vec![],
1234            skills: vec![],
1235            manifest_hash: None,
1236            has_mutable_deps: None,
1237            resource_count: None,
1238        };
1239
1240        context.save_lockfile(&lockfile).unwrap();
1241
1242        assert!(context.lockfile_path.exists());
1243        let saved_content = std::fs::read_to_string(&context.lockfile_path).unwrap();
1244        assert!(saved_content.contains("version = 1"));
1245    }
1246
1247    #[test]
1248    fn test_check_for_legacy_ccpm_no_files() {
1249        let temp_dir = TempDir::new().unwrap();
1250        let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
1251        assert!(result.is_none());
1252    }
1253
1254    #[test]
1255    fn test_check_for_legacy_ccpm_toml_only() {
1256        let temp_dir = TempDir::new().unwrap();
1257        std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
1258
1259        let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
1260        assert!(result.is_some());
1261        let msg = result.unwrap();
1262        assert!(msg.contains("Legacy CCPM files detected"));
1263        assert!(msg.contains("ccpm.toml"));
1264        assert!(msg.contains("agpm migrate"));
1265    }
1266
1267    #[test]
1268    fn test_check_for_legacy_ccpm_lock_only() {
1269        let temp_dir = TempDir::new().unwrap();
1270        std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
1271
1272        let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
1273        assert!(result.is_some());
1274        let msg = result.unwrap();
1275        assert!(msg.contains("ccpm.lock"));
1276    }
1277
1278    #[test]
1279    fn test_check_for_legacy_ccpm_both_files() {
1280        let temp_dir = TempDir::new().unwrap();
1281        std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
1282        std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
1283
1284        let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
1285        assert!(result.is_some());
1286        let msg = result.unwrap();
1287        assert!(msg.contains("ccpm.toml and ccpm.lock"));
1288    }
1289
1290    #[test]
1291    fn test_find_legacy_ccpm_directory_no_files() {
1292        let temp_dir = TempDir::new().unwrap();
1293        let result = find_legacy_ccpm_directory(temp_dir.path());
1294        assert!(result.is_none());
1295    }
1296
1297    #[test]
1298    fn test_find_legacy_ccpm_directory_in_current_dir() {
1299        let temp_dir = TempDir::new().unwrap();
1300        std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
1301
1302        let result = find_legacy_ccpm_directory(temp_dir.path());
1303        assert!(result.is_some());
1304        assert_eq!(result.unwrap(), temp_dir.path());
1305    }
1306
1307    #[test]
1308    fn test_find_legacy_ccpm_directory_in_parent() {
1309        let temp_dir = TempDir::new().unwrap();
1310        let parent = temp_dir.path();
1311        let child = parent.join("subdir");
1312        std::fs::create_dir(&child).unwrap();
1313
1314        // Create legacy file in parent
1315        std::fs::write(parent.join("ccpm.toml"), "[sources]\n").unwrap();
1316
1317        // Search from child directory
1318        let result = find_legacy_ccpm_directory(&child);
1319        assert!(result.is_some());
1320        assert_eq!(result.unwrap(), parent);
1321    }
1322
1323    #[test]
1324    fn test_find_legacy_ccpm_directory_finds_lock_file() {
1325        let temp_dir = TempDir::new().unwrap();
1326        std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
1327
1328        let result = find_legacy_ccpm_directory(temp_dir.path());
1329        assert!(result.is_some());
1330        assert_eq!(result.unwrap(), temp_dir.path());
1331    }
1332
1333    #[tokio::test]
1334    async fn test_handle_legacy_ccpm_migration_no_files() -> Result<()> {
1335        let temp_dir = TempDir::new()?;
1336
1337        // Test directory with no legacy files
1338        let result = handle_legacy_ccpm_migration(Some(temp_dir.path().to_path_buf()), false).await;
1339
1340        assert!(result?.is_none());
1341        Ok(())
1342    }
1343
1344    #[cfg(test)]
1345    mod lockfile_regeneration_tests {
1346        use super::*;
1347        use crate::manifest::Manifest;
1348        use tempfile::TempDir;
1349
1350        #[test]
1351        fn test_load_lockfile_with_regeneration_valid_lockfile() {
1352            let temp_dir = TempDir::new().unwrap();
1353            let project_dir = temp_dir.path();
1354            let manifest_path = project_dir.join("agpm.toml");
1355            let lockfile_path = project_dir.join("agpm.lock");
1356
1357            // Create a minimal manifest
1358            let manifest_content = r#"[sources]
1359example = "https://github.com/example/repo.git"
1360
1361[agents]
1362test = { source = "example", path = "test.md", version = "v1.0.0" }
1363"#;
1364            std::fs::write(&manifest_path, manifest_content).unwrap();
1365
1366            // Create a valid lockfile
1367            let lockfile_content = r#"version = 1
1368
1369[[sources]]
1370name = "example"
1371url = "https://github.com/example/repo.git"
1372commit = "abc123def456789012345678901234567890abcd"
1373fetched_at = "2024-01-01T00:00:00Z"
1374
1375[[agents]]
1376name = "test"
1377source = "example"
1378path = "test.md"
1379version = "v1.0.0"
1380resolved_commit = "abc123def456789012345678901234567890abcd"
1381checksum = "sha256:examplechecksum"
1382installed_at = ".claude/agents/test.md"
1383"#;
1384            std::fs::write(&lockfile_path, lockfile_content).unwrap();
1385
1386            // Test loading valid lockfile
1387            let manifest = Manifest::load(&manifest_path).unwrap();
1388            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1389
1390            let result = ctx.load_lockfile_with_regeneration(true, "test").unwrap();
1391            assert!(result.is_some());
1392        }
1393
1394        #[test]
1395        fn test_load_lockfile_with_regeneration_invalid_toml() {
1396            let temp_dir = TempDir::new().unwrap();
1397            let project_dir = temp_dir.path();
1398            let manifest_path = project_dir.join("agpm.toml");
1399            let lockfile_path = project_dir.join("agpm.lock");
1400
1401            // Create a minimal manifest
1402            let manifest_content = r#"[sources]
1403example = "https://github.com/example/repo.git"
1404"#;
1405            std::fs::write(&manifest_path, manifest_content).unwrap();
1406
1407            // Create an invalid TOML lockfile
1408            std::fs::write(&lockfile_path, "invalid toml [[[").unwrap();
1409
1410            // Test loading invalid lockfile in non-interactive mode
1411            let manifest = Manifest::load(&manifest_path).unwrap();
1412            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1413
1414            // This should return an error in non-interactive mode
1415            let result = ctx.load_lockfile_with_regeneration(true, "test");
1416            assert!(result.is_err());
1417
1418            let error_msg = result.unwrap_err().to_string();
1419            assert!(error_msg.contains("Invalid or corrupted lockfile detected"));
1420            assert!(error_msg.contains("beta software"));
1421            assert!(error_msg.contains("cp agpm.lock"));
1422        }
1423
1424        #[test]
1425        fn test_load_lockfile_with_regeneration_missing_lockfile() {
1426            let temp_dir = TempDir::new().unwrap();
1427            let project_dir = temp_dir.path();
1428            let manifest_path = project_dir.join("agpm.toml");
1429
1430            // Create a minimal manifest
1431            let manifest_content = r#"[sources]
1432example = "https://github.com/example/repo.git"
1433"#;
1434            std::fs::write(&manifest_path, manifest_content).unwrap();
1435
1436            // Test loading non-existent lockfile
1437            let manifest = Manifest::load(&manifest_path).unwrap();
1438            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1439
1440            let result = ctx.load_lockfile_with_regeneration(true, "test").unwrap();
1441            assert!(result.is_none()); // Should return None for missing lockfile
1442        }
1443
1444        #[test]
1445        fn test_load_lockfile_with_regeneration_version_incompatibility() {
1446            let temp_dir = TempDir::new().unwrap();
1447            let project_dir = temp_dir.path();
1448            let manifest_path = project_dir.join("agpm.toml");
1449            let lockfile_path = project_dir.join("agpm.lock");
1450
1451            // Create a minimal manifest
1452            let manifest_content = r#"[sources]
1453example = "https://github.com/example/repo.git"
1454"#;
1455            std::fs::write(&manifest_path, manifest_content).unwrap();
1456
1457            // Create a lockfile with future version
1458            let lockfile_content = r#"version = 999
1459
1460[[sources]]
1461name = "example"
1462url = "https://github.com/example/repo.git"
1463commit = "abc123def456789012345678901234567890abcd"
1464fetched_at = "2024-01-01T00:00:00Z"
1465"#;
1466            std::fs::write(&lockfile_path, lockfile_content).unwrap();
1467
1468            // Test loading future version lockfile
1469            let manifest = Manifest::load(&manifest_path).unwrap();
1470            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1471
1472            let result = ctx.load_lockfile_with_regeneration(true, "test");
1473            assert!(result.is_err());
1474
1475            let error_msg = result.unwrap_err().to_string();
1476            assert!(error_msg.contains("version") || error_msg.contains("newer"));
1477        }
1478
1479        #[test]
1480        fn test_load_lockfile_with_regeneration_cannot_regenerate() {
1481            let temp_dir = TempDir::new().unwrap();
1482            let project_dir = temp_dir.path();
1483            let manifest_path = project_dir.join("agpm.toml");
1484            let lockfile_path = project_dir.join("agpm.lock");
1485
1486            // Create a minimal manifest
1487            let manifest_content = r#"[sources]
1488example = "https://github.com/example/repo.git"
1489"#;
1490            std::fs::write(&manifest_path, manifest_content).unwrap();
1491
1492            // Create an invalid TOML lockfile
1493            std::fs::write(&lockfile_path, "invalid toml [[[").unwrap();
1494
1495            // Test with can_regenerate = false
1496            let manifest = Manifest::load(&manifest_path).unwrap();
1497            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1498
1499            let result = ctx.load_lockfile_with_regeneration(false, "test");
1500            assert!(result.is_err());
1501
1502            // Should return the original error, not the enhanced one
1503            let error_msg = result.unwrap_err().to_string();
1504            assert!(!error_msg.contains("Invalid or corrupted lockfile detected"));
1505            assert!(
1506                error_msg.contains("Failed to load lockfile")
1507                    || error_msg.contains("Invalid TOML syntax")
1508            );
1509        }
1510
1511        #[test]
1512        fn test_backup_and_regenerate_lockfile() {
1513            let temp_dir = TempDir::new().unwrap();
1514            let project_dir = temp_dir.path();
1515            let manifest_path = project_dir.join("agpm.toml");
1516            let lockfile_path = project_dir.join("agpm.lock");
1517
1518            // Create a minimal manifest
1519            let manifest_content = r#"[sources]
1520example = "https://github.com/example/repo.git"
1521"#;
1522            std::fs::write(&manifest_path, manifest_content).unwrap();
1523
1524            // Create an invalid lockfile
1525            std::fs::write(&lockfile_path, "invalid content").unwrap();
1526
1527            // Test backup and regeneration
1528            let manifest = Manifest::load(&manifest_path).unwrap();
1529            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1530
1531            let backup_path = lockfile_path.with_extension("lock.invalid");
1532
1533            // This should backup the file and remove the original
1534            ctx.backup_and_regenerate_lockfile(&backup_path, "test").unwrap();
1535
1536            // Check that backup was created
1537            assert!(backup_path.exists());
1538            assert_eq!(std::fs::read_to_string(&backup_path).unwrap(), "invalid content");
1539
1540            // Check that original was removed
1541            assert!(!lockfile_path.exists());
1542        }
1543
1544        #[test]
1545        fn test_create_non_interactive_error() {
1546            let temp_dir = TempDir::new().unwrap();
1547            let project_dir = temp_dir.path();
1548            let manifest_path = project_dir.join("agpm.toml");
1549
1550            // Create a minimal manifest
1551            let manifest_content = r#"[sources]
1552example = "https://github.com/example/repo.git"
1553"#;
1554            std::fs::write(&manifest_path, manifest_content).unwrap();
1555
1556            // Test non-interactive error creation
1557            let manifest = Manifest::load(&manifest_path).unwrap();
1558            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1559
1560            let error = ctx.create_non_interactive_error("Invalid TOML syntax", "test");
1561            let error_msg = error.to_string();
1562
1563            assert!(error_msg.contains("Invalid TOML syntax"));
1564            assert!(error_msg.contains("beta software"));
1565            assert!(error_msg.contains("cp agpm.lock"));
1566            assert!(error_msg.contains("rm agpm.lock"));
1567            assert!(error_msg.contains("agpm install"));
1568        }
1569    }
1570
1571    /// Tests for CCPM and format migration functions.
1572    ///
1573    /// Note: These tests focus on non-interactive mode since mocking stdin
1574    /// with tokio::io::stdin() is complex. Interactive behavior is implicitly
1575    /// tested in CI environments which are non-TTY.
1576    mod migration_tests {
1577        use super::*;
1578        use tempfile::TempDir;
1579
1580        #[tokio::test]
1581        async fn test_handle_legacy_ccpm_migration_with_files_non_interactive() {
1582            // Tests run in non-TTY mode, so this tests the non-interactive path
1583            let temp_dir = TempDir::new().unwrap();
1584            let project_dir = temp_dir.path();
1585
1586            // Create legacy CCPM files
1587            std::fs::write(
1588                project_dir.join("ccpm.toml"),
1589                "[sources]\ntest = \"https://test.git\"\n",
1590            )
1591            .unwrap();
1592            std::fs::write(project_dir.join("ccpm.lock"), "version = 1\n").unwrap();
1593
1594            // In non-interactive mode (yes=false), should return None without migrating
1595            let result = handle_legacy_ccpm_migration(Some(project_dir.to_path_buf()), false).await;
1596            assert!(result.is_ok());
1597            assert!(result.unwrap().is_none());
1598
1599            // Files should NOT be migrated (non-interactive mode)
1600            assert!(project_dir.join("ccpm.toml").exists());
1601            assert!(!project_dir.join("agpm.toml").exists());
1602        }
1603
1604        #[tokio::test]
1605        async fn test_handle_legacy_format_migration_no_migration_needed() {
1606            let temp_dir = TempDir::new().unwrap();
1607            let project_dir = temp_dir.path();
1608
1609            // Create project with new format (files in agpm/ subdirectory)
1610            let agents_dir = project_dir.join(".claude/agents/agpm");
1611            std::fs::create_dir_all(&agents_dir).unwrap();
1612            std::fs::write(agents_dir.join("test.md"), "# Test Agent").unwrap();
1613
1614            // Create a lockfile with new paths
1615            let lockfile = r#"version = 1
1616
1617[[agents]]
1618name = "test"
1619source = "test"
1620path = "agents/test.md"
1621version = "v1.0.0"
1622resolved_commit = "abc123"
1623checksum = "sha256:abc"
1624context_checksum = "sha256:def"
1625installed_at = ".claude/agents/agpm/test.md"
1626dependencies = []
1627resource_type = "Agent"
1628tool = "claude-code"
1629"#;
1630            std::fs::write(project_dir.join("agpm.lock"), lockfile).unwrap();
1631
1632            // No migration needed
1633            let result = handle_legacy_format_migration(project_dir, false).await;
1634            assert!(result.is_ok());
1635            assert!(!result.unwrap()); // false = no migration performed
1636        }
1637
1638        #[tokio::test]
1639        async fn test_handle_legacy_format_migration_with_old_paths_non_interactive() {
1640            let temp_dir = TempDir::new().unwrap();
1641            let project_dir = temp_dir.path();
1642
1643            // Create resources at OLD paths (not in agpm/ subdirectory)
1644            let agents_dir = project_dir.join(".claude/agents");
1645            std::fs::create_dir_all(&agents_dir).unwrap();
1646            std::fs::write(agents_dir.join("test.md"), "# Test Agent").unwrap();
1647
1648            // Create a lockfile pointing to old paths
1649            let lockfile = r#"version = 1
1650
1651[[agents]]
1652name = "test"
1653source = "test"
1654path = "agents/test.md"
1655version = "v1.0.0"
1656resolved_commit = "abc123"
1657checksum = "sha256:abc"
1658context_checksum = "sha256:def"
1659installed_at = ".claude/agents/test.md"
1660dependencies = []
1661resource_type = "Agent"
1662tool = "claude-code"
1663"#;
1664            std::fs::write(project_dir.join("agpm.lock"), lockfile).unwrap();
1665
1666            // In non-interactive mode (yes=false), should return false without migrating
1667            let result = handle_legacy_format_migration(project_dir, false).await;
1668            assert!(result.is_ok());
1669            assert!(!result.unwrap()); // false = no migration performed
1670
1671            // Files should NOT be migrated (non-interactive mode)
1672            assert!(agents_dir.join("test.md").exists());
1673            assert!(!agents_dir.join("agpm/test.md").exists());
1674        }
1675
1676        #[tokio::test]
1677        async fn test_handle_legacy_format_migration_with_gitignore_section_non_interactive() {
1678            let temp_dir = TempDir::new().unwrap();
1679            let project_dir = temp_dir.path();
1680
1681            // Create .gitignore with managed section
1682            let gitignore = r#"# User entries
1683node_modules/
1684
1685# AGPM managed entries - do not edit below this line
1686.claude/agents/test.md
1687# End of AGPM managed entries
1688"#;
1689            std::fs::write(project_dir.join(".gitignore"), gitignore).unwrap();
1690
1691            // Create an empty lockfile (gitignore section alone triggers migration)
1692            std::fs::write(project_dir.join("agpm.lock"), "version = 1\n").unwrap();
1693
1694            // In non-interactive mode (yes=false), should return false without migrating
1695            let result = handle_legacy_format_migration(project_dir, false).await;
1696            assert!(result.is_ok());
1697            assert!(!result.unwrap()); // false = no migration performed
1698
1699            // .gitignore should NOT be modified (non-interactive mode)
1700            let content = std::fs::read_to_string(project_dir.join(".gitignore")).unwrap();
1701            assert!(content.contains("# AGPM managed entries"));
1702        }
1703
1704        #[tokio::test]
1705        async fn test_handle_legacy_format_migration_no_lockfile() {
1706            let temp_dir = TempDir::new().unwrap();
1707            let project_dir = temp_dir.path();
1708
1709            // Create resources at old paths but NO lockfile
1710            let agents_dir = project_dir.join(".claude/agents");
1711            std::fs::create_dir_all(&agents_dir).unwrap();
1712            std::fs::write(agents_dir.join("test.md"), "# Test Agent").unwrap();
1713
1714            // Without lockfile, can't detect AGPM-managed files
1715            // so no migration should be detected
1716            let result = handle_legacy_format_migration(project_dir, false).await;
1717            assert!(result.is_ok());
1718            assert!(!result.unwrap()); // false = no migration needed
1719        }
1720    }
1721
1722    /// Tests for gitignore entry offering functionality.
1723    mod gitignore_offering_tests {
1724        use super::*;
1725        use crate::installer::ConfigValidation;
1726        use tempfile::TempDir;
1727
1728        #[tokio::test]
1729        async fn test_handle_missing_gitignore_no_entries() {
1730            let temp_dir = TempDir::new().unwrap();
1731            let validation = ConfigValidation::default(); // Empty missing entries
1732
1733            let result =
1734                handle_missing_gitignore_entries(&validation, temp_dir.path(), false).await;
1735
1736            assert!(result.is_ok());
1737            assert!(!result.unwrap()); // No entries added
1738        }
1739
1740        #[tokio::test]
1741        async fn test_handle_missing_gitignore_with_yes_flag() {
1742            let temp_dir = TempDir::new().unwrap();
1743            let validation = ConfigValidation {
1744                missing_gitignore_entries: vec![
1745                    ".claude/agents/agpm/".to_string(),
1746                    "agpm.private.toml".to_string(),
1747                ],
1748                ..Default::default()
1749            };
1750
1751            // With --yes flag, should add entries without prompting
1752            let result = handle_missing_gitignore_entries(
1753                &validation,
1754                temp_dir.path(),
1755                true, // yes=true
1756            )
1757            .await;
1758
1759            assert!(result.is_ok());
1760            assert!(result.unwrap()); // Entries added
1761
1762            // Verify .gitignore was created with correct content
1763            let content = std::fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
1764            assert!(content.contains("# AGPM managed paths"));
1765            assert!(content.contains(".claude/*/agpm/"));
1766            assert!(content.contains(".opencode/*/agpm/"));
1767            assert!(content.contains(".agpm/"));
1768            assert!(content.contains("agpm.private.toml"));
1769            assert!(content.contains("agpm.private.lock"));
1770            assert!(content.contains("# End of AGPM managed paths"));
1771        }
1772
1773        #[tokio::test]
1774        async fn test_handle_missing_gitignore_appends_to_existing() {
1775            let temp_dir = TempDir::new().unwrap();
1776
1777            // Create existing .gitignore
1778            std::fs::write(temp_dir.path().join(".gitignore"), "node_modules/\n.env\n").unwrap();
1779
1780            let validation = ConfigValidation {
1781                missing_gitignore_entries: vec![".claude/agents/agpm/".to_string()],
1782                ..Default::default()
1783            };
1784
1785            let result = handle_missing_gitignore_entries(&validation, temp_dir.path(), true).await;
1786
1787            assert!(result.is_ok());
1788            assert!(result.unwrap());
1789
1790            let content = std::fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
1791            // Original content preserved
1792            assert!(content.contains("node_modules/"));
1793            assert!(content.contains(".env"));
1794            // New content added
1795            assert!(content.contains("# AGPM managed paths"));
1796            assert!(content.contains(".claude/*/agpm/"));
1797        }
1798
1799        #[tokio::test]
1800        async fn test_handle_missing_gitignore_non_interactive_no_yes() {
1801            // In test environment, stdin is not a TTY, so this tests non-interactive mode
1802            let temp_dir = TempDir::new().unwrap();
1803            let validation = ConfigValidation {
1804                missing_gitignore_entries: vec![".claude/agents/agpm/".to_string()],
1805                ..Default::default()
1806            };
1807
1808            let result = handle_missing_gitignore_entries(
1809                &validation,
1810                temp_dir.path(),
1811                false, // yes=false, non-TTY
1812            )
1813            .await;
1814
1815            assert!(result.is_ok());
1816            assert!(!result.unwrap()); // No entries added in non-interactive
1817
1818            // .gitignore should not be created
1819            assert!(!temp_dir.path().join(".gitignore").exists());
1820        }
1821
1822        #[tokio::test]
1823        async fn test_handle_missing_gitignore_skips_if_section_exists() {
1824            let temp_dir = TempDir::new().unwrap();
1825
1826            // Create .gitignore with existing managed section
1827            let existing = r#"node_modules/
1828
1829# AGPM managed paths
1830.claude/*/agpm/
1831# End of AGPM managed paths
1832"#;
1833            std::fs::write(temp_dir.path().join(".gitignore"), existing).unwrap();
1834
1835            // Even with missing entries, if section exists, don't add again
1836            let validation = ConfigValidation {
1837                missing_gitignore_entries: vec!["agpm.private.toml".to_string()],
1838                ..Default::default()
1839            };
1840
1841            let result = handle_missing_gitignore_entries(&validation, temp_dir.path(), true).await;
1842
1843            assert!(result.is_ok());
1844            assert!(!result.unwrap()); // Not added (section exists)
1845
1846            // Content should be unchanged
1847            let content = std::fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
1848            assert_eq!(content, existing);
1849        }
1850
1851        #[tokio::test]
1852        async fn test_handle_missing_gitignore_creates_new_file() {
1853            let temp_dir = TempDir::new().unwrap();
1854
1855            // No .gitignore exists
1856            assert!(!temp_dir.path().join(".gitignore").exists());
1857
1858            let validation = ConfigValidation {
1859                missing_gitignore_entries: vec![".claude/agents/agpm/".to_string()],
1860                ..Default::default()
1861            };
1862
1863            let result = handle_missing_gitignore_entries(&validation, temp_dir.path(), true).await;
1864
1865            assert!(result.is_ok());
1866            assert!(result.unwrap());
1867
1868            // .gitignore should be created
1869            assert!(temp_dir.path().join(".gitignore").exists());
1870            let content = std::fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
1871            assert!(content.contains("# AGPM managed paths"));
1872        }
1873
1874        #[tokio::test]
1875        async fn test_handle_missing_gitignore_handles_file_without_newline() {
1876            let temp_dir = TempDir::new().unwrap();
1877
1878            // Create .gitignore without trailing newline
1879            std::fs::write(temp_dir.path().join(".gitignore"), "node_modules/").unwrap();
1880
1881            let validation = ConfigValidation {
1882                missing_gitignore_entries: vec![".claude/agents/agpm/".to_string()],
1883                ..Default::default()
1884            };
1885
1886            let result = handle_missing_gitignore_entries(&validation, temp_dir.path(), true).await;
1887
1888            assert!(result.is_ok());
1889            assert!(result.unwrap());
1890
1891            let content = std::fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
1892            // Should have proper separation (newline added before section)
1893            assert!(content.contains("node_modules/\n\n# AGPM managed paths"));
1894        }
1895    }
1896}