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(None, false).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 reload_manifest(&mut self) -> Result<()> {
self.manifest = Manifest::load(&self.manifest_path).with_context(|| {
format!("Failed to reload manifest file: {}", self.manifest_path.display())
})?;
Ok(())
}
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(mut lockfile) => {
if let Ok(Some(private_lock)) =
crate::lockfile::PrivateLockFile::load(&self.project_dir)
{
lockfile.merge_private(&private_lock);
}
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(
from_dir: Option<PathBuf>,
yes: bool,
) -> Result<Option<PathBuf>> {
let current_dir = match from_dir {
Some(dir) => dir,
None => std::env::current_dir()?,
};
let legacy_dir = find_legacy_ccpm_directory(¤t_dir);
let Some(dir) = legacy_dir else {
return Ok(None);
};
if !yes && !std::io::stdin().is_terminal() {
eprintln!("{}", "Legacy CCPM files detected (non-interactive mode).".yellow());
eprintln!(
"Run {} to migrate manually, or use --yes to auto-accept.",
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!();
let should_migrate = if yes {
true
} else {
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();
response.is_empty() || response == "y" || response == "yes"
};
if should_migrate {
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)
}
}
pub async fn handle_legacy_format_migration(project_dir: &Path, yes: bool) -> Result<bool> {
use super::migrate::{detect_old_format, run_format_migration};
let detection = detect_old_format(project_dir);
if !detection.needs_migration() {
return Ok(false);
}
if !yes && !std::io::stdin().is_terminal() {
eprintln!("{}", "Legacy AGPM format detected (non-interactive mode).".yellow());
eprintln!(
"Run {} to migrate manually, or use --yes to auto-accept.",
format!("agpm migrate --path {}", project_dir.display()).cyan()
);
return Ok(false);
}
println!("{}", "Legacy AGPM format detected!".yellow().bold());
if !detection.old_resource_paths.is_empty() {
println!(
"\n{} Found {} resources at old paths:",
"→".cyan(),
detection.old_resource_paths.len()
);
for path in &detection.old_resource_paths {
let rel = path.strip_prefix(project_dir).unwrap_or(path);
println!(" • {}", rel.display());
}
}
if detection.has_managed_gitignore_section {
println!("\n{} Found AGPM/CCPM managed section in .gitignore", "→".cyan());
}
println!();
println!(
"{}",
"The new format uses agpm/ subdirectories for easier gitignore management.".dimmed()
);
println!();
let should_migrate = if yes {
true
} else {
print!("{} ", "Would you like to migrate to the new format 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();
response.is_empty() || response == "y" || response == "yes"
};
if should_migrate {
println!();
run_format_migration(project_dir).await?;
Ok(true)
} else {
println!();
println!("{}", "Migration cancelled.".yellow());
println!(
"Run {} to migrate manually.",
format!("agpm migrate --path {}", project_dir.display()).cyan()
);
Ok(false)
}
}
#[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()?;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OperationMode {
Install,
Update,
}
pub fn display_dry_run_results(
new_lockfile: &crate::lockfile::LockFile,
existing_lockfile: Option<&crate::lockfile::LockFile>,
quiet: bool,
) -> Result<()> {
let (new_resources, updated_resources, unchanged_count) =
categorize_resource_changes(new_lockfile, existing_lockfile);
let has_changes = !new_resources.is_empty() || !updated_resources.is_empty();
display_dry_run_output(&new_resources, &updated_resources, unchanged_count, quiet);
if has_changes {
Err(anyhow::anyhow!("Dry-run detected changes (exit 1)"))
} else {
Ok(())
}
}
#[derive(Debug, Clone)]
struct NewResource {
resource_type: String,
name: String,
version: String,
}
#[derive(Debug, Clone)]
struct UpdatedResource {
resource_type: String,
name: String,
old_version: String,
new_version: String,
}
fn categorize_resource_changes(
new_lockfile: &crate::lockfile::LockFile,
existing_lockfile: Option<&crate::lockfile::LockFile>,
) -> (Vec<NewResource>, Vec<UpdatedResource>, usize) {
use crate::core::resource_iterator::ResourceIterator;
let mut new_resources = Vec::new();
let mut updated_resources = Vec::new();
let mut unchanged_count = 0;
if let Some(existing) = existing_lockfile {
ResourceIterator::for_each_resource(new_lockfile, |resource_type, new_entry| {
if let Some((_, old_entry)) = ResourceIterator::find_resource_by_name_and_source(
existing,
&new_entry.name,
new_entry.source.as_deref(),
) {
if old_entry.resolved_commit == new_entry.resolved_commit {
unchanged_count += 1;
} else {
let old_version =
old_entry.version.clone().unwrap_or_else(|| "latest".to_string());
let new_version =
new_entry.version.clone().unwrap_or_else(|| "latest".to_string());
updated_resources.push(UpdatedResource {
resource_type: resource_type.to_string(),
name: new_entry.name.clone(),
old_version,
new_version,
});
}
} else {
new_resources.push(NewResource {
resource_type: resource_type.to_string(),
name: new_entry.name.clone(),
version: new_entry.version.clone().unwrap_or_else(|| "latest".to_string()),
});
}
});
} else {
ResourceIterator::for_each_resource(new_lockfile, |resource_type, new_entry| {
new_resources.push(NewResource {
resource_type: resource_type.to_string(),
name: new_entry.name.clone(),
version: new_entry.version.clone().unwrap_or_else(|| "latest".to_string()),
});
});
}
(new_resources, updated_resources, unchanged_count)
}
fn display_dry_run_output(
new_resources: &[NewResource],
updated_resources: &[UpdatedResource],
unchanged_count: usize,
quiet: bool,
) {
if quiet {
return;
}
let has_changes = !new_resources.is_empty() || !updated_resources.is_empty();
if has_changes {
println!("{}", "Dry run - the following changes would be made:".yellow());
println!();
if !new_resources.is_empty() {
println!("{}", "New resources:".green().bold());
for resource in new_resources {
println!(
" {} {} ({})",
"+".green(),
resource.name.cyan(),
format!("{} {}", resource.resource_type, resource.version).dimmed()
);
}
println!();
}
if !updated_resources.is_empty() {
println!("{}", "Updated resources:".yellow().bold());
for resource in updated_resources {
print!(
" {} {} {} → ",
"~".yellow(),
resource.name.cyan(),
resource.old_version.yellow()
);
println!("{} ({})", resource.new_version.green(), resource.resource_type.dimmed());
}
println!();
}
if unchanged_count > 0 {
println!("{}", format!("{unchanged_count} unchanged resources").dimmed());
}
println!();
println!(
"{}",
format!(
"Total: {} new, {} updated, {} unchanged",
new_resources.len(),
updated_resources.len(),
unchanged_count
)
.bold()
);
println!();
println!("{}", "No files were modified (dry-run mode)".yellow());
} else {
println!("✓ {}", "No changes would be made".green());
}
}
pub fn display_no_changes(mode: OperationMode, quiet: bool) {
if quiet {
return;
}
match mode {
OperationMode::Install => println!("No dependencies to install"),
OperationMode::Update => println!("All dependencies are up to date!"),
}
}
pub async fn handle_missing_gitignore_entries(
validation: &crate::installer::ConfigValidation,
project_dir: &Path,
yes: bool,
) -> Result<bool> {
use super::migrate::{AGPM_MANAGED_PATHS, AGPM_MANAGED_PATHS_END};
use tokio::io::AsyncWriteExt;
if validation.missing_gitignore_entries.is_empty() {
return Ok(false);
}
let missing = &validation.missing_gitignore_entries;
let gitignore_path = project_dir.join(".gitignore");
if gitignore_path.exists() {
if let Ok(content) = tokio::fs::read_to_string(&gitignore_path).await {
if content.contains(AGPM_MANAGED_PATHS) {
eprintln!("\n{}", "Warning: Missing gitignore entries detected:".yellow());
for entry in missing {
eprintln!(" {}", entry);
}
eprintln!(
"\nThe {} section exists but may need manual updates.",
AGPM_MANAGED_PATHS.cyan()
);
return Ok(false);
}
}
}
if !yes && !std::io::stdin().is_terminal() {
eprintln!("\n{}", "Missing gitignore entries detected:".yellow());
for entry in missing {
eprintln!(" {}", entry);
}
eprintln!("\nRun with {} to add them automatically, or add manually.", "--yes".cyan());
return Ok(false);
}
println!("\n{}", "Missing .gitignore entries detected:".yellow().bold());
for entry in missing {
println!(" {} {}", "→".cyan(), entry);
}
println!();
let should_add = if yes {
true
} else {
print!("{} ", "Would you like to add them 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();
response.is_empty() || response == "y" || response == "yes"
};
if !should_add {
println!("{}", "Skipped adding gitignore entries.".yellow());
return Ok(false);
}
let mut content = String::new();
if gitignore_path.exists() {
let existing = tokio::fs::read_to_string(&gitignore_path).await.unwrap_or_default();
if !existing.is_empty() && !existing.ends_with('\n') {
content.push('\n');
}
content.push('\n');
}
content.push_str(AGPM_MANAGED_PATHS);
content.push('\n');
content.push_str(".claude/*/agpm/\n");
content.push_str(".opencode/*/agpm/\n");
content.push_str(".agpm/\n");
content.push_str("agpm.private.toml\n");
content.push_str("agpm.private.lock\n");
content.push_str(AGPM_MANAGED_PATHS_END);
content.push('\n');
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&gitignore_path)
.await
.context("Failed to open .gitignore for writing")?;
file.write_all(content.as_bytes()).await.context("Failed to write to .gitignore")?;
file.sync_all().await.context("Failed to sync .gitignore")?;
println!("{} Added AGPM managed paths section to .gitignore", "✓".green());
Ok(true)
}
#[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![],
skills: vec![],
manifest_hash: None,
has_mutable_deps: None,
resource_count: None,
};
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() -> Result<()> {
let temp_dir = TempDir::new()?;
let result = handle_legacy_ccpm_migration(Some(temp_dir.path().to_path_buf()), false).await;
assert!(result?.is_none());
Ok(())
}
#[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"));
}
}
mod migration_tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_handle_legacy_ccpm_migration_with_files_non_interactive() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
std::fs::write(
project_dir.join("ccpm.toml"),
"[sources]\ntest = \"https://test.git\"\n",
)
.unwrap();
std::fs::write(project_dir.join("ccpm.lock"), "version = 1\n").unwrap();
let result = handle_legacy_ccpm_migration(Some(project_dir.to_path_buf()), false).await;
assert!(result.is_ok());
assert!(result.unwrap().is_none());
assert!(project_dir.join("ccpm.toml").exists());
assert!(!project_dir.join("agpm.toml").exists());
}
#[tokio::test]
async fn test_handle_legacy_format_migration_no_migration_needed() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
let agents_dir = project_dir.join(".claude/agents/agpm");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(agents_dir.join("test.md"), "# Test Agent").unwrap();
let lockfile = r#"version = 1
[[agents]]
name = "test"
source = "test"
path = "agents/test.md"
version = "v1.0.0"
resolved_commit = "abc123"
checksum = "sha256:abc"
context_checksum = "sha256:def"
installed_at = ".claude/agents/agpm/test.md"
dependencies = []
resource_type = "Agent"
tool = "claude-code"
"#;
std::fs::write(project_dir.join("agpm.lock"), lockfile).unwrap();
let result = handle_legacy_format_migration(project_dir, false).await;
assert!(result.is_ok());
assert!(!result.unwrap()); }
#[tokio::test]
async fn test_handle_legacy_format_migration_with_old_paths_non_interactive() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
let agents_dir = project_dir.join(".claude/agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(agents_dir.join("test.md"), "# Test Agent").unwrap();
let lockfile = r#"version = 1
[[agents]]
name = "test"
source = "test"
path = "agents/test.md"
version = "v1.0.0"
resolved_commit = "abc123"
checksum = "sha256:abc"
context_checksum = "sha256:def"
installed_at = ".claude/agents/test.md"
dependencies = []
resource_type = "Agent"
tool = "claude-code"
"#;
std::fs::write(project_dir.join("agpm.lock"), lockfile).unwrap();
let result = handle_legacy_format_migration(project_dir, false).await;
assert!(result.is_ok());
assert!(!result.unwrap());
assert!(agents_dir.join("test.md").exists());
assert!(!agents_dir.join("agpm/test.md").exists());
}
#[tokio::test]
async fn test_handle_legacy_format_migration_with_gitignore_section_non_interactive() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
let gitignore = r#"# User entries
node_modules/
# AGPM managed entries - do not edit below this line
.claude/agents/test.md
# End of AGPM managed entries
"#;
std::fs::write(project_dir.join(".gitignore"), gitignore).unwrap();
std::fs::write(project_dir.join("agpm.lock"), "version = 1\n").unwrap();
let result = handle_legacy_format_migration(project_dir, false).await;
assert!(result.is_ok());
assert!(!result.unwrap());
let content = std::fs::read_to_string(project_dir.join(".gitignore")).unwrap();
assert!(content.contains("# AGPM managed entries"));
}
#[tokio::test]
async fn test_handle_legacy_format_migration_no_lockfile() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
let agents_dir = project_dir.join(".claude/agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(agents_dir.join("test.md"), "# Test Agent").unwrap();
let result = handle_legacy_format_migration(project_dir, false).await;
assert!(result.is_ok());
assert!(!result.unwrap()); }
}
mod gitignore_offering_tests {
use super::*;
use crate::installer::ConfigValidation;
use tempfile::TempDir;
#[tokio::test]
async fn test_handle_missing_gitignore_no_entries() {
let temp_dir = TempDir::new().unwrap();
let validation = ConfigValidation::default();
let result =
handle_missing_gitignore_entries(&validation, temp_dir.path(), false).await;
assert!(result.is_ok());
assert!(!result.unwrap()); }
#[tokio::test]
async fn test_handle_missing_gitignore_with_yes_flag() {
let temp_dir = TempDir::new().unwrap();
let validation = ConfigValidation {
missing_gitignore_entries: vec![
".claude/agents/agpm/".to_string(),
"agpm.private.toml".to_string(),
],
..Default::default()
};
let result = handle_missing_gitignore_entries(
&validation,
temp_dir.path(),
true, )
.await;
assert!(result.is_ok());
assert!(result.unwrap());
let content = std::fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
assert!(content.contains("# AGPM managed paths"));
assert!(content.contains(".claude/*/agpm/"));
assert!(content.contains(".opencode/*/agpm/"));
assert!(content.contains(".agpm/"));
assert!(content.contains("agpm.private.toml"));
assert!(content.contains("agpm.private.lock"));
assert!(content.contains("# End of AGPM managed paths"));
}
#[tokio::test]
async fn test_handle_missing_gitignore_appends_to_existing() {
let temp_dir = TempDir::new().unwrap();
std::fs::write(temp_dir.path().join(".gitignore"), "node_modules/\n.env\n").unwrap();
let validation = ConfigValidation {
missing_gitignore_entries: vec![".claude/agents/agpm/".to_string()],
..Default::default()
};
let result = handle_missing_gitignore_entries(&validation, temp_dir.path(), true).await;
assert!(result.is_ok());
assert!(result.unwrap());
let content = std::fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
assert!(content.contains("node_modules/"));
assert!(content.contains(".env"));
assert!(content.contains("# AGPM managed paths"));
assert!(content.contains(".claude/*/agpm/"));
}
#[tokio::test]
async fn test_handle_missing_gitignore_non_interactive_no_yes() {
let temp_dir = TempDir::new().unwrap();
let validation = ConfigValidation {
missing_gitignore_entries: vec![".claude/agents/agpm/".to_string()],
..Default::default()
};
let result = handle_missing_gitignore_entries(
&validation,
temp_dir.path(),
false, )
.await;
assert!(result.is_ok());
assert!(!result.unwrap());
assert!(!temp_dir.path().join(".gitignore").exists());
}
#[tokio::test]
async fn test_handle_missing_gitignore_skips_if_section_exists() {
let temp_dir = TempDir::new().unwrap();
let existing = r#"node_modules/
# AGPM managed paths
.claude/*/agpm/
# End of AGPM managed paths
"#;
std::fs::write(temp_dir.path().join(".gitignore"), existing).unwrap();
let validation = ConfigValidation {
missing_gitignore_entries: vec!["agpm.private.toml".to_string()],
..Default::default()
};
let result = handle_missing_gitignore_entries(&validation, temp_dir.path(), true).await;
assert!(result.is_ok());
assert!(!result.unwrap());
let content = std::fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
assert_eq!(content, existing);
}
#[tokio::test]
async fn test_handle_missing_gitignore_creates_new_file() {
let temp_dir = TempDir::new().unwrap();
assert!(!temp_dir.path().join(".gitignore").exists());
let validation = ConfigValidation {
missing_gitignore_entries: vec![".claude/agents/agpm/".to_string()],
..Default::default()
};
let result = handle_missing_gitignore_entries(&validation, temp_dir.path(), true).await;
assert!(result.is_ok());
assert!(result.unwrap());
assert!(temp_dir.path().join(".gitignore").exists());
let content = std::fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
assert!(content.contains("# AGPM managed paths"));
}
#[tokio::test]
async fn test_handle_missing_gitignore_handles_file_without_newline() {
let temp_dir = TempDir::new().unwrap();
std::fs::write(temp_dir.path().join(".gitignore"), "node_modules/").unwrap();
let validation = ConfigValidation {
missing_gitignore_entries: vec![".claude/agents/agpm/".to_string()],
..Default::default()
};
let result = handle_missing_gitignore_entries(&validation, temp_dir.path(), true).await;
assert!(result.is_ok());
assert!(result.unwrap());
let content = std::fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
assert!(content.contains("node_modules/\n\n# AGPM managed paths"));
}
}
}