use anyhow::{Context, Result};
use clap::Args;
use colored::Colorize;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use tracing::{debug, info};
use crate::cache::Cache;
use crate::core::OperationContext;
use crate::git::parse_git_url;
use crate::lockfile::LockedResource;
use crate::manifest::{Manifest, find_manifest_with_optional};
use crate::resolver::DependencyResolver;
use crate::utils::progress::{InstallationPhase, MultiPhaseProgress};
#[derive(Debug, Args)]
#[command(about = "Check for available updates to installed dependencies", author, version)]
pub struct OutdatedCommand {
#[arg(value_name = "DEPENDENCY")]
pub dependencies: Vec<String>,
#[arg(long, default_value = "table", value_parser = ["table", "json"])]
pub format: String,
#[arg(long)]
pub check: bool,
#[arg(long)]
pub no_fetch: bool,
#[arg(long, value_name = "NUMBER")]
pub max_parallel: Option<usize>,
#[arg(skip)]
pub no_progress: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutdatedInfo {
pub name: String,
#[serde(rename = "type")]
pub resource_type: String,
pub source: String,
pub tool: String,
pub current: String,
pub latest: String, pub latest_available: String, pub constraint: String,
pub has_update: bool, pub has_major_update: bool, }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutdatedSummary {
pub total: usize,
pub outdated: usize,
pub with_updates: usize,
pub with_major_updates: usize,
pub up_to_date: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct OutdatedResult {
pub outdated: Vec<OutdatedInfo>,
pub summary: OutdatedSummary,
}
impl Default for OutdatedCommand {
fn default() -> Self {
Self {
dependencies: vec![],
format: "table".to_string(),
check: false,
no_fetch: false,
max_parallel: None,
no_progress: false,
}
}
}
impl OutdatedCommand {
pub async fn execute_with_manifest_path(self, manifest_path: Option<PathBuf>) -> Result<()> {
let manifest_path = find_manifest_with_optional(manifest_path)?;
self.execute_from_path(manifest_path).await
}
pub async fn execute_from_path(self, manifest_path: PathBuf) -> Result<()> {
info!("Checking for outdated dependencies");
let manifest = Manifest::load(&manifest_path)
.with_context(|| format!("Failed to load manifest from {manifest_path:?}"))?;
let project_dir =
manifest_path.parent().ok_or_else(|| anyhow::anyhow!("Invalid manifest path"))?;
let lockfile_path = manifest_path.with_file_name("agpm.lock");
if !lockfile_path.exists() {
return Err(anyhow::anyhow!(
"No lockfile found at {lockfile_path:?}. Run 'agpm install' first to create a lockfile."
));
}
let command_context =
crate::cli::common::CommandContext::new(manifest.clone(), project_dir.to_path_buf())?;
let lockfile = match command_context.load_lockfile_with_regeneration(true, "outdated")? {
Some(lockfile) => lockfile,
None => {
return Err(anyhow::anyhow!(
"Lockfile was invalid and has been removed. Run 'agpm install' to regenerate it first."
));
}
};
let cache = Cache::new().context("Failed to initialize cache")?;
let mut resolver = DependencyResolver::new(manifest.clone(), cache.clone())
.await
.context("Failed to create dependency resolver")?;
let operation_context = Arc::new(OperationContext::new());
resolver.set_operation_context(operation_context);
let progress = if self.no_progress {
None
} else {
Some(Arc::new(MultiPhaseProgress::new(!self.no_progress)))
};
if !self.no_fetch {
let deps: Vec<(String, crate::manifest::ResourceDependency)> = manifest
.all_dependencies()
.into_iter()
.map(|(name, dep)| (name.to_string(), dep.clone()))
.collect();
resolver
.pre_sync_sources(&deps, progress.clone())
.await
.context("Failed to sync sources")?;
}
if let Some(ref progress) = progress {
progress
.start_phase(InstallationPhase::ResolvingDependencies, Some("Checking versions"));
}
let deps_to_check = if self.dependencies.is_empty() {
None
} else {
Some(self.dependencies.clone())
};
let updated_lockfile = resolver.update(&lockfile, deps_to_check.clone(), None).await?;
let mut outdated_deps = Vec::new();
for new_entry in updated_lockfile.all_resources() {
if !self.dependencies.is_empty() && !self.dependencies.contains(&new_entry.name) {
continue;
}
if let Some((_, old_entry)) =
crate::core::ResourceIterator::find_resource_by_name_and_source(
&lockfile,
new_entry.display_name(),
new_entry.source.as_deref(),
)
{
if let Some(outdated_info) = self
.analyze_update(
&new_entry.name,
old_entry,
new_entry,
&manifest,
&cache,
&resolver,
)
.await?
{
if outdated_info.has_update || outdated_info.has_major_update {
outdated_deps.push(outdated_info);
}
}
}
}
let summary = self.calculate_summary(&outdated_deps, lockfile.all_resources().len());
self.display_results(&outdated_deps, &summary)?;
if self.check && outdated_deps.iter().any(|d| d.has_update || d.has_major_update) {
std::process::exit(1);
}
Ok(())
}
async fn analyze_update(
&self,
name: &str,
old_entry: &LockedResource,
new_entry: &LockedResource,
manifest: &Manifest,
cache: &Cache,
resolver: &DependencyResolver,
) -> Result<Option<OutdatedInfo>> {
let dep = manifest.find_dependency(name);
if dep.is_none() {
debug!("Dependency {} not found in manifest", name);
return Ok(None);
}
let dep = dep.unwrap();
if dep.is_local() {
debug!("Skipping local dependency: {}", name);
return Ok(None);
}
let source_name =
dep.get_source().ok_or_else(|| anyhow::anyhow!("Dependency {name} has no source"))?;
let constraint_str = dep
.get_version()
.map_or_else(|| "latest".to_string(), std::string::ToString::to_string);
let source_url = manifest
.sources
.get(source_name)
.ok_or_else(|| anyhow::anyhow!("Source {source_name} not found in manifest"))?;
let (owner, repo) = parse_git_url(source_url)
.unwrap_or_else(|_| ("unknown".to_string(), source_name.to_string()));
let bare_repo_path =
cache.get_cache_location().join("sources").join(format!("{owner}_{repo}.git"));
if !bare_repo_path.exists() {
debug!("Repository not found in cache at {:?}, skipping", bare_repo_path);
return Ok(None);
}
let available_versions = resolver.get_available_versions(&bare_repo_path).await?;
let mut semver_versions: Vec<semver::Version> = available_versions
.iter()
.filter_map(|v| {
let version_str = v.trim_start_matches('v');
semver::Version::parse(version_str).ok()
})
.collect();
semver_versions.sort_by(|a, b| b.cmp(a));
if semver_versions.is_empty() {
debug!("No semantic versions found for {}", name);
return Ok(None);
}
let resource_type = if manifest.agents.contains_key(name) {
"agent"
} else if manifest.snippets.contains_key(name) {
"snippet"
} else if manifest.commands.contains_key(name) {
"command"
} else if manifest.scripts.contains_key(name) {
"script"
} else if manifest.hooks.contains_key(name) {
"hook"
} else if manifest.mcp_servers.contains_key(name) {
"mcp-server"
} else {
"unknown"
};
let current_version = old_entry.version.clone().unwrap_or_else(|| "unknown".to_string());
let latest_compatible = new_entry.version.clone().unwrap_or_else(|| "unknown".to_string());
let latest_available = if old_entry.version.as_ref().is_some_and(|s| s.starts_with('v')) {
format!("v{}", semver_versions[0])
} else {
semver_versions[0].to_string()
};
let has_update = old_entry.resolved_commit != new_entry.resolved_commit;
let has_major_update = latest_compatible != latest_available;
Ok(Some(OutdatedInfo {
name: name.to_string(),
resource_type: resource_type.to_string(),
source: source_name.to_string(),
tool: old_entry.tool.clone().unwrap_or_else(|| "claude-code".to_string()),
current: current_version,
latest: latest_compatible,
latest_available,
constraint: constraint_str,
has_update,
has_major_update,
}))
}
fn calculate_summary(&self, outdated: &[OutdatedInfo], total: usize) -> OutdatedSummary {
let with_updates = outdated.iter().filter(|d| d.has_update).count();
let with_major_updates = outdated.iter().filter(|d| d.has_major_update).count();
let outdated_count = outdated.len();
let up_to_date = total - outdated_count;
OutdatedSummary {
total,
outdated: outdated_count,
with_updates,
with_major_updates,
up_to_date,
}
}
fn display_results(&self, outdated: &[OutdatedInfo], summary: &OutdatedSummary) -> Result<()> {
match self.format.as_str() {
"json" => self.display_json(outdated, summary),
_ => self.display_table(outdated, summary),
}
}
fn display_json(&self, outdated: &[OutdatedInfo], summary: &OutdatedSummary) -> Result<()> {
let result = OutdatedResult {
outdated: outdated.to_vec(),
summary: summary.clone(),
};
println!("{}", serde_json::to_string_pretty(&result)?);
Ok(())
}
fn display_table(&self, outdated: &[OutdatedInfo], summary: &OutdatedSummary) -> Result<()> {
if outdated.is_empty() {
println!("{}", "All dependencies are up to date!".green());
return Ok(());
}
println!(
"\n{:<30} {:<12} {:<12} {:<12} {:<15}",
"Package".bold(),
"Current".bold(),
"Latest".bold(),
"Available".bold(),
"Tool".bold()
);
println!("{}", "─".repeat(85));
for dep in outdated {
let name = if dep.has_update || dep.has_major_update {
dep.name.yellow()
} else {
dep.name.normal()
};
let latest = if dep.has_update {
dep.latest.green()
} else {
dep.latest.normal()
};
let available = if dep.has_major_update {
dep.latest_available.cyan()
} else {
dep.latest_available.normal()
};
println!(
"{:<30} {:<12} {:<12} {:<12} {:<15}",
name,
dep.current,
latest,
available,
dep.tool.bright_black()
);
}
println!("\n{}", "Summary:".bold());
println!(" Total dependencies: {}", summary.total);
if summary.with_updates > 0 {
println!(
" {} dependencies have compatible updates",
summary.with_updates.to_string().green()
);
}
if summary.with_major_updates > 0 {
println!(
" {} dependencies have major updates available",
summary.with_major_updates.to_string().cyan()
);
}
println!(" {} dependencies are up to date", summary.up_to_date.to_string().green());
Ok(())
}
}