use anyhow::{Context, Result};
use clap::Args;
use colored::Colorize;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tracing::{debug, info};
use crate::cache::Cache;
use crate::git::parse_git_url;
use crate::lockfile::{LockFile, LockedResource};
use crate::manifest::{Manifest, find_manifest_with_optional};
use crate::resolver::DependencyResolver;
use crate::utils::progress::{InstallationPhase, MultiPhaseProgress};
use crate::version::constraints::VersionConstraint;
#[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 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 lockfile_path = manifest_path.with_file_name("ccpm.lock");
if !lockfile_path.exists() {
return Err(anyhow::anyhow!(
"No lockfile found at {:?}. Run 'ccpm install' first to create a lockfile.",
lockfile_path
));
}
let lockfile = LockFile::load(&lockfile_path)
.with_context(|| format!("Failed to load lockfile from {:?}", lockfile_path))?;
let cache = Cache::new().context("Failed to initialize cache")?;
let mut resolver = DependencyResolver::new(manifest.clone(), cache.clone())
.context("Failed to create dependency resolver")?;
let progress = if !self.no_progress {
Some(MultiPhaseProgress::new(!self.no_progress))
} else {
None
};
if !self.no_fetch {
if let Some(ref progress) = progress {
progress.start_phase(InstallationPhase::SyncingSources, Some("Syncing sources"));
}
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)
.await
.context("Failed to sync sources")?;
}
if let Some(ref progress) = progress {
progress.start_phase(
InstallationPhase::ResolvingDependencies,
Some("Checking versions"),
);
}
let mut outdated_deps = Vec::new();
for locked in lockfile.all_resources() {
let name = &locked.name;
if !self.dependencies.is_empty() && !self.dependencies.contains(name) {
continue;
}
debug!("Checking dependency: {}", name);
if let Some(outdated_info) = self
.check_dependency(name, locked, &manifest, &cache, &resolver)
.await?
{
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 check_dependency(
&self,
name: &str,
locked: &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 {} has no source", name))?;
let constraint_str = dep
.get_version()
.map(|v| v.to_string())
.unwrap_or_else(|| "latest".to_string());
let constraint = VersionConstraint::parse(&constraint_str)?;
let source_url = manifest
.sources
.get(source_name)
.ok_or_else(|| anyhow::anyhow!("Source {} not found in manifest", source_name))?;
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!("{}_{}.git", owner, repo));
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 version_str = locked
.version
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Dependency {} has no version in lockfile", name))?;
let current_str = version_str.trim_start_matches('v');
let current_version = semver::Version::parse(current_str).with_context(|| {
format!(
"Failed to parse current version {} for {}",
version_str, name
)
})?;
let latest_compatible = semver_versions
.iter()
.find(|v| constraint.matches(v))
.cloned()
.unwrap_or_else(|| current_version.clone());
let latest_available = semver_versions[0].clone();
let has_update = latest_compatible > current_version;
let has_major_update = latest_available > latest_compatible;
let format_version = |v: &semver::Version| {
if locked
.version
.as_ref()
.map(|s| s.starts_with('v'))
.unwrap_or(false)
{
format!("v{}", v)
} else {
v.to_string()
}
};
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"
};
Ok(Some(OutdatedInfo {
name: name.to_string(),
resource_type: resource_type.to_string(),
source: source_name.to_string(),
current: locked
.version
.clone()
.unwrap_or_else(|| "unknown".to_string()),
latest: format_version(&latest_compatible),
latest_available: format_version(&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}",
"Package".bold(),
"Current".bold(),
"Latest".bold(),
"Available".bold()
);
println!("{}", "─".repeat(70));
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}",
name, dep.current, latest, available
);
}
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(())
}
}