use anyhow::{Context, Result};
use colored::Colorize;
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use tokio::io::{AsyncBufReadExt, BufReader};
use crate::manifest::{Manifest, find_manifest};
pub trait CommandExecutor: Sized {
fn execute(self) -> impl std::future::Future<Output = Result<()>> + Send
where
Self: Send,
{
async move {
let manifest_path = if let Ok(path) = find_manifest() {
path
} else {
match handle_legacy_ccpm_migration().await {
Ok(Some(path)) => path,
Ok(None) => {
return Err(anyhow::anyhow!(
"No agpm.toml found in current directory or any parent directory. \
Run 'agpm init' to create a new project."
));
}
Err(e) => return Err(e),
}
};
self.execute_from_path(manifest_path).await
}
}
fn execute_from_path(
self,
manifest_path: PathBuf,
) -> impl std::future::Future<Output = Result<()>> + Send;
}
#[derive(Debug)]
pub struct CommandContext {
pub manifest: Manifest,
pub manifest_path: PathBuf,
pub project_dir: PathBuf,
pub lockfile_path: PathBuf,
}
impl CommandContext {
pub fn new(manifest: Manifest, project_dir: PathBuf) -> Result<Self> {
let lockfile_path = project_dir.join("agpm.lock");
Ok(Self {
manifest,
manifest_path: project_dir.join("agpm.toml"),
project_dir,
lockfile_path,
})
}
pub fn from_manifest_path(manifest_path: impl AsRef<Path>) -> Result<Self> {
let manifest_path = manifest_path.as_ref();
if !manifest_path.exists() {
return Err(anyhow::anyhow!("Manifest file {} not found", manifest_path.display()));
}
let project_dir = manifest_path
.parent()
.ok_or_else(|| anyhow::anyhow!("Invalid manifest path"))?
.to_path_buf();
let manifest = Manifest::load(manifest_path).with_context(|| {
format!("Failed to parse manifest file: {}", manifest_path.display())
})?;
let lockfile_path = project_dir.join("agpm.lock");
Ok(Self {
manifest,
manifest_path: manifest_path.to_path_buf(),
project_dir,
lockfile_path,
})
}
pub fn load_lockfile(&self) -> Result<Option<crate::lockfile::LockFile>> {
if self.lockfile_path.exists() {
let lockfile =
crate::lockfile::LockFile::load(&self.lockfile_path).with_context(|| {
format!("Failed to load lockfile: {}", self.lockfile_path.display())
})?;
Ok(Some(lockfile))
} else {
Ok(None)
}
}
pub fn load_lockfile_with_regeneration(
&self,
can_regenerate: bool,
operation_name: &str,
) -> Result<Option<crate::lockfile::LockFile>> {
if !self.lockfile_path.exists() {
return Ok(None);
}
match crate::lockfile::LockFile::load(&self.lockfile_path) {
Ok(lockfile) => Ok(Some(lockfile)),
Err(e) => {
let error_msg = e.to_string();
let can_auto_recover = can_regenerate
&& (error_msg.contains("Invalid TOML syntax")
|| error_msg.contains("Lockfile version")
|| error_msg.contains("missing field")
|| error_msg.contains("invalid type")
|| error_msg.contains("expected"));
if !can_auto_recover {
return Err(e);
}
let backup_path = self.lockfile_path.with_extension("lock.invalid");
let regenerate_message = format!(
"The lockfile appears to be invalid or corrupted.\n\n\
Error: {}\n\n\
Note: The lockfile format is not yet stable as this is beta software.\n\n\
The invalid lockfile will be backed up to: {}",
error_msg,
backup_path.display()
);
if io::stdin().is_terminal() {
println!("{}", regenerate_message);
print!("Would you like to regenerate the lockfile automatically? [Y/n] ");
io::stdout().flush().unwrap();
let mut input = String::new();
match io::stdin().read_line(&mut input) {
Ok(_) => {
let response = input.trim().to_lowercase();
if response.is_empty() || response == "y" || response == "yes" {
self.backup_and_regenerate_lockfile(&backup_path, operation_name)?;
Ok(None) } else {
Err(crate::core::AgpmError::InvalidLockfileError {
file: self.lockfile_path.display().to_string(),
reason: format!(
"{} (User declined automatic regeneration)",
error_msg
),
can_regenerate: true,
}
.into())
}
}
Err(_) => {
Err(self.create_non_interactive_error(&error_msg, operation_name))
}
}
} else {
Err(self.create_non_interactive_error(&error_msg, operation_name))
}
}
}
}
fn backup_and_regenerate_lockfile(
&self,
backup_path: &Path,
operation_name: &str,
) -> Result<()> {
if let Err(e) = std::fs::copy(&self.lockfile_path, backup_path) {
eprintln!("Warning: Failed to backup invalid lockfile: {}", e);
} else {
println!("✓ Backed up invalid lockfile to: {}", backup_path.display());
}
if let Err(e) = std::fs::remove_file(&self.lockfile_path) {
return Err(anyhow::anyhow!("Failed to remove invalid lockfile: {}", e));
}
println!("✓ Removed invalid lockfile");
println!("Note: Run 'agpm install' to regenerate the lockfile");
if operation_name != "install" {
println!("Alternatively, run 'agpm {} --regenerate' if available", operation_name);
}
Ok(())
}
fn create_non_interactive_error(
&self,
error_msg: &str,
_operation_name: &str,
) -> anyhow::Error {
let backup_path = self.lockfile_path.with_extension("lock.invalid");
crate::core::AgpmError::InvalidLockfileError {
file: self.lockfile_path.display().to_string(),
reason: format!(
"{}\n\n\
To fix this issue:\n\
1. Backup the invalid lockfile: cp agpm.lock {}\n\
2. Remove the invalid lockfile: rm agpm.lock\n\
3. Regenerate it: agpm install\n\n\
Note: The lockfile format is not yet stable as this is beta software.",
error_msg,
backup_path.display()
),
can_regenerate: true,
}
.into()
}
pub fn save_lockfile(&self, lockfile: &crate::lockfile::LockFile) -> Result<()> {
lockfile
.save(&self.lockfile_path)
.with_context(|| format!("Failed to save lockfile: {}", self.lockfile_path.display()))
}
}
pub async fn handle_legacy_ccpm_migration() -> Result<Option<PathBuf>> {
let current_dir = std::env::current_dir()?;
let legacy_dir = find_legacy_ccpm_directory(¤t_dir);
let Some(dir) = legacy_dir else {
return Ok(None);
};
if !std::io::stdin().is_terminal() {
eprintln!("{}", "Legacy CCPM files detected (non-interactive mode).".yellow());
eprintln!(
"Run {} to migrate manually.",
format!("agpm migrate --path {}", dir.display()).cyan()
);
return Ok(None);
}
let ccpm_toml = dir.join("ccpm.toml");
let ccpm_lock = dir.join("ccpm.lock");
let mut files = Vec::new();
if ccpm_toml.exists() {
files.push("ccpm.toml");
}
if ccpm_lock.exists() {
files.push("ccpm.lock");
}
let files_str = files.join(" and ");
println!("{}", "Legacy CCPM files detected!".yellow().bold());
println!("{} {} found in {}", "→".cyan(), files_str, dir.display());
println!();
print!("{} ", "Would you like to migrate to AGPM now? [Y/n]:".green());
io::stdout().flush()?;
let mut reader = BufReader::new(tokio::io::stdin());
let mut response = String::new();
reader.read_line(&mut response).await?;
let response = response.trim().to_lowercase();
if response.is_empty() || response == "y" || response == "yes" {
println!();
println!("{}", "🚀 Starting migration...".cyan());
let migrate_cmd = super::migrate::MigrateCommand::new(Some(dir.clone()), false, false);
migrate_cmd.execute().await?;
Ok(Some(dir.join("agpm.toml")))
} else {
println!();
println!("{}", "Migration cancelled.".yellow());
println!(
"Run {} to migrate manually.",
format!("agpm migrate --path {}", dir.display()).cyan()
);
Ok(None)
}
}
#[must_use]
pub fn check_for_legacy_ccpm_files() -> Option<String> {
check_for_legacy_ccpm_files_from(std::env::current_dir().ok()?)
}
fn find_legacy_ccpm_directory(start_dir: &Path) -> Option<PathBuf> {
let mut dir = start_dir;
loop {
let ccpm_toml = dir.join("ccpm.toml");
let ccpm_lock = dir.join("ccpm.lock");
if ccpm_toml.exists() || ccpm_lock.exists() {
return Some(dir.to_path_buf());
}
dir = dir.parent()?;
}
}
fn check_for_legacy_ccpm_files_from(start_dir: PathBuf) -> Option<String> {
let current = start_dir;
let mut dir = current.as_path();
loop {
let ccpm_toml = dir.join("ccpm.toml");
let ccpm_lock = dir.join("ccpm.lock");
if ccpm_toml.exists() || ccpm_lock.exists() {
let mut files = Vec::new();
if ccpm_toml.exists() {
files.push("ccpm.toml");
}
if ccpm_lock.exists() {
files.push("ccpm.lock");
}
let files_str = files.join(" and ");
let location = if dir == current {
"current directory".to_string()
} else {
format!("parent directory: {}", dir.display())
};
return Some(format!(
"{}\n\n{} {} found in {}.\n{}\n {}\n\n{}",
"Legacy CCPM files detected!".yellow().bold(),
"→".cyan(),
files_str,
location,
"Run the migration command to upgrade:".yellow(),
format!("agpm migrate --path {}", dir.display()).cyan().bold(),
"Or run 'agpm init' to create a new AGPM project.".dimmed()
));
}
dir = dir.parent()?;
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_command_context_from_manifest_path() {
let temp_dir = TempDir::new().unwrap();
let manifest_path = temp_dir.path().join("agpm.toml");
std::fs::write(
&manifest_path,
r#"
[sources]
test = "https://github.com/test/repo.git"
[agents]
"#,
)
.unwrap();
let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
assert_eq!(context.manifest_path, manifest_path);
assert_eq!(context.project_dir, temp_dir.path());
assert_eq!(context.lockfile_path, temp_dir.path().join("agpm.lock"));
assert!(context.manifest.sources.contains_key("test"));
}
#[test]
fn test_command_context_missing_manifest() {
let result = CommandContext::from_manifest_path("/nonexistent/agpm.toml");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
fn test_command_context_invalid_manifest() {
let temp_dir = TempDir::new().unwrap();
let manifest_path = temp_dir.path().join("agpm.toml");
std::fs::write(&manifest_path, "invalid toml {{").unwrap();
let result = CommandContext::from_manifest_path(&manifest_path);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Failed to parse manifest"));
}
#[test]
fn test_load_lockfile_exists() {
let temp_dir = TempDir::new().unwrap();
let manifest_path = temp_dir.path().join("agpm.toml");
let lockfile_path = temp_dir.path().join("agpm.lock");
std::fs::write(&manifest_path, "[sources]\n").unwrap();
std::fs::write(
&lockfile_path,
r#"
version = 1
[[sources]]
name = "test"
url = "https://github.com/test/repo.git"
commit = "abc123"
fetched_at = "2024-01-01T00:00:00Z"
"#,
)
.unwrap();
let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
let lockfile = context.load_lockfile().unwrap();
assert!(lockfile.is_some());
let lockfile = lockfile.unwrap();
assert_eq!(lockfile.sources.len(), 1);
assert_eq!(lockfile.sources[0].name, "test");
}
#[test]
fn test_load_lockfile_not_exists() {
let temp_dir = TempDir::new().unwrap();
let manifest_path = temp_dir.path().join("agpm.toml");
std::fs::write(&manifest_path, "[sources]\n").unwrap();
let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
let lockfile = context.load_lockfile().unwrap();
assert!(lockfile.is_none());
}
#[test]
fn test_save_lockfile() {
let temp_dir = TempDir::new().unwrap();
let manifest_path = temp_dir.path().join("agpm.toml");
std::fs::write(&manifest_path, "[sources]\n").unwrap();
let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
let lockfile = crate::lockfile::LockFile {
version: 1,
sources: vec![],
agents: vec![],
snippets: vec![],
commands: vec![],
scripts: vec![],
hooks: vec![],
mcp_servers: vec![],
};
context.save_lockfile(&lockfile).unwrap();
assert!(context.lockfile_path.exists());
let saved_content = std::fs::read_to_string(&context.lockfile_path).unwrap();
assert!(saved_content.contains("version = 1"));
}
#[test]
fn test_check_for_legacy_ccpm_no_files() {
let temp_dir = TempDir::new().unwrap();
let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
assert!(result.is_none());
}
#[test]
fn test_check_for_legacy_ccpm_toml_only() {
let temp_dir = TempDir::new().unwrap();
std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
assert!(result.is_some());
let msg = result.unwrap();
assert!(msg.contains("Legacy CCPM files detected"));
assert!(msg.contains("ccpm.toml"));
assert!(msg.contains("agpm migrate"));
}
#[test]
fn test_check_for_legacy_ccpm_lock_only() {
let temp_dir = TempDir::new().unwrap();
std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
assert!(result.is_some());
let msg = result.unwrap();
assert!(msg.contains("ccpm.lock"));
}
#[test]
fn test_check_for_legacy_ccpm_both_files() {
let temp_dir = TempDir::new().unwrap();
std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
assert!(result.is_some());
let msg = result.unwrap();
assert!(msg.contains("ccpm.toml and ccpm.lock"));
}
#[test]
fn test_find_legacy_ccpm_directory_no_files() {
let temp_dir = TempDir::new().unwrap();
let result = find_legacy_ccpm_directory(temp_dir.path());
assert!(result.is_none());
}
#[test]
fn test_find_legacy_ccpm_directory_in_current_dir() {
let temp_dir = TempDir::new().unwrap();
std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
let result = find_legacy_ccpm_directory(temp_dir.path());
assert!(result.is_some());
assert_eq!(result.unwrap(), temp_dir.path());
}
#[test]
fn test_find_legacy_ccpm_directory_in_parent() {
let temp_dir = TempDir::new().unwrap();
let parent = temp_dir.path();
let child = parent.join("subdir");
std::fs::create_dir(&child).unwrap();
std::fs::write(parent.join("ccpm.toml"), "[sources]\n").unwrap();
let result = find_legacy_ccpm_directory(&child);
assert!(result.is_some());
assert_eq!(result.unwrap(), parent);
}
#[test]
fn test_find_legacy_ccpm_directory_finds_lock_file() {
let temp_dir = TempDir::new().unwrap();
std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
let result = find_legacy_ccpm_directory(temp_dir.path());
assert!(result.is_some());
assert_eq!(result.unwrap(), temp_dir.path());
}
#[tokio::test]
async fn test_handle_legacy_ccpm_migration_no_files() {
let temp_dir = TempDir::new().unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(temp_dir.path()).unwrap();
let result = handle_legacy_ccpm_migration().await;
std::env::set_current_dir(original_dir).unwrap();
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
#[cfg(test)]
mod lockfile_regeneration_tests {
use super::*;
use crate::manifest::Manifest;
use tempfile::TempDir;
#[test]
fn test_load_lockfile_with_regeneration_valid_lockfile() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
let manifest_path = project_dir.join("agpm.toml");
let lockfile_path = project_dir.join("agpm.lock");
let manifest_content = r#"[sources]
example = "https://github.com/example/repo.git"
[agents]
test = { source = "example", path = "test.md", version = "v1.0.0" }
"#;
std::fs::write(&manifest_path, manifest_content).unwrap();
let lockfile_content = r#"version = 1
[[sources]]
name = "example"
url = "https://github.com/example/repo.git"
commit = "abc123def456789012345678901234567890abcd"
fetched_at = "2024-01-01T00:00:00Z"
[[agents]]
name = "test"
source = "example"
path = "test.md"
version = "v1.0.0"
resolved_commit = "abc123def456789012345678901234567890abcd"
checksum = "sha256:examplechecksum"
installed_at = ".claude/agents/test.md"
"#;
std::fs::write(&lockfile_path, lockfile_content).unwrap();
let manifest = Manifest::load(&manifest_path).unwrap();
let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
let result = ctx.load_lockfile_with_regeneration(true, "test").unwrap();
assert!(result.is_some());
}
#[test]
fn test_load_lockfile_with_regeneration_invalid_toml() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
let manifest_path = project_dir.join("agpm.toml");
let lockfile_path = project_dir.join("agpm.lock");
let manifest_content = r#"[sources]
example = "https://github.com/example/repo.git"
"#;
std::fs::write(&manifest_path, manifest_content).unwrap();
std::fs::write(&lockfile_path, "invalid toml [[[").unwrap();
let manifest = Manifest::load(&manifest_path).unwrap();
let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
let result = ctx.load_lockfile_with_regeneration(true, "test");
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("Invalid or corrupted lockfile detected"));
assert!(error_msg.contains("beta software"));
assert!(error_msg.contains("cp agpm.lock"));
}
#[test]
fn test_load_lockfile_with_regeneration_missing_lockfile() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
let manifest_path = project_dir.join("agpm.toml");
let manifest_content = r#"[sources]
example = "https://github.com/example/repo.git"
"#;
std::fs::write(&manifest_path, manifest_content).unwrap();
let manifest = Manifest::load(&manifest_path).unwrap();
let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
let result = ctx.load_lockfile_with_regeneration(true, "test").unwrap();
assert!(result.is_none()); }
#[test]
fn test_load_lockfile_with_regeneration_version_incompatibility() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
let manifest_path = project_dir.join("agpm.toml");
let lockfile_path = project_dir.join("agpm.lock");
let manifest_content = r#"[sources]
example = "https://github.com/example/repo.git"
"#;
std::fs::write(&manifest_path, manifest_content).unwrap();
let lockfile_content = r#"version = 999
[[sources]]
name = "example"
url = "https://github.com/example/repo.git"
commit = "abc123def456789012345678901234567890abcd"
fetched_at = "2024-01-01T00:00:00Z"
"#;
std::fs::write(&lockfile_path, lockfile_content).unwrap();
let manifest = Manifest::load(&manifest_path).unwrap();
let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
let result = ctx.load_lockfile_with_regeneration(true, "test");
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("version") || error_msg.contains("newer"));
}
#[test]
fn test_load_lockfile_with_regeneration_cannot_regenerate() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
let manifest_path = project_dir.join("agpm.toml");
let lockfile_path = project_dir.join("agpm.lock");
let manifest_content = r#"[sources]
example = "https://github.com/example/repo.git"
"#;
std::fs::write(&manifest_path, manifest_content).unwrap();
std::fs::write(&lockfile_path, "invalid toml [[[").unwrap();
let manifest = Manifest::load(&manifest_path).unwrap();
let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
let result = ctx.load_lockfile_with_regeneration(false, "test");
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(!error_msg.contains("Invalid or corrupted lockfile detected"));
assert!(
error_msg.contains("Failed to load lockfile")
|| error_msg.contains("Invalid TOML syntax")
);
}
#[test]
fn test_backup_and_regenerate_lockfile() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
let manifest_path = project_dir.join("agpm.toml");
let lockfile_path = project_dir.join("agpm.lock");
let manifest_content = r#"[sources]
example = "https://github.com/example/repo.git"
"#;
std::fs::write(&manifest_path, manifest_content).unwrap();
std::fs::write(&lockfile_path, "invalid content").unwrap();
let manifest = Manifest::load(&manifest_path).unwrap();
let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
let backup_path = lockfile_path.with_extension("lock.invalid");
ctx.backup_and_regenerate_lockfile(&backup_path, "test").unwrap();
assert!(backup_path.exists());
assert_eq!(std::fs::read_to_string(&backup_path).unwrap(), "invalid content");
assert!(!lockfile_path.exists());
}
#[test]
fn test_create_non_interactive_error() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
let manifest_path = project_dir.join("agpm.toml");
let manifest_content = r#"[sources]
example = "https://github.com/example/repo.git"
"#;
std::fs::write(&manifest_path, manifest_content).unwrap();
let manifest = Manifest::load(&manifest_path).unwrap();
let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
let error = ctx.create_non_interactive_error("Invalid TOML syntax", "test");
let error_msg = error.to_string();
assert!(error_msg.contains("Invalid TOML syntax"));
assert!(error_msg.contains("beta software"));
assert!(error_msg.contains("cp agpm.lock"));
assert!(error_msg.contains("rm agpm.lock"));
assert!(error_msg.contains("agpm install"));
}
}
}