use anyhow::{Context, Result};
use clap::Args;
use colored::Colorize;
use std::path::PathBuf;
use crate::cache::Cache;
use crate::core::{OperationContext, ResourceIterator};
use crate::lockfile::LockFile;
use crate::manifest::{Manifest, ResourceDependency, find_manifest_with_optional};
use crate::resolver::DependencyResolver;
#[derive(Debug, Args)]
pub struct UpdateCommand {
#[arg(value_name = "DEPENDENCY")]
pub dependencies: Vec<String>,
#[arg(long, conflicts_with = "check")]
pub dry_run: bool,
#[arg(long, conflicts_with = "dry_run")]
pub check: bool,
#[arg(long)]
pub backup: bool,
#[arg(long)]
pub verbose: bool,
#[arg(long, short)]
pub quiet: bool,
#[arg(long, value_name = "NUMBER")]
pub max_parallel: Option<usize>,
#[arg(skip)]
pub no_progress: bool,
#[arg(short = 'y', long)]
pub yes: bool,
}
impl UpdateCommand {
pub async fn execute_with_manifest_path(self, manifest_path: Option<PathBuf>) -> Result<()> {
let manifest_path = find_manifest_with_optional(manifest_path).with_context(|| {
"No agpm.toml found in current directory or any parent directory.\n\n\
The update command requires a agpm.toml file to know what dependencies to update.\n\
Create one first, then run 'agpm install' before updating."
})?;
self.execute_from_path(manifest_path).await
}
pub async fn execute_from_path(self, manifest_path: PathBuf) -> Result<()> {
use crate::installer::{ResourceFilter, install_resources};
use crate::utils::progress::{InstallationPhase, MultiPhaseProgress};
use std::sync::Arc;
if !manifest_path.exists() {
return Err(anyhow::anyhow!("Manifest file {} not found", manifest_path.display()));
}
let project_dir = manifest_path.parent().unwrap();
let multi_phase = Arc::new(MultiPhaseProgress::new(!self.quiet && !self.no_progress));
let (manifest, _conflicts) =
Manifest::load_with_private(&manifest_path).with_context(|| {
format!(
"Failed to parse manifest file: {}\n\n\
Please check the TOML syntax and fix any errors before updating.",
manifest_path.display()
)
})?;
let lockfile_path = project_dir.join("agpm.lock");
let existing_lockfile = if lockfile_path.exists() {
LockFile::load(&lockfile_path)?
} else {
if !self.quiet && !self.no_progress {
println!("⚠️ No lockfile found");
println!("ℹ️ Performing fresh install");
}
let install_cmd = if self.quiet {
crate::cli::install::InstallCommand::new_quiet()
} else {
crate::cli::install::InstallCommand::new()
};
return install_cmd.execute_from_path(Some(&manifest_path)).await;
};
if self.backup {
let backup_path = crate::utils::generate_backup_path(&lockfile_path, "agpm")?;
if let Some(backup_dir) = backup_path.parent() {
if !backup_dir.exists() {
tokio::fs::create_dir_all(backup_dir).await.with_context(|| {
format!("Failed to create directory: {}", backup_dir.display())
})?;
}
}
tokio::fs::copy(&lockfile_path, &backup_path)
.await
.with_context(|| format!("Failed to create backup at {}", backup_path.display()))?;
if !self.quiet && !self.no_progress {
println!("ℹ️ Created backup: {}", backup_path.display());
}
}
let deps_to_update = if self.dependencies.is_empty() {
None
} else {
Some(self.dependencies.clone())
};
let has_remote_deps =
manifest.all_dependencies().iter().any(|(_, dep)| dep.get_source().is_some());
let cache = Cache::new()?;
let mut resolver = DependencyResolver::new(manifest.clone(), cache.clone()).await?;
let operation_context = Arc::new(OperationContext::new());
resolver.set_operation_context(operation_context);
if has_remote_deps {
let all_deps: Vec<(String, ResourceDependency)> = manifest
.all_dependencies_with_types()
.into_iter()
.map(|(name, dep, _resource_type)| (name.to_string(), dep.into_owned()))
.collect();
let sync_progress = if !self.quiet && !self.no_progress {
Some(multi_phase.clone())
} else {
None
};
resolver.pre_sync_sources(&all_deps, sync_progress).await?;
}
let progress = if !self.quiet && !self.no_progress {
Some(multi_phase.clone())
} else {
None
};
let mut new_lockfile =
resolver.update(&existing_lockfile, deps_to_update.clone(), progress).await?;
let mut updates = Vec::new();
ResourceIterator::for_each_resource(&new_lockfile, |_, new_entry| {
if let Some((_, old_entry)) = ResourceIterator::find_resource_by_name_and_source(
&existing_lockfile,
new_entry.display_name(),
new_entry.source.as_deref(),
) {
let version_changed = old_entry.resolved_commit != new_entry.resolved_commit;
let patches_changed = old_entry.applied_patches != new_entry.applied_patches;
if version_changed || patches_changed {
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());
updates.push((
new_entry.name.clone(),
new_entry.source.clone(),
old_version,
new_version,
));
}
}
});
if updates.is_empty() {
crate::cli::common::display_no_changes(
crate::cli::common::OperationMode::Update,
self.quiet || self.no_progress,
);
} else {
if !self.quiet && !self.no_progress {
println!("✓ Found {} update(s)", updates.len());
}
if !self.quiet && !self.no_progress {
println!(); for (name, _source, old_ver, new_ver) in &updates {
println!(" {} {} → {}", name.cyan(), old_ver.yellow(), new_ver.green());
}
}
if self.dry_run || self.check {
if self.check {
if !self.quiet && !self.no_progress {
println!(); println!("{}", "Check mode - no changes made".yellow());
}
} else {
if !self.quiet && !self.no_progress {
println!(); println!("{} {}", "Would update".green(), "(dry run)".yellow());
}
}
return Err(anyhow::anyhow!("Dry-run detected updates available (exit 1)"));
}
let _resource_lock =
crate::installer::ProjectLock::acquire(project_dir, "resource").await?;
if !self.quiet && !self.no_progress && !updates.is_empty() {
multi_phase.start_phase(
InstallationPhase::Installing,
Some(&format!("({} resources)", updates.len())),
);
}
let global_config = crate::config::GlobalConfig::load().await.unwrap_or_default();
let token_warning_threshold =
manifest.token_warning_threshold.unwrap_or(global_config.token_warning_threshold);
let results = install_resources(
ResourceFilter::Updated(updates.clone()),
&Arc::new(new_lockfile.clone()),
&manifest,
project_dir,
cache.clone(), false, self.max_parallel, if self.quiet || self.no_progress {
None
} else {
Some(multi_phase.clone())
},
self.verbose,
Some(&existing_lockfile), false, Some(token_warning_threshold),
)
.await?;
new_lockfile.apply_installation_results(
results.checksums,
results.context_checksums,
results.applied_patches,
results.token_counts,
);
if results.installed_count > 0 && !self.quiet && !self.no_progress {
multi_phase.complete_phase(Some(&format!(
"Updated {} resources",
results.installed_count
)));
}
if !self.quiet && !self.no_progress && results.installed_count > 0 {
multi_phase.start_phase(InstallationPhase::Finalizing, None);
}
let (_hook_count, _server_count) = crate::installer::finalize_installation(
&mut new_lockfile,
&manifest,
project_dir,
&cache,
Some(&existing_lockfile), self.quiet,
false, )
.await?;
if !self.quiet && !self.no_progress && results.installed_count > 0 {
multi_phase.complete_phase(Some("Update finalized"));
}
if !self.quiet && !self.no_progress {
multi_phase.clear();
}
if !self.quiet && !self.no_progress && results.installed_count > 0 {
println!("\n✓ Updated {} resources", results.installed_count);
}
if !self.quiet && results.installed_count > 0 {
let validation = crate::installer::validate_config(
project_dir,
&new_lockfile,
manifest.gitignore,
)
.await;
if let Some(warning) = &validation.claude_settings_warning {
eprintln!("\n{}", warning);
}
if !validation.missing_gitignore_entries.is_empty() {
let _ = crate::cli::common::handle_missing_gitignore_entries(
&validation,
project_dir,
self.yes,
)
.await;
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lockfile::{LockFile, LockedResource, LockedSource};
use crate::manifest::{DetailedDependency, Manifest, ResourceDependency};
use std::collections::HashMap;
use std::fs;
use tempfile::TempDir;
fn create_update_command() -> UpdateCommand {
UpdateCommand {
dependencies: vec![],
dry_run: false,
check: false,
backup: false,
verbose: false,
quiet: true, no_progress: true, max_parallel: None,
yes: false,
}
}
#[allow(deprecated)]
fn create_test_manifest() -> Manifest {
let mut sources = HashMap::new();
sources.insert("test-source".to_string(), "file:///tmp/test-repo".to_string());
let mut agents = HashMap::new();
agents.insert(
"test-agent".to_string(),
ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("test-source".to_string()),
path: "agents/test-agent.md".to_string(),
version: Some("v1.0.0".to_string()),
command: None,
branch: None,
rev: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: Some("claude-code".to_string()),
flatten: None,
install: None,
template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
})),
);
Manifest {
sources,
tools: None,
agents,
snippets: HashMap::new(),
commands: HashMap::new(),
mcp_servers: HashMap::new(),
scripts: HashMap::new(),
hooks: HashMap::new(),
skills: HashMap::new(),
patches: crate::manifest::patches::ManifestPatches::default(),
project_patches: crate::manifest::patches::ManifestPatches::default(),
private_patches: crate::manifest::patches::ManifestPatches::default(),
manifest_dir: None,
default_tools: HashMap::new(),
project: None,
private_dependency_names: std::collections::HashSet::new(),
gitignore: true,
token_warning_threshold: None,
}
}
fn create_test_lockfile() -> LockFile {
LockFile {
version: 1,
commands: vec![],
sources: vec![LockedSource {
name: "test-source".to_string(),
url: "file:///tmp/test-repo".to_string(),
fetched_at: "2023-01-01T00:00:00Z".to_string(),
}],
agents: vec![LockedResource {
name: "test-agent".to_string(),
source: Some("test-source".to_string()),
url: Some("file:///tmp/test-repo".to_string()),
path: "agents/test-agent.md".to_string(),
version: Some("v1.0.0".to_string()),
resolved_commit: Some("abc123456789".to_string()),
checksum: "sha256:test123".to_string(),
installed_at: "agents/test-agent.md".to_string(),
dependencies: vec![],
resource_type: crate::core::ResourceType::Agent,
tool: Some("claude-code".to_string()),
manifest_alias: None,
context_checksum: None,
applied_patches: std::collections::BTreeMap::new(),
install: None,
variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
is_private: false,
approximate_token_count: None,
}],
snippets: vec![],
mcp_servers: vec![],
scripts: vec![],
hooks: vec![],
skills: vec![],
manifest_hash: None,
has_mutable_deps: None,
resource_count: None,
}
}
#[tokio::test]
async fn test_execute_no_manifest_found() {
let temp = TempDir::new().unwrap();
let non_existent_manifest = temp.path().join("agpm.toml");
assert!(!non_existent_manifest.exists());
let cmd = create_update_command();
let result = cmd.execute_with_manifest_path(Some(non_existent_manifest)).await;
assert!(result.is_err());
let error_msg = format!("{}", result.unwrap_err());
assert!(error_msg.contains("No agpm.toml found"));
assert!(error_msg.contains("Create one first"));
}
#[tokio::test]
async fn test_execute_from_path_nonexistent_manifest() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("nonexistent").join("agpm.toml");
let cmd = create_update_command();
let result = cmd.execute_from_path(manifest_path.clone()).await;
assert!(result.is_err());
let error_msg = format!("{}", result.unwrap_err());
assert!(error_msg.contains("not found"));
assert!(error_msg.contains(&manifest_path.display().to_string()));
}
#[tokio::test]
async fn test_execute_from_path_invalid_manifest() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
fs::write(&manifest_path, "invalid toml [[[").unwrap();
let cmd = create_update_command();
let result = cmd.execute_from_path(manifest_path).await;
assert!(result.is_err());
let error_msg = format!("{}", result.unwrap_err());
assert!(error_msg.contains("Failed to parse manifest"));
assert!(error_msg.contains("check the TOML syntax"));
}
#[tokio::test]
async fn test_execute_from_path_no_lockfile_fresh_install() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let lockfile_path = temp.path().join("agpm.lock");
let manifest_content = r"
[sources]
# No sources defined - this overrides any global sources
[agents]
# No agents
[snippets]
# No snippets
";
std::fs::write(&manifest_path, manifest_content).unwrap();
assert!(!lockfile_path.exists());
let mut cmd = create_update_command();
cmd.quiet = false;
let result = cmd.execute_from_path(manifest_path).await;
assert!(result.is_ok(), "Fresh install failed: {result:?}");
assert!(lockfile_path.exists());
}
#[tokio::test]
async fn test_execute_with_backup_flag() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let lockfile_path = temp.path().join("agpm.lock");
let backup_path = temp.path().join(".agpm").join("backups").join("agpm").join("agpm.lock");
let manifest = create_test_manifest();
let lockfile = create_test_lockfile();
manifest.save(&manifest_path).unwrap();
lockfile.save(&lockfile_path).unwrap();
let mut cmd = create_update_command();
cmd.backup = true;
let _result = cmd.execute_from_path(manifest_path).await;
if backup_path.exists() {
let backup_content = fs::read_to_string(&backup_path).unwrap();
let _original_content = fs::read_to_string(&lockfile_path).unwrap();
assert!(!backup_content.is_empty());
}
}
#[tokio::test]
async fn test_execute_with_specific_dependencies() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let lockfile_path = temp.path().join("agpm.lock");
let manifest = create_test_manifest();
let lockfile = create_test_lockfile();
manifest.save(&manifest_path).unwrap();
lockfile.save(&lockfile_path).unwrap();
let mut cmd = create_update_command();
cmd.dependencies = vec!["test-agent".to_string()];
let _result = cmd.execute_from_path(manifest_path).await;
}
#[tokio::test]
async fn test_execute_dry_run_mode() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let lockfile_path = temp.path().join("agpm.lock");
let manifest = create_test_manifest();
let lockfile = create_test_lockfile();
manifest.save(&manifest_path).unwrap();
lockfile.save(&lockfile_path).unwrap();
let original_content = fs::read_to_string(&lockfile_path).unwrap();
let mut cmd = create_update_command();
cmd.dry_run = true;
let _result = cmd.execute_from_path(manifest_path).await;
let current_content = fs::read_to_string(&lockfile_path).unwrap();
assert_eq!(original_content, current_content);
}
#[tokio::test]
async fn test_execute_check_mode() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let lockfile_path = temp.path().join("agpm.lock");
let manifest = create_test_manifest();
let lockfile = create_test_lockfile();
manifest.save(&manifest_path).unwrap();
lockfile.save(&lockfile_path).unwrap();
let original_content = fs::read_to_string(&lockfile_path).unwrap();
let mut cmd = create_update_command();
cmd.check = true;
let _result = cmd.execute_from_path(manifest_path).await;
let current_content = fs::read_to_string(&lockfile_path).unwrap();
assert_eq!(original_content, current_content);
}
#[tokio::test]
async fn test_execute_verbose_mode() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let lockfile_path = temp.path().join("agpm.lock");
let manifest = create_test_manifest();
let lockfile = create_test_lockfile();
manifest.save(&manifest_path).unwrap();
lockfile.save(&lockfile_path).unwrap();
let mut cmd = create_update_command();
cmd.verbose = true;
cmd.quiet = false;
let _result = cmd.execute_from_path(manifest_path).await;
}
#[tokio::test]
async fn test_execute_quiet_mode() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let lockfile_path = temp.path().join("agpm.lock");
let manifest = create_test_manifest();
let lockfile = create_test_lockfile();
manifest.save(&manifest_path).unwrap();
lockfile.save(&lockfile_path).unwrap();
let mut cmd = create_update_command();
cmd.quiet = true;
let _result = cmd.execute_from_path(manifest_path).await;
}
#[tokio::test]
async fn test_update_comparison_logic() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let lockfile_path = temp.path().join("agpm.lock");
let manifest = create_test_manifest();
manifest.save(&manifest_path).unwrap();
let mut old_lockfile = create_test_lockfile();
old_lockfile.agents[0].resolved_commit = Some("old123456789".to_string());
old_lockfile.save(&lockfile_path).unwrap();
let cmd = create_update_command();
let _result = cmd.execute_from_path(manifest_path).await;
}
#[tokio::test]
async fn test_lockfile_save_error_handling() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let lockfile_path = temp.path().join("agpm.lock");
let manifest = create_test_manifest();
let lockfile = create_test_lockfile();
manifest.save(&manifest_path).unwrap();
lockfile.save(&lockfile_path).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(temp.path()).unwrap().permissions();
perms.set_mode(0o444); fs::set_permissions(temp.path(), perms).unwrap();
}
let cmd = create_update_command();
let _result = cmd.execute_from_path(manifest_path).await;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(temp.path()).unwrap().permissions();
perms.set_mode(0o755); fs::set_permissions(temp.path(), perms).unwrap();
}
}
#[tokio::test]
async fn test_backup_and_rollback_logic() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let lockfile_path = temp.path().join("agpm.lock");
let backup_path = temp.path().join(".agpm").join("backups").join("agpm").join("agpm.lock");
let manifest = create_test_manifest();
let lockfile = create_test_lockfile();
manifest.save(&manifest_path).unwrap();
lockfile.save(&lockfile_path).unwrap();
if let Some(backup_dir) = backup_path.parent() {
fs::create_dir_all(backup_dir).unwrap();
}
fs::copy(&lockfile_path, &backup_path).unwrap();
let mut cmd = create_update_command();
cmd.backup = true;
let _result = cmd.execute_from_path(manifest_path).await;
assert!(backup_path.exists());
}
#[tokio::test]
async fn test_dependencies_empty_vs_specific() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let lockfile_path = temp.path().join("agpm.lock");
let manifest = create_test_manifest();
let lockfile = create_test_lockfile();
manifest.save(&manifest_path).unwrap();
lockfile.save(&lockfile_path).unwrap();
let cmd1 = create_update_command();
assert!(cmd1.dependencies.is_empty());
let mut cmd2 = create_update_command();
cmd2.dependencies = vec!["test-agent".to_string(), "another-dep".to_string()];
assert!(!cmd2.dependencies.is_empty());
assert_eq!(cmd2.dependencies.len(), 2);
}
#[tokio::test]
async fn test_progress_bar_creation_logic() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let lockfile_path = temp.path().join("agpm.lock");
let manifest = create_test_manifest();
let lockfile = create_test_lockfile();
manifest.save(&manifest_path).unwrap();
lockfile.save(&lockfile_path).unwrap();
let mut cmd1 = create_update_command();
cmd1.quiet = true;
let mut cmd2 = create_update_command();
cmd2.quiet = false;
}
#[tokio::test]
async fn test_update_output_messages() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let manifest = Manifest::new();
manifest.save(&manifest_path).unwrap();
let mut cmd = create_update_command();
cmd.quiet = false;
let _result = cmd.execute_from_path(manifest_path).await;
}
#[tokio::test]
async fn test_execute_main_workflow() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let lockfile_path = temp.path().join("agpm.lock");
let manifest_content = r"
[sources]
# No sources defined - this overrides any global sources
[agents]
# No agents
[snippets]
# No snippets
";
std::fs::write(&manifest_path, manifest_content).unwrap();
let mut cmd = create_update_command();
cmd.quiet = false;
cmd.verbose = true;
let result = cmd.execute_from_path(manifest_path).await;
assert!(result.is_ok(), "Update failed: {result:?}");
assert!(lockfile_path.exists());
}
#[test]
fn test_update_command_defaults() {
let cmd = UpdateCommand {
dependencies: vec![],
dry_run: false,
check: false,
backup: false,
verbose: false,
quiet: false,
no_progress: false,
max_parallel: None,
yes: false,
};
assert!(cmd.dependencies.is_empty());
assert!(!cmd.dry_run);
assert!(!cmd.check);
assert!(!cmd.backup);
assert!(!cmd.verbose);
assert!(!cmd.quiet);
assert!(!cmd.yes);
}
#[test]
fn test_update_command_with_all_flags() {
let cmd = UpdateCommand {
dependencies: vec!["dep1".to_string(), "dep2".to_string()],
dry_run: true,
check: true,
backup: true,
verbose: true,
quiet: true,
no_progress: true,
max_parallel: Some(4),
yes: true,
};
assert_eq!(cmd.dependencies.len(), 2);
assert!(cmd.dry_run);
assert!(cmd.check);
assert!(cmd.backup);
assert!(cmd.verbose);
assert!(cmd.quiet);
assert!(cmd.yes);
}
}