agpm_cli/cli/
common.rs

1//! Common utilities and traits for CLI commands
2
3use anyhow::{Context, Result};
4use colored::Colorize;
5use std::io::{self, IsTerminal, Write};
6use std::path::{Path, PathBuf};
7use tokio::io::{AsyncBufReadExt, BufReader};
8
9use crate::manifest::{Manifest, find_manifest};
10
11/// Common trait for CLI command execution pattern
12pub trait CommandExecutor: Sized {
13    /// Execute the command, finding the manifest automatically
14    fn execute(self) -> impl std::future::Future<Output = Result<()>> + Send
15    where
16        Self: Send,
17    {
18        async move {
19            let manifest_path = if let Ok(path) = find_manifest() {
20                path
21            } else {
22                // Check if legacy CCPM files exist and offer interactive migration
23                match handle_legacy_ccpm_migration().await {
24                    Ok(Some(path)) => path,
25                    Ok(None) => {
26                        return Err(anyhow::anyhow!(
27                            "No agpm.toml found in current directory or any parent directory. \
28                             Run 'agpm init' to create a new project."
29                        ));
30                    }
31                    Err(e) => return Err(e),
32                }
33            };
34            self.execute_from_path(manifest_path).await
35        }
36    }
37
38    /// Execute the command with a specific manifest path
39    fn execute_from_path(
40        self,
41        manifest_path: PathBuf,
42    ) -> impl std::future::Future<Output = Result<()>> + Send;
43}
44
45/// Common context for CLI commands that need manifest and project information
46#[derive(Debug)]
47pub struct CommandContext {
48    /// Parsed project manifest (agpm.toml)
49    pub manifest: Manifest,
50    /// Path to the manifest file
51    pub manifest_path: PathBuf,
52    /// Project root directory (containing agpm.toml)
53    pub project_dir: PathBuf,
54    /// Path to the lockfile (agpm.lock)
55    pub lockfile_path: PathBuf,
56}
57
58impl CommandContext {
59    /// Create a new command context from a manifest and project directory
60    pub fn new(manifest: Manifest, project_dir: PathBuf) -> Result<Self> {
61        let lockfile_path = project_dir.join("agpm.lock");
62        Ok(Self {
63            manifest,
64            manifest_path: project_dir.join("agpm.toml"),
65            project_dir,
66            lockfile_path,
67        })
68    }
69
70    /// Create a new command context from a manifest path
71    ///
72    /// # Errors
73    /// Returns an error if the manifest file doesn't exist or cannot be read
74    pub fn from_manifest_path(manifest_path: impl AsRef<Path>) -> Result<Self> {
75        let manifest_path = manifest_path.as_ref();
76
77        if !manifest_path.exists() {
78            return Err(anyhow::anyhow!("Manifest file {} not found", manifest_path.display()));
79        }
80
81        let project_dir = manifest_path
82            .parent()
83            .ok_or_else(|| anyhow::anyhow!("Invalid manifest path"))?
84            .to_path_buf();
85
86        let manifest = Manifest::load(manifest_path).with_context(|| {
87            format!("Failed to parse manifest file: {}", manifest_path.display())
88        })?;
89
90        let lockfile_path = project_dir.join("agpm.lock");
91
92        Ok(Self {
93            manifest,
94            manifest_path: manifest_path.to_path_buf(),
95            project_dir,
96            lockfile_path,
97        })
98    }
99
100    /// Load an existing lockfile if it exists
101    ///
102    /// # Errors
103    /// Returns an error if the lockfile exists but cannot be parsed
104    pub fn load_lockfile(&self) -> Result<Option<crate::lockfile::LockFile>> {
105        if self.lockfile_path.exists() {
106            let lockfile =
107                crate::lockfile::LockFile::load(&self.lockfile_path).with_context(|| {
108                    format!("Failed to load lockfile: {}", self.lockfile_path.display())
109                })?;
110            Ok(Some(lockfile))
111        } else {
112            Ok(None)
113        }
114    }
115
116    /// Load an existing lockfile with automatic regeneration for invalid files
117    ///
118    /// If the lockfile exists but is invalid or corrupted, this method will
119    /// offer to automatically regenerate it. This provides a better user
120    /// experience by recovering from common lockfile issues.
121    ///
122    /// # Arguments
123    ///
124    /// * `can_regenerate` - Whether automatic regeneration should be offered
125    /// * `operation_name` - Name of the operation for error messages (e.g., "list")
126    ///
127    /// # Returns
128    ///
129    /// * `Ok(Some(lockfile))` - Successfully loaded or regenerated lockfile
130    /// * `Ok(None)` - No lockfile exists (not an error)
131    /// * `Err` - Critical error that cannot be recovered from
132    ///
133    /// # Behavior
134    ///
135    /// - **Interactive mode** (TTY): Prompts user with Y/n confirmation
136    /// - **Non-interactive mode** (CI/CD): Fails with helpful error message
137    /// - **Backup strategy**: Copies invalid lockfile to `agpm.lock.invalid` before regeneration
138    ///
139    /// # Examples
140    ///
141    /// ```no_run
142    /// # use anyhow::Result;
143    /// # use agpm_cli::cli::common::CommandContext;
144    /// # use agpm_cli::manifest::Manifest;
145    /// # use std::path::PathBuf;
146    /// # async fn example() -> Result<()> {
147    /// let manifest = Manifest::load(&PathBuf::from("agpm.toml"))?;
148    /// let project_dir = PathBuf::from(".");
149    /// let ctx = CommandContext::new(manifest, project_dir)?;
150    /// match ctx.load_lockfile_with_regeneration(true, "list") {
151    ///     Ok(Some(lockfile)) => println!("Loaded lockfile"),
152    ///     Ok(None) => println!("No lockfile found"),
153    ///     Err(e) => eprintln!("Error: {}", e),
154    /// }
155    /// # Ok(())
156    /// # }
157    /// ```
158    pub fn load_lockfile_with_regeneration(
159        &self,
160        can_regenerate: bool,
161        operation_name: &str,
162    ) -> Result<Option<crate::lockfile::LockFile>> {
163        // If lockfile doesn't exist, that's not an error
164        if !self.lockfile_path.exists() {
165            return Ok(None);
166        }
167
168        // Try to load the lockfile
169        match crate::lockfile::LockFile::load(&self.lockfile_path) {
170            Ok(lockfile) => Ok(Some(lockfile)),
171            Err(e) => {
172                // Analyze the error to see if it's recoverable
173                let error_msg = e.to_string();
174                let can_auto_recover = can_regenerate
175                    && (error_msg.contains("Invalid TOML syntax")
176                        || error_msg.contains("Lockfile version")
177                        || error_msg.contains("missing field")
178                        || error_msg.contains("invalid type")
179                        || error_msg.contains("expected"));
180
181                if !can_auto_recover {
182                    // Not a recoverable error, return the original error
183                    return Err(e);
184                }
185
186                // This is a recoverable error, offer regeneration
187                let backup_path = self.lockfile_path.with_extension("lock.invalid");
188
189                // Create user-friendly message
190                let regenerate_message = format!(
191                    "The lockfile appears to be invalid or corrupted.\n\n\
192                     Error: {}\n\n\
193                     Note: The lockfile format is not yet stable as this is beta software.\n\n\
194                     The invalid lockfile will be backed up to: {}",
195                    error_msg,
196                    backup_path.display()
197                );
198
199                // Check if we're in interactive mode
200                if io::stdin().is_terminal() {
201                    // Interactive mode: prompt user
202                    println!("{}", regenerate_message);
203                    print!("Would you like to regenerate the lockfile automatically? [Y/n] ");
204                    io::stdout().flush().unwrap();
205
206                    let mut input = String::new();
207                    match io::stdin().read_line(&mut input) {
208                        Ok(_) => {
209                            let response = input.trim().to_lowercase();
210                            if response.is_empty() || response == "y" || response == "yes" {
211                                // User agreed to regenerate
212                                self.backup_and_regenerate_lockfile(&backup_path, operation_name)?;
213                                Ok(None) // Return None so caller creates new lockfile
214                            } else {
215                                // User declined, return the original error
216                                Err(crate::core::AgpmError::InvalidLockfileError {
217                                    file: self.lockfile_path.display().to_string(),
218                                    reason: format!(
219                                        "{} (User declined automatic regeneration)",
220                                        error_msg
221                                    ),
222                                    can_regenerate: true,
223                                }
224                                .into())
225                            }
226                        }
227                        Err(_) => {
228                            // Failed to read input, fall back to non-interactive behavior
229                            Err(self.create_non_interactive_error(&error_msg, operation_name))
230                        }
231                    }
232                } else {
233                    // Non-interactive mode: fail with helpful message
234                    Err(self.create_non_interactive_error(&error_msg, operation_name))
235                }
236            }
237        }
238    }
239
240    /// Backup the invalid lockfile and display regeneration instructions
241    fn backup_and_regenerate_lockfile(
242        &self,
243        backup_path: &Path,
244        operation_name: &str,
245    ) -> Result<()> {
246        // Backup the invalid lockfile
247        if let Err(e) = std::fs::copy(&self.lockfile_path, backup_path) {
248            eprintln!("Warning: Failed to backup invalid lockfile: {}", e);
249        } else {
250            println!("✓ Backed up invalid lockfile to: {}", backup_path.display());
251        }
252
253        // Remove the invalid lockfile
254        if let Err(e) = std::fs::remove_file(&self.lockfile_path) {
255            return Err(anyhow::anyhow!("Failed to remove invalid lockfile: {}", e));
256        }
257
258        println!("✓ Removed invalid lockfile");
259        println!("Note: Run 'agpm install' to regenerate the lockfile");
260
261        // If this is not an install command, suggest running install
262        if operation_name != "install" {
263            println!("Alternatively, run 'agpm {} --regenerate' if available", operation_name);
264        }
265
266        Ok(())
267    }
268
269    /// Create a non-interactive error message for CI/CD environments
270    fn create_non_interactive_error(
271        &self,
272        error_msg: &str,
273        _operation_name: &str,
274    ) -> anyhow::Error {
275        let backup_path = self.lockfile_path.with_extension("lock.invalid");
276
277        crate::core::AgpmError::InvalidLockfileError {
278            file: self.lockfile_path.display().to_string(),
279            reason: format!(
280                "{}\n\n\
281                 To fix this issue:\n\
282                 1. Backup the invalid lockfile: cp agpm.lock {}\n\
283                 2. Remove the invalid lockfile: rm agpm.lock\n\
284                 3. Regenerate it: agpm install\n\n\
285                 Note: The lockfile format is not yet stable as this is beta software.",
286                error_msg,
287                backup_path.display()
288            ),
289            can_regenerate: true,
290        }
291        .into()
292    }
293
294    /// Save a lockfile to the project directory
295    ///
296    /// # Errors
297    /// Returns an error if the lockfile cannot be written
298    pub fn save_lockfile(&self, lockfile: &crate::lockfile::LockFile) -> Result<()> {
299        lockfile
300            .save(&self.lockfile_path)
301            .with_context(|| format!("Failed to save lockfile: {}", self.lockfile_path.display()))
302    }
303}
304
305/// Handle legacy CCPM files by offering interactive migration.
306///
307/// This function searches for ccpm.toml and ccpm.lock files in the current
308/// directory and parent directories. If found, it prompts the user to migrate
309/// and performs the migration if they accept.
310///
311/// # Behavior
312///
313/// - **Interactive mode**: Prompts user with Y/n confirmation (stdin is a TTY)
314/// - **Non-interactive mode**: Returns `Ok(None)` if stdin is not a TTY (e.g., CI/CD)
315/// - **Search scope**: Traverses from current directory to filesystem root
316///
317/// # Returns
318///
319/// - `Ok(Some(PathBuf))` with the path to agpm.toml if migration succeeded
320/// - `Ok(None)` if no legacy files were found OR user declined OR non-interactive mode
321/// - `Err` if migration failed
322///
323/// # Examples
324///
325/// ```no_run
326/// # use anyhow::Result;
327/// # async fn example() -> Result<()> {
328/// use agpm_cli::cli::common::handle_legacy_ccpm_migration;
329///
330/// match handle_legacy_ccpm_migration().await? {
331///     Some(path) => println!("Migrated to: {}", path.display()),
332///     None => println!("No migration performed"),
333/// }
334/// # Ok(())
335/// # }
336/// ```
337///
338/// # Errors
339///
340/// Returns an error if:
341/// - Unable to access current directory
342/// - Unable to perform migration operations
343pub async fn handle_legacy_ccpm_migration() -> Result<Option<PathBuf>> {
344    let current_dir = std::env::current_dir()?;
345    let legacy_dir = find_legacy_ccpm_directory(&current_dir);
346
347    let Some(dir) = legacy_dir else {
348        return Ok(None);
349    };
350
351    // Check if we're in an interactive terminal
352    if !std::io::stdin().is_terminal() {
353        // Non-interactive mode: Don't prompt, just inform and exit
354        eprintln!("{}", "Legacy CCPM files detected (non-interactive mode).".yellow());
355        eprintln!(
356            "Run {} to migrate manually.",
357            format!("agpm migrate --path {}", dir.display()).cyan()
358        );
359        return Ok(None);
360    }
361
362    // Found legacy files - prompt for migration
363    let ccpm_toml = dir.join("ccpm.toml");
364    let ccpm_lock = dir.join("ccpm.lock");
365
366    let mut files = Vec::new();
367    if ccpm_toml.exists() {
368        files.push("ccpm.toml");
369    }
370    if ccpm_lock.exists() {
371        files.push("ccpm.lock");
372    }
373
374    let files_str = files.join(" and ");
375
376    println!("{}", "Legacy CCPM files detected!".yellow().bold());
377    println!("{} {} found in {}", "→".cyan(), files_str, dir.display());
378    println!();
379
380    // Prompt user for migration
381    print!("{} ", "Would you like to migrate to AGPM now? [Y/n]:".green());
382    io::stdout().flush()?;
383
384    // Use async I/O for proper integration with Tokio runtime
385    let mut reader = BufReader::new(tokio::io::stdin());
386    let mut response = String::new();
387    reader.read_line(&mut response).await?;
388    let response = response.trim().to_lowercase();
389
390    if response.is_empty() || response == "y" || response == "yes" {
391        println!();
392        println!("{}", "🚀 Starting migration...".cyan());
393
394        // Perform the migration with automatic installation
395        let migrate_cmd = super::migrate::MigrateCommand::new(Some(dir.clone()), false, false);
396
397        migrate_cmd.execute().await?;
398
399        // Return the path to the newly created agpm.toml
400        Ok(Some(dir.join("agpm.toml")))
401    } else {
402        println!();
403        println!("{}", "Migration cancelled.".yellow());
404        println!(
405            "Run {} to migrate manually.",
406            format!("agpm migrate --path {}", dir.display()).cyan()
407        );
408        Ok(None)
409    }
410}
411
412/// Check for legacy CCPM files and return a migration message if found.
413///
414/// This function searches for ccpm.toml and ccpm.lock files in the current
415/// directory and parent directories, similar to how `find_manifest` works.
416/// If legacy files are found, it returns a helpful error message suggesting
417/// to run the migration command.
418///
419/// # Returns
420///
421/// - `Some(String)` with migration instructions if legacy files are found
422/// - `None` if no legacy files are detected
423#[must_use]
424pub fn check_for_legacy_ccpm_files() -> Option<String> {
425    check_for_legacy_ccpm_files_from(std::env::current_dir().ok()?)
426}
427
428/// Find the directory containing legacy CCPM files.
429///
430/// Searches for ccpm.toml or ccpm.lock starting from the given directory
431/// and walking up the directory tree.
432///
433/// # Returns
434///
435/// - `Some(PathBuf)` with the directory containing legacy files
436/// - `None` if no legacy files are found
437fn find_legacy_ccpm_directory(start_dir: &Path) -> Option<PathBuf> {
438    let mut dir = start_dir;
439
440    loop {
441        let ccpm_toml = dir.join("ccpm.toml");
442        let ccpm_lock = dir.join("ccpm.lock");
443
444        if ccpm_toml.exists() || ccpm_lock.exists() {
445            return Some(dir.to_path_buf());
446        }
447
448        dir = dir.parent()?;
449    }
450}
451
452/// Check for legacy CCPM files starting from a specific directory.
453///
454/// This is the internal implementation that allows for testing without
455/// changing the current working directory.
456fn check_for_legacy_ccpm_files_from(start_dir: PathBuf) -> Option<String> {
457    let current = start_dir;
458    let mut dir = current.as_path();
459
460    loop {
461        let ccpm_toml = dir.join("ccpm.toml");
462        let ccpm_lock = dir.join("ccpm.lock");
463
464        if ccpm_toml.exists() || ccpm_lock.exists() {
465            let mut files = Vec::new();
466            if ccpm_toml.exists() {
467                files.push("ccpm.toml");
468            }
469            if ccpm_lock.exists() {
470                files.push("ccpm.lock");
471            }
472
473            let files_str = files.join(" and ");
474            let location = if dir == current {
475                "current directory".to_string()
476            } else {
477                format!("parent directory: {}", dir.display())
478            };
479
480            return Some(format!(
481                "{}\n\n{} {} found in {}.\n{}\n  {}\n\n{}",
482                "Legacy CCPM files detected!".yellow().bold(),
483                "→".cyan(),
484                files_str,
485                location,
486                "Run the migration command to upgrade:".yellow(),
487                format!("agpm migrate --path {}", dir.display()).cyan().bold(),
488                "Or run 'agpm init' to create a new AGPM project.".dimmed()
489            ));
490        }
491
492        dir = dir.parent()?;
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499    use tempfile::TempDir;
500
501    #[test]
502    fn test_command_context_from_manifest_path() {
503        let temp_dir = TempDir::new().unwrap();
504        let manifest_path = temp_dir.path().join("agpm.toml");
505
506        // Create a test manifest
507        std::fs::write(
508            &manifest_path,
509            r#"
510[sources]
511test = "https://github.com/test/repo.git"
512
513[agents]
514"#,
515        )
516        .unwrap();
517
518        let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
519
520        assert_eq!(context.manifest_path, manifest_path);
521        assert_eq!(context.project_dir, temp_dir.path());
522        assert_eq!(context.lockfile_path, temp_dir.path().join("agpm.lock"));
523        assert!(context.manifest.sources.contains_key("test"));
524    }
525
526    #[test]
527    fn test_command_context_missing_manifest() {
528        let result = CommandContext::from_manifest_path("/nonexistent/agpm.toml");
529        assert!(result.is_err());
530        assert!(result.unwrap_err().to_string().contains("not found"));
531    }
532
533    #[test]
534    fn test_command_context_invalid_manifest() {
535        let temp_dir = TempDir::new().unwrap();
536        let manifest_path = temp_dir.path().join("agpm.toml");
537
538        // Create an invalid manifest
539        std::fs::write(&manifest_path, "invalid toml {{").unwrap();
540
541        let result = CommandContext::from_manifest_path(&manifest_path);
542        assert!(result.is_err());
543        assert!(result.unwrap_err().to_string().contains("Failed to parse manifest"));
544    }
545
546    #[test]
547    fn test_load_lockfile_exists() {
548        let temp_dir = TempDir::new().unwrap();
549        let manifest_path = temp_dir.path().join("agpm.toml");
550        let lockfile_path = temp_dir.path().join("agpm.lock");
551
552        // Create test files
553        std::fs::write(&manifest_path, "[sources]\n").unwrap();
554        std::fs::write(
555            &lockfile_path,
556            r#"
557version = 1
558
559[[sources]]
560name = "test"
561url = "https://github.com/test/repo.git"
562commit = "abc123"
563fetched_at = "2024-01-01T00:00:00Z"
564"#,
565        )
566        .unwrap();
567
568        let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
569        let lockfile = context.load_lockfile().unwrap();
570
571        assert!(lockfile.is_some());
572        let lockfile = lockfile.unwrap();
573        assert_eq!(lockfile.sources.len(), 1);
574        assert_eq!(lockfile.sources[0].name, "test");
575    }
576
577    #[test]
578    fn test_load_lockfile_not_exists() {
579        let temp_dir = TempDir::new().unwrap();
580        let manifest_path = temp_dir.path().join("agpm.toml");
581
582        std::fs::write(&manifest_path, "[sources]\n").unwrap();
583
584        let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
585        let lockfile = context.load_lockfile().unwrap();
586
587        assert!(lockfile.is_none());
588    }
589
590    #[test]
591    fn test_save_lockfile() {
592        let temp_dir = TempDir::new().unwrap();
593        let manifest_path = temp_dir.path().join("agpm.toml");
594
595        std::fs::write(&manifest_path, "[sources]\n").unwrap();
596
597        let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
598
599        let lockfile = crate::lockfile::LockFile {
600            version: 1,
601            sources: vec![],
602            agents: vec![],
603            snippets: vec![],
604            commands: vec![],
605            scripts: vec![],
606            hooks: vec![],
607            mcp_servers: vec![],
608        };
609
610        context.save_lockfile(&lockfile).unwrap();
611
612        assert!(context.lockfile_path.exists());
613        let saved_content = std::fs::read_to_string(&context.lockfile_path).unwrap();
614        assert!(saved_content.contains("version = 1"));
615    }
616
617    #[test]
618    fn test_check_for_legacy_ccpm_no_files() {
619        let temp_dir = TempDir::new().unwrap();
620        let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
621        assert!(result.is_none());
622    }
623
624    #[test]
625    fn test_check_for_legacy_ccpm_toml_only() {
626        let temp_dir = TempDir::new().unwrap();
627        std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
628
629        let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
630        assert!(result.is_some());
631        let msg = result.unwrap();
632        assert!(msg.contains("Legacy CCPM files detected"));
633        assert!(msg.contains("ccpm.toml"));
634        assert!(msg.contains("agpm migrate"));
635    }
636
637    #[test]
638    fn test_check_for_legacy_ccpm_lock_only() {
639        let temp_dir = TempDir::new().unwrap();
640        std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
641
642        let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
643        assert!(result.is_some());
644        let msg = result.unwrap();
645        assert!(msg.contains("ccpm.lock"));
646    }
647
648    #[test]
649    fn test_check_for_legacy_ccpm_both_files() {
650        let temp_dir = TempDir::new().unwrap();
651        std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
652        std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
653
654        let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
655        assert!(result.is_some());
656        let msg = result.unwrap();
657        assert!(msg.contains("ccpm.toml and ccpm.lock"));
658    }
659
660    #[test]
661    fn test_find_legacy_ccpm_directory_no_files() {
662        let temp_dir = TempDir::new().unwrap();
663        let result = find_legacy_ccpm_directory(temp_dir.path());
664        assert!(result.is_none());
665    }
666
667    #[test]
668    fn test_find_legacy_ccpm_directory_in_current_dir() {
669        let temp_dir = TempDir::new().unwrap();
670        std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
671
672        let result = find_legacy_ccpm_directory(temp_dir.path());
673        assert!(result.is_some());
674        assert_eq!(result.unwrap(), temp_dir.path());
675    }
676
677    #[test]
678    fn test_find_legacy_ccpm_directory_in_parent() {
679        let temp_dir = TempDir::new().unwrap();
680        let parent = temp_dir.path();
681        let child = parent.join("subdir");
682        std::fs::create_dir(&child).unwrap();
683
684        // Create legacy file in parent
685        std::fs::write(parent.join("ccpm.toml"), "[sources]\n").unwrap();
686
687        // Search from child directory
688        let result = find_legacy_ccpm_directory(&child);
689        assert!(result.is_some());
690        assert_eq!(result.unwrap(), parent);
691    }
692
693    #[test]
694    fn test_find_legacy_ccpm_directory_finds_lock_file() {
695        let temp_dir = TempDir::new().unwrap();
696        std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
697
698        let result = find_legacy_ccpm_directory(temp_dir.path());
699        assert!(result.is_some());
700        assert_eq!(result.unwrap(), temp_dir.path());
701    }
702
703    #[tokio::test]
704    async fn test_handle_legacy_ccpm_migration_no_files() {
705        let temp_dir = TempDir::new().unwrap();
706        let original_dir = std::env::current_dir().unwrap();
707
708        // Change to temp directory with no legacy files
709        std::env::set_current_dir(temp_dir.path()).unwrap();
710
711        let result = handle_legacy_ccpm_migration().await;
712
713        // Restore original directory
714        std::env::set_current_dir(original_dir).unwrap();
715
716        assert!(result.is_ok());
717        assert!(result.unwrap().is_none());
718    }
719
720    #[cfg(test)]
721    mod lockfile_regeneration_tests {
722        use super::*;
723        use crate::manifest::Manifest;
724        use tempfile::TempDir;
725
726        #[test]
727        fn test_load_lockfile_with_regeneration_valid_lockfile() {
728            let temp_dir = TempDir::new().unwrap();
729            let project_dir = temp_dir.path();
730            let manifest_path = project_dir.join("agpm.toml");
731            let lockfile_path = project_dir.join("agpm.lock");
732
733            // Create a minimal manifest
734            let manifest_content = r#"[sources]
735example = "https://github.com/example/repo.git"
736
737[agents]
738test = { source = "example", path = "test.md", version = "v1.0.0" }
739"#;
740            std::fs::write(&manifest_path, manifest_content).unwrap();
741
742            // Create a valid lockfile
743            let lockfile_content = r#"version = 1
744
745[[sources]]
746name = "example"
747url = "https://github.com/example/repo.git"
748commit = "abc123def456789012345678901234567890abcd"
749fetched_at = "2024-01-01T00:00:00Z"
750
751[[agents]]
752name = "test"
753source = "example"
754path = "test.md"
755version = "v1.0.0"
756resolved_commit = "abc123def456789012345678901234567890abcd"
757checksum = "sha256:examplechecksum"
758installed_at = ".claude/agents/test.md"
759"#;
760            std::fs::write(&lockfile_path, lockfile_content).unwrap();
761
762            // Test loading valid lockfile
763            let manifest = Manifest::load(&manifest_path).unwrap();
764            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
765
766            let result = ctx.load_lockfile_with_regeneration(true, "test").unwrap();
767            assert!(result.is_some());
768        }
769
770        #[test]
771        fn test_load_lockfile_with_regeneration_invalid_toml() {
772            let temp_dir = TempDir::new().unwrap();
773            let project_dir = temp_dir.path();
774            let manifest_path = project_dir.join("agpm.toml");
775            let lockfile_path = project_dir.join("agpm.lock");
776
777            // Create a minimal manifest
778            let manifest_content = r#"[sources]
779example = "https://github.com/example/repo.git"
780"#;
781            std::fs::write(&manifest_path, manifest_content).unwrap();
782
783            // Create an invalid TOML lockfile
784            std::fs::write(&lockfile_path, "invalid toml [[[").unwrap();
785
786            // Test loading invalid lockfile in non-interactive mode
787            let manifest = Manifest::load(&manifest_path).unwrap();
788            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
789
790            // This should return an error in non-interactive mode
791            let result = ctx.load_lockfile_with_regeneration(true, "test");
792            assert!(result.is_err());
793
794            let error_msg = result.unwrap_err().to_string();
795            assert!(error_msg.contains("Invalid or corrupted lockfile detected"));
796            assert!(error_msg.contains("beta software"));
797            assert!(error_msg.contains("cp agpm.lock"));
798        }
799
800        #[test]
801        fn test_load_lockfile_with_regeneration_missing_lockfile() {
802            let temp_dir = TempDir::new().unwrap();
803            let project_dir = temp_dir.path();
804            let manifest_path = project_dir.join("agpm.toml");
805
806            // Create a minimal manifest
807            let manifest_content = r#"[sources]
808example = "https://github.com/example/repo.git"
809"#;
810            std::fs::write(&manifest_path, manifest_content).unwrap();
811
812            // Test loading non-existent lockfile
813            let manifest = Manifest::load(&manifest_path).unwrap();
814            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
815
816            let result = ctx.load_lockfile_with_regeneration(true, "test").unwrap();
817            assert!(result.is_none()); // Should return None for missing lockfile
818        }
819
820        #[test]
821        fn test_load_lockfile_with_regeneration_version_incompatibility() {
822            let temp_dir = TempDir::new().unwrap();
823            let project_dir = temp_dir.path();
824            let manifest_path = project_dir.join("agpm.toml");
825            let lockfile_path = project_dir.join("agpm.lock");
826
827            // Create a minimal manifest
828            let manifest_content = r#"[sources]
829example = "https://github.com/example/repo.git"
830"#;
831            std::fs::write(&manifest_path, manifest_content).unwrap();
832
833            // Create a lockfile with future version
834            let lockfile_content = r#"version = 999
835
836[[sources]]
837name = "example"
838url = "https://github.com/example/repo.git"
839commit = "abc123def456789012345678901234567890abcd"
840fetched_at = "2024-01-01T00:00:00Z"
841"#;
842            std::fs::write(&lockfile_path, lockfile_content).unwrap();
843
844            // Test loading future version lockfile
845            let manifest = Manifest::load(&manifest_path).unwrap();
846            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
847
848            let result = ctx.load_lockfile_with_regeneration(true, "test");
849            assert!(result.is_err());
850
851            let error_msg = result.unwrap_err().to_string();
852            assert!(error_msg.contains("version") || error_msg.contains("newer"));
853        }
854
855        #[test]
856        fn test_load_lockfile_with_regeneration_cannot_regenerate() {
857            let temp_dir = TempDir::new().unwrap();
858            let project_dir = temp_dir.path();
859            let manifest_path = project_dir.join("agpm.toml");
860            let lockfile_path = project_dir.join("agpm.lock");
861
862            // Create a minimal manifest
863            let manifest_content = r#"[sources]
864example = "https://github.com/example/repo.git"
865"#;
866            std::fs::write(&manifest_path, manifest_content).unwrap();
867
868            // Create an invalid TOML lockfile
869            std::fs::write(&lockfile_path, "invalid toml [[[").unwrap();
870
871            // Test with can_regenerate = false
872            let manifest = Manifest::load(&manifest_path).unwrap();
873            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
874
875            let result = ctx.load_lockfile_with_regeneration(false, "test");
876            assert!(result.is_err());
877
878            // Should return the original error, not the enhanced one
879            let error_msg = result.unwrap_err().to_string();
880            assert!(!error_msg.contains("Invalid or corrupted lockfile detected"));
881            assert!(
882                error_msg.contains("Failed to load lockfile")
883                    || error_msg.contains("Invalid TOML syntax")
884            );
885        }
886
887        #[test]
888        fn test_backup_and_regenerate_lockfile() {
889            let temp_dir = TempDir::new().unwrap();
890            let project_dir = temp_dir.path();
891            let manifest_path = project_dir.join("agpm.toml");
892            let lockfile_path = project_dir.join("agpm.lock");
893
894            // Create a minimal manifest
895            let manifest_content = r#"[sources]
896example = "https://github.com/example/repo.git"
897"#;
898            std::fs::write(&manifest_path, manifest_content).unwrap();
899
900            // Create an invalid lockfile
901            std::fs::write(&lockfile_path, "invalid content").unwrap();
902
903            // Test backup and regeneration
904            let manifest = Manifest::load(&manifest_path).unwrap();
905            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
906
907            let backup_path = lockfile_path.with_extension("lock.invalid");
908
909            // This should backup the file and remove the original
910            ctx.backup_and_regenerate_lockfile(&backup_path, "test").unwrap();
911
912            // Check that backup was created
913            assert!(backup_path.exists());
914            assert_eq!(std::fs::read_to_string(&backup_path).unwrap(), "invalid content");
915
916            // Check that original was removed
917            assert!(!lockfile_path.exists());
918        }
919
920        #[test]
921        fn test_create_non_interactive_error() {
922            let temp_dir = TempDir::new().unwrap();
923            let project_dir = temp_dir.path();
924            let manifest_path = project_dir.join("agpm.toml");
925
926            // Create a minimal manifest
927            let manifest_content = r#"[sources]
928example = "https://github.com/example/repo.git"
929"#;
930            std::fs::write(&manifest_path, manifest_content).unwrap();
931
932            // Test non-interactive error creation
933            let manifest = Manifest::load(&manifest_path).unwrap();
934            let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
935
936            let error = ctx.create_non_interactive_error("Invalid TOML syntax", "test");
937            let error_msg = error.to_string();
938
939            assert!(error_msg.contains("Invalid TOML syntax"));
940            assert!(error_msg.contains("beta software"));
941            assert!(error_msg.contains("cp agpm.lock"));
942            assert!(error_msg.contains("rm agpm.lock"));
943            assert!(error_msg.contains("agpm install"));
944        }
945    }
946
947    // Note: Testing interactive behavior (user input) requires mocking stdin,
948    // which is complex with tokio::io::stdin(). The non-interactive TTY check
949    // will be automatically triggered in CI environments, providing implicit
950    // integration testing.
951}