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 path
60    pub fn from_manifest_path(manifest_path: impl AsRef<Path>) -> Result<Self> {
61        let manifest_path = manifest_path.as_ref();
62
63        if !manifest_path.exists() {
64            return Err(anyhow::anyhow!("Manifest file {} not found", manifest_path.display()));
65        }
66
67        let project_dir = manifest_path
68            .parent()
69            .ok_or_else(|| anyhow::anyhow!("Invalid manifest path"))?
70            .to_path_buf();
71
72        let manifest = Manifest::load(manifest_path).with_context(|| {
73            format!("Failed to parse manifest file: {}", manifest_path.display())
74        })?;
75
76        let lockfile_path = project_dir.join("agpm.lock");
77
78        Ok(Self {
79            manifest,
80            manifest_path: manifest_path.to_path_buf(),
81            project_dir,
82            lockfile_path,
83        })
84    }
85
86    /// Load an existing lockfile if it exists
87    pub fn load_lockfile(&self) -> Result<Option<crate::lockfile::LockFile>> {
88        if self.lockfile_path.exists() {
89            let lockfile =
90                crate::lockfile::LockFile::load(&self.lockfile_path).with_context(|| {
91                    format!("Failed to load lockfile: {}", self.lockfile_path.display())
92                })?;
93            Ok(Some(lockfile))
94        } else {
95            Ok(None)
96        }
97    }
98
99    /// Save a lockfile to the project directory
100    pub fn save_lockfile(&self, lockfile: &crate::lockfile::LockFile) -> Result<()> {
101        lockfile
102            .save(&self.lockfile_path)
103            .with_context(|| format!("Failed to save lockfile: {}", self.lockfile_path.display()))
104    }
105}
106
107/// Handle legacy CCPM files by offering interactive migration.
108///
109/// This function searches for ccpm.toml and ccpm.lock files in the current
110/// directory and parent directories. If found, it prompts the user to migrate
111/// and performs the migration if they accept.
112///
113/// # Behavior
114///
115/// - **Interactive mode**: Prompts user with Y/n confirmation (stdin is a TTY)
116/// - **Non-interactive mode**: Returns `Ok(None)` if stdin is not a TTY (e.g., CI/CD)
117/// - **Search scope**: Traverses from current directory to filesystem root
118///
119/// # Returns
120///
121/// - `Ok(Some(PathBuf))` with the path to agpm.toml if migration succeeded
122/// - `Ok(None)` if no legacy files were found OR user declined OR non-interactive mode
123/// - `Err` if migration failed
124///
125/// # Examples
126///
127/// ```no_run
128/// # use anyhow::Result;
129/// # async fn example() -> Result<()> {
130/// use agpm_cli::cli::common::handle_legacy_ccpm_migration;
131///
132/// match handle_legacy_ccpm_migration().await? {
133///     Some(path) => println!("Migrated to: {}", path.display()),
134///     None => println!("No migration performed"),
135/// }
136/// # Ok(())
137/// # }
138/// ```
139pub async fn handle_legacy_ccpm_migration() -> Result<Option<PathBuf>> {
140    let current_dir = std::env::current_dir()?;
141    let legacy_dir = find_legacy_ccpm_directory(&current_dir);
142
143    let Some(dir) = legacy_dir else {
144        return Ok(None);
145    };
146
147    // Check if we're in an interactive terminal
148    if !std::io::stdin().is_terminal() {
149        // Non-interactive mode: Don't prompt, just inform and exit
150        eprintln!("{}", "Legacy CCPM files detected (non-interactive mode).".yellow());
151        eprintln!(
152            "Run {} to migrate manually.",
153            format!("agpm migrate --path {}", dir.display()).cyan()
154        );
155        return Ok(None);
156    }
157
158    // Found legacy files - prompt for migration
159    let ccpm_toml = dir.join("ccpm.toml");
160    let ccpm_lock = dir.join("ccpm.lock");
161
162    let mut files = Vec::new();
163    if ccpm_toml.exists() {
164        files.push("ccpm.toml");
165    }
166    if ccpm_lock.exists() {
167        files.push("ccpm.lock");
168    }
169
170    let files_str = files.join(" and ");
171
172    println!("{}", "Legacy CCPM files detected!".yellow().bold());
173    println!("{} {} found in {}", "→".cyan(), files_str, dir.display());
174    println!();
175
176    // Prompt user for migration
177    print!("{} ", "Would you like to migrate to AGPM now? [Y/n]:".green());
178    io::stdout().flush()?;
179
180    // Use async I/O for proper integration with Tokio runtime
181    let mut reader = BufReader::new(tokio::io::stdin());
182    let mut response = String::new();
183    reader.read_line(&mut response).await?;
184    let response = response.trim().to_lowercase();
185
186    if response.is_empty() || response == "y" || response == "yes" {
187        println!();
188        println!("{}", "🚀 Starting migration...".cyan());
189
190        // Perform the migration with automatic installation
191        let migrate_cmd = super::migrate::MigrateCommand::new(Some(dir.clone()), false, false);
192
193        migrate_cmd.execute().await?;
194
195        // Return the path to the newly created agpm.toml
196        Ok(Some(dir.join("agpm.toml")))
197    } else {
198        println!();
199        println!("{}", "Migration cancelled.".yellow());
200        println!(
201            "Run {} to migrate manually.",
202            format!("agpm migrate --path {}", dir.display()).cyan()
203        );
204        Ok(None)
205    }
206}
207
208/// Check for legacy CCPM files and return a migration message if found.
209///
210/// This function searches for ccpm.toml and ccpm.lock files in the current
211/// directory and parent directories, similar to how `find_manifest` works.
212/// If legacy files are found, it returns a helpful error message suggesting
213/// to run the migration command.
214///
215/// # Returns
216///
217/// - `Some(String)` with migration instructions if legacy files are found
218/// - `None` if no legacy files are detected
219pub fn check_for_legacy_ccpm_files() -> Option<String> {
220    check_for_legacy_ccpm_files_from(std::env::current_dir().ok()?)
221}
222
223/// Find the directory containing legacy CCPM files.
224///
225/// Searches for ccpm.toml or ccpm.lock starting from the given directory
226/// and walking up the directory tree.
227///
228/// # Returns
229///
230/// - `Some(PathBuf)` with the directory containing legacy files
231/// - `None` if no legacy files are found
232fn find_legacy_ccpm_directory(start_dir: &Path) -> Option<PathBuf> {
233    let mut dir = start_dir;
234
235    loop {
236        let ccpm_toml = dir.join("ccpm.toml");
237        let ccpm_lock = dir.join("ccpm.lock");
238
239        if ccpm_toml.exists() || ccpm_lock.exists() {
240            return Some(dir.to_path_buf());
241        }
242
243        dir = dir.parent()?;
244    }
245}
246
247/// Check for legacy CCPM files starting from a specific directory.
248///
249/// This is the internal implementation that allows for testing without
250/// changing the current working directory.
251fn check_for_legacy_ccpm_files_from(start_dir: PathBuf) -> Option<String> {
252    let current = start_dir;
253    let mut dir = current.as_path();
254
255    loop {
256        let ccpm_toml = dir.join("ccpm.toml");
257        let ccpm_lock = dir.join("ccpm.lock");
258
259        if ccpm_toml.exists() || ccpm_lock.exists() {
260            let mut files = Vec::new();
261            if ccpm_toml.exists() {
262                files.push("ccpm.toml");
263            }
264            if ccpm_lock.exists() {
265                files.push("ccpm.lock");
266            }
267
268            let files_str = files.join(" and ");
269            let location = if dir == current {
270                "current directory".to_string()
271            } else {
272                format!("parent directory: {}", dir.display())
273            };
274
275            return Some(format!(
276                "{}\n\n{} {} found in {}.\n{}\n  {}\n\n{}",
277                "Legacy CCPM files detected!".yellow().bold(),
278                "→".cyan(),
279                files_str,
280                location,
281                "Run the migration command to upgrade:".yellow(),
282                format!("agpm migrate --path {}", dir.display()).cyan().bold(),
283                "Or run 'agpm init' to create a new AGPM project.".dimmed()
284            ));
285        }
286
287        dir = dir.parent()?;
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use tempfile::TempDir;
295
296    #[test]
297    fn test_command_context_from_manifest_path() {
298        let temp_dir = TempDir::new().unwrap();
299        let manifest_path = temp_dir.path().join("agpm.toml");
300
301        // Create a test manifest
302        std::fs::write(
303            &manifest_path,
304            r#"
305[sources]
306test = "https://github.com/test/repo.git"
307
308[agents]
309"#,
310        )
311        .unwrap();
312
313        let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
314
315        assert_eq!(context.manifest_path, manifest_path);
316        assert_eq!(context.project_dir, temp_dir.path());
317        assert_eq!(context.lockfile_path, temp_dir.path().join("agpm.lock"));
318        assert!(context.manifest.sources.contains_key("test"));
319    }
320
321    #[test]
322    fn test_command_context_missing_manifest() {
323        let result = CommandContext::from_manifest_path("/nonexistent/agpm.toml");
324        assert!(result.is_err());
325        assert!(result.unwrap_err().to_string().contains("not found"));
326    }
327
328    #[test]
329    fn test_command_context_invalid_manifest() {
330        let temp_dir = TempDir::new().unwrap();
331        let manifest_path = temp_dir.path().join("agpm.toml");
332
333        // Create an invalid manifest
334        std::fs::write(&manifest_path, "invalid toml {{").unwrap();
335
336        let result = CommandContext::from_manifest_path(&manifest_path);
337        assert!(result.is_err());
338        assert!(result.unwrap_err().to_string().contains("Failed to parse manifest"));
339    }
340
341    #[test]
342    fn test_load_lockfile_exists() {
343        let temp_dir = TempDir::new().unwrap();
344        let manifest_path = temp_dir.path().join("agpm.toml");
345        let lockfile_path = temp_dir.path().join("agpm.lock");
346
347        // Create test files
348        std::fs::write(&manifest_path, "[sources]\n").unwrap();
349        std::fs::write(
350            &lockfile_path,
351            r#"
352version = 1
353
354[[sources]]
355name = "test"
356url = "https://github.com/test/repo.git"
357commit = "abc123"
358fetched_at = "2024-01-01T00:00:00Z"
359"#,
360        )
361        .unwrap();
362
363        let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
364        let lockfile = context.load_lockfile().unwrap();
365
366        assert!(lockfile.is_some());
367        let lockfile = lockfile.unwrap();
368        assert_eq!(lockfile.sources.len(), 1);
369        assert_eq!(lockfile.sources[0].name, "test");
370    }
371
372    #[test]
373    fn test_load_lockfile_not_exists() {
374        let temp_dir = TempDir::new().unwrap();
375        let manifest_path = temp_dir.path().join("agpm.toml");
376
377        std::fs::write(&manifest_path, "[sources]\n").unwrap();
378
379        let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
380        let lockfile = context.load_lockfile().unwrap();
381
382        assert!(lockfile.is_none());
383    }
384
385    #[test]
386    fn test_save_lockfile() {
387        let temp_dir = TempDir::new().unwrap();
388        let manifest_path = temp_dir.path().join("agpm.toml");
389
390        std::fs::write(&manifest_path, "[sources]\n").unwrap();
391
392        let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
393
394        let lockfile = crate::lockfile::LockFile {
395            version: 1,
396            sources: vec![],
397            agents: vec![],
398            snippets: vec![],
399            commands: vec![],
400            scripts: vec![],
401            hooks: vec![],
402            mcp_servers: vec![],
403        };
404
405        context.save_lockfile(&lockfile).unwrap();
406
407        assert!(context.lockfile_path.exists());
408        let saved_content = std::fs::read_to_string(&context.lockfile_path).unwrap();
409        assert!(saved_content.contains("version = 1"));
410    }
411
412    #[test]
413    fn test_check_for_legacy_ccpm_no_files() {
414        let temp_dir = TempDir::new().unwrap();
415        let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
416        assert!(result.is_none());
417    }
418
419    #[test]
420    fn test_check_for_legacy_ccpm_toml_only() {
421        let temp_dir = TempDir::new().unwrap();
422        std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
423
424        let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
425        assert!(result.is_some());
426        let msg = result.unwrap();
427        assert!(msg.contains("Legacy CCPM files detected"));
428        assert!(msg.contains("ccpm.toml"));
429        assert!(msg.contains("agpm migrate"));
430    }
431
432    #[test]
433    fn test_check_for_legacy_ccpm_lock_only() {
434        let temp_dir = TempDir::new().unwrap();
435        std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
436
437        let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
438        assert!(result.is_some());
439        let msg = result.unwrap();
440        assert!(msg.contains("ccpm.lock"));
441    }
442
443    #[test]
444    fn test_check_for_legacy_ccpm_both_files() {
445        let temp_dir = TempDir::new().unwrap();
446        std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
447        std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
448
449        let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
450        assert!(result.is_some());
451        let msg = result.unwrap();
452        assert!(msg.contains("ccpm.toml and ccpm.lock"));
453    }
454
455    #[test]
456    fn test_find_legacy_ccpm_directory_no_files() {
457        let temp_dir = TempDir::new().unwrap();
458        let result = find_legacy_ccpm_directory(temp_dir.path());
459        assert!(result.is_none());
460    }
461
462    #[test]
463    fn test_find_legacy_ccpm_directory_in_current_dir() {
464        let temp_dir = TempDir::new().unwrap();
465        std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
466
467        let result = find_legacy_ccpm_directory(temp_dir.path());
468        assert!(result.is_some());
469        assert_eq!(result.unwrap(), temp_dir.path());
470    }
471
472    #[test]
473    fn test_find_legacy_ccpm_directory_in_parent() {
474        let temp_dir = TempDir::new().unwrap();
475        let parent = temp_dir.path();
476        let child = parent.join("subdir");
477        std::fs::create_dir(&child).unwrap();
478
479        // Create legacy file in parent
480        std::fs::write(parent.join("ccpm.toml"), "[sources]\n").unwrap();
481
482        // Search from child directory
483        let result = find_legacy_ccpm_directory(&child);
484        assert!(result.is_some());
485        assert_eq!(result.unwrap(), parent);
486    }
487
488    #[test]
489    fn test_find_legacy_ccpm_directory_finds_lock_file() {
490        let temp_dir = TempDir::new().unwrap();
491        std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
492
493        let result = find_legacy_ccpm_directory(temp_dir.path());
494        assert!(result.is_some());
495        assert_eq!(result.unwrap(), temp_dir.path());
496    }
497
498    #[tokio::test]
499    async fn test_handle_legacy_ccpm_migration_no_files() {
500        let temp_dir = TempDir::new().unwrap();
501        let original_dir = std::env::current_dir().unwrap();
502
503        // Change to temp directory with no legacy files
504        std::env::set_current_dir(temp_dir.path()).unwrap();
505
506        let result = handle_legacy_ccpm_migration().await;
507
508        // Restore original directory
509        std::env::set_current_dir(original_dir).unwrap();
510
511        assert!(result.is_ok());
512        assert!(result.unwrap().is_none());
513    }
514
515    // Note: Testing interactive behavior (user input) requires mocking stdin,
516    // which is complex with tokio::io::stdin(). The non-interactive TTY check
517    // will be automatically triggered in CI environments, providing implicit
518    // integration testing.
519}