use std::path::Path;
use semver::Version;
use serde::Serialize;
use crate::error::Result;
use crate::git;
use crate::lockfile;
use crate::manifest::CapabilityType;
use crate::oci::{self, OciTransferOptions};
use crate::release;
use super::{home_dir_opt, render_table, short_sha, style_outdated_status};
struct OutdatedRow {
id: String,
capability_type: CapabilityType,
target: String,
version_scheme: lockfile::VersionScheme,
from_git: bool,
current: String,
latest: String,
status: String,
change: Option<String>,
latest_release: Option<String>,
}
#[derive(Serialize)]
struct JsonOutdatedRow<'a> {
id: &'a str,
#[serde(rename = "type")]
capability_type: CapabilityType,
target: &'a str,
version_scheme: lockfile::VersionScheme,
current: &'a str,
latest: Option<&'a str>,
status: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
change: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
latest_release: Option<&'a str>,
}
impl OutdatedRow {
fn as_json(&self) -> JsonOutdatedRow<'_> {
JsonOutdatedRow {
id: &self.id,
capability_type: self.capability_type,
target: &self.target,
version_scheme: self.version_scheme,
current: &self.current,
latest: (self.latest != UNRESOLVED).then_some(self.latest.as_str()),
status: &self.status,
change: self.change.as_deref(),
latest_release: self.latest_release.as_deref(),
}
}
}
struct Verdict {
current: String,
latest: String,
status: String,
latest_release: Option<String>,
}
impl From<(String, String, String)> for Verdict {
fn from((current, latest, status): (String, String, String)) -> Self {
Verdict {
current,
latest,
status,
latest_release: None,
}
}
}
const UNRESOLVED: &str = "—";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PackVersionStatus {
Newer(String),
Current,
Unknown,
}
pub(crate) fn compare_pack_versions(installed: &str, tags: &[String]) -> PackVersionStatus {
let Ok(installed_version) = Version::parse(installed) else {
return PackVersionStatus::Unknown;
};
match tags.iter().filter_map(|tag| Version::parse(tag).ok()).max() {
None => PackVersionStatus::Unknown,
Some(latest) if latest > installed_version => PackVersionStatus::Newer(latest.to_string()),
Some(_) => PackVersionStatus::Current,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PackTagIntegrity {
Matches,
Repointed { live_digest: String },
Missing,
Unavailable(String),
}
pub(crate) fn verify_pack_tag(
pack: &lockfile::PackProvenance,
registry: &str,
options: &OciTransferOptions,
) -> PackTagIntegrity {
let reference = format!("{registry}:{}", pack.version);
match super::block_on_oci(oci::resolve_pack_tag(&reference, options)) {
Err(error) => PackTagIntegrity::Unavailable(error.to_string()),
Ok(None) => PackTagIntegrity::Missing,
Ok(Some(resolved)) => {
if resolved.artifact_digest == format!("sha256:{}", pack.digest) {
PackTagIntegrity::Matches
} else {
PackTagIntegrity::Repointed {
live_digest: resolved.artifact_digest,
}
}
}
}
}
fn pick_latest_pack_version(installed: &str, tags: &[String]) -> (String, String, String) {
let current = installed.to_string();
match compare_pack_versions(installed, tags) {
PackVersionStatus::Unknown => (current, UNRESOLVED.to_string(), "not checked".to_string()),
PackVersionStatus::Newer(latest) => (current, latest, "outdated".to_string()),
PackVersionStatus::Current => (current.clone(), current, "up to date".to_string()),
}
}
fn pack_row(
installed: &str,
tags: &[String],
integrity: &PackTagIntegrity,
) -> (String, String, String) {
let (current, latest, status) = pick_latest_pack_version(installed, tags);
let status = match integrity {
PackTagIntegrity::Matches => status,
PackTagIntegrity::Repointed { .. } => "repointed".to_string(),
PackTagIntegrity::Missing => "tag missing".to_string(),
PackTagIntegrity::Unavailable(_) => "error".to_string(),
};
(current, latest, status)
}
#[derive(Default)]
pub(crate) struct PackCheckCache {
rows: std::collections::BTreeMap<String, (String, String, String)>,
}
fn classify_pack(
pack: &lockfile::PackProvenance,
registry: &str,
options: &OciTransferOptions,
cache: &mut PackCheckCache,
) -> (String, String, String) {
let key = format!("{registry}:{}@{}", pack.version, pack.digest);
if let Some(row) = cache.rows.get(&key) {
return row.clone();
}
let row = match super::block_on_oci(oci::list_pack_versions(registry, options)) {
Err(_) => (
pack.version.clone(),
"unavailable".to_string(),
"error".to_string(),
),
Ok(tags) => {
let integrity = verify_pack_tag(pack, registry, options);
pack_row(&pack.version, &tags, &integrity)
}
};
cache.rows.insert(key, row.clone());
row
}
fn classify(
id: &str,
entry: &lockfile::CapabilityLockEntry,
oci_options: &OciTransferOptions,
cache: &mut PackCheckCache,
) -> Verdict {
let git = match &entry.source {
lockfile::CapabilitySource::Pack(pack) => {
return match &pack.registry {
Some(registry) => classify_pack(pack, registry, oci_options, cache),
None => (
entry.version.clone(),
UNRESOLVED.to_string(),
"not checked".to_string(),
),
}
.into();
}
lockfile::CapabilitySource::Local(_) => {
return (
entry.version.clone(),
UNRESOLVED.to_string(),
"not checked".to_string(),
)
.into();
}
lockfile::CapabilitySource::Catalog(catalog) if catalog.registry.is_some() => {
let registry = catalog.registry.as_deref().expect("registry checked above");
let (latest, status) =
match super::block_on_oci(crate::registry::fetch(registry, &catalog.id)) {
Ok(Some(server)) if server.version == entry.version => {
(server.version, "up to date")
}
Ok(Some(server)) => (server.version, "outdated"),
Ok(None) => ("unpublished".to_string(), "error"),
Err(_) => ("unavailable".to_string(), "error"),
};
return (entry.version.clone(), latest, status.to_string()).into();
}
lockfile::CapabilitySource::Catalog(catalog) => {
let (latest, status) = match crate::catalog::lookup(&catalog.id) {
Ok(Some(manifest)) if manifest.version == entry.version => {
(manifest.version, "up to date")
}
Ok(Some(manifest)) => (manifest.version, "outdated"),
_ => ("unavailable".to_string(), "error"),
};
return (entry.version.clone(), latest, status.to_string()).into();
}
lockfile::CapabilitySource::Git(git) if git.tag.is_some() => {
return classify_release(id, entry, git);
}
lockfile::CapabilitySource::Git(git) => git,
};
classify_commit(id, entry, git)
}
fn classify_commit(
id: &str,
entry: &lockfile::CapabilityLockEntry,
git: &lockfile::GitSource,
) -> Verdict {
let declared = entry.version_scheme == lockfile::VersionScheme::Declared;
let current = if declared {
entry.version.clone()
} else {
short_sha(&entry.version).to_string()
};
let unavailable = |current: String, latest_release: Option<String>| Verdict {
current,
latest: "unavailable".to_string(),
status: "error".to_string(),
latest_release,
};
let refs = match git::list_remote_refs(&git.url) {
Ok(refs) => refs,
Err(_) => return unavailable(current, None),
};
let releases = release::release_tags(refs.tags.iter().map(|tag| tag.name.as_str()), id);
let latest_release = release::latest_release(&releases).map(|tag| tag.version.to_string());
let latest_sha = match refs.head.or_else(|| clone_head(&git.url)) {
Some(sha) => sha,
None => return unavailable(current, latest_release),
};
if latest_sha == git.git_ref {
return Verdict {
latest: current.clone(),
current,
status: "up to date".to_string(),
latest_release,
};
}
if !declared {
return Verdict {
current,
latest: short_sha(&latest_sha).to_string(),
status: "outdated".to_string(),
latest_release,
};
}
let latest_declared =
git::clone_to_temp(&git.url, None)
.ok()
.and_then(|(_guard, checkout, _)| {
git::discover_capability(&checkout, &git.path, entry.capability_type)
.ok()
.and_then(|dir| crate::manifest::declared_version(&dir))
});
let latest = latest_declared.unwrap_or_else(|| short_sha(&latest_sha).to_string());
let status = match (Version::parse(&entry.version), Version::parse(&latest)) {
(Ok(from), Ok(to)) if to != from => {
format!("outdated ({})", release::change_kind(&from, &to))
}
_ => "outdated".to_string(),
};
Verdict {
current,
latest,
status,
latest_release,
}
}
fn clone_head(url: &str) -> Option<String> {
let (_guard, checkout, _) = git::clone_to_temp(url, None).ok()?;
git::resolve_ref(&checkout).ok()
}
fn classify_release(
id: &str,
entry: &lockfile::CapabilityLockEntry,
git: &lockfile::GitSource,
) -> Verdict {
let current = entry.version.clone();
let tags = match git::list_remote_tags(&git.url) {
Ok(tags) => tags,
Err(_) => return (current, "unavailable".to_string(), "error".to_string()).into(),
};
let releases = release::release_tags(tags.iter().map(|tag| tag.name.as_str()), id);
let latest = release::latest_release(&releases).map(|latest| latest.version.to_string());
let verdict = |current: String, latest: String, status: &str| Verdict {
current,
latest_release: (latest != UNRESOLVED).then(|| latest.clone()),
latest,
status: status.to_string(),
};
let integrity = git
.tag
.as_deref()
.map(|tag| release::tag_integrity(tag, &git.git_ref, &tags));
match integrity {
Some(release::TagIntegrity::Repointed { .. }) => {
return verdict(
current,
latest.unwrap_or_else(|| UNRESOLVED.to_string()),
"repointed",
);
}
Some(release::TagIntegrity::Missing) => {
return verdict(
current,
latest.unwrap_or_else(|| UNRESOLVED.to_string()),
"tag missing",
);
}
Some(release::TagIntegrity::Matches) | None => {}
}
let Some(latest) = release::latest_release(&releases) else {
return verdict(current, UNRESOLVED.to_string(), "tag missing");
};
match Version::parse(&entry.version) {
Ok(installed) if latest.version > installed => verdict(
current,
latest.version.to_string(),
&format!(
"outdated ({})",
release::change_kind(&installed, &latest.version)
),
),
Ok(_) => verdict(current, latest.version.to_string(), "up to date"),
Err(_) => verdict(current, latest.version.to_string(), "not checked"),
}
}
fn split_change(status: String) -> (String, Option<String>) {
match status
.strip_prefix("outdated (")
.and_then(|rest| rest.strip_suffix(')'))
{
Some(kind) => ("outdated".to_string(), Some(kind.to_string())),
None => (status, None),
}
}
fn collect_rows(
lf: &lockfile::Lockfile,
oci_options: &OciTransferOptions,
cache: &mut PackCheckCache,
rows: &mut Vec<OutdatedRow>,
) {
for (id, entry) in &lf.capabilities {
let verdict = classify(id, entry, oci_options, cache);
let (status, change) = split_change(verdict.status);
for target_id in entry.targets.keys() {
rows.push(OutdatedRow {
id: id.clone(),
capability_type: entry.capability_type,
target: target_id.clone(),
version_scheme: entry.version_scheme,
from_git: matches!(entry.source, lockfile::CapabilitySource::Git(_)),
current: verdict.current.clone(),
latest: verdict.latest.clone(),
status: status.clone(),
change: change.clone(),
latest_release: verdict.latest_release.clone(),
});
}
}
}
fn release_hints(rows: &[OutdatedRow]) -> Vec<String> {
let mut seen = std::collections::BTreeSet::new();
rows.iter()
.filter(|row| row.from_git && row.status != "error")
.filter(|row| row.version_scheme != lockfile::VersionScheme::Semver)
.filter_map(|row| {
let release = row.latest_release.as_deref()?;
seen.insert(row.id.clone()).then(|| {
let requirement = match Version::parse(release) {
Ok(version) => format!("^{}.{}", version.major, version.minor),
Err(_) => release.to_string(),
};
format!(
"note: {} follows a commit, but its repository publishes releases (newest {release}); 'tuff update {}@{requirement}' pins one",
row.id, row.id
)
})
})
.collect()
}
pub fn cmd_outdated(
repo_root: &Path,
plain_http: bool,
ca_files: &[std::path::PathBuf],
json: bool,
) -> Result<()> {
let oci_options = OciTransferOptions {
plain_http,
ca_files: ca_files.to_vec(),
};
let mut rows: Vec<OutdatedRow> = Vec::new();
let mut cache = PackCheckCache::default();
if let Some(lf) = lockfile::read_optional_lockfile(&lockfile::project_lockfile(repo_root))? {
collect_rows(&lf, &oci_options, &mut cache, &mut rows);
}
if let Some(home) = home_dir_opt() {
let lock_path = crate::paths::global_lockfile(&home);
if let Some(lf) = lockfile::read_optional_lockfile(&lock_path)? {
collect_rows(&lf, &oci_options, &mut cache, &mut rows);
}
}
if rows.is_empty() {
if json {
println!("[]");
} else {
println!("no capabilities installed");
}
return Ok(());
}
rows.sort_by(|a, b| a.id.cmp(&b.id));
let hints = release_hints(&rows);
if json {
let rows: Vec<JsonOutdatedRow<'_>> = rows.iter().map(OutdatedRow::as_json).collect();
println!("{}", serde_json::to_string_pretty(&rows)?);
for hint in hints {
eprintln!("{hint}");
}
return Ok(());
}
let table_rows: Vec<Vec<String>> = rows
.into_iter()
.map(|r| {
let status = match &r.change {
Some(change) => format!("{} ({change})", style_outdated_status(&r.status)),
None => style_outdated_status(&r.status),
};
let current = if r.from_git && r.version_scheme == lockfile::VersionScheme::Declared {
format!("{} (declared)", r.current)
} else {
r.current.clone()
};
let row = OutdatedRow {
status,
current,
..r
};
vec![
row.id,
row.capability_type.to_string(),
row.target,
row.current,
row.latest,
row.status,
]
})
.collect();
println!(
"{}",
render_table(
&["ID", "TYPE", "AGENT", "CURRENT", "LATEST", "STATUS"],
&table_rows
)
);
for hint in hints {
eprintln!("{hint}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn entry(source: lockfile::CapabilitySource) -> lockfile::CapabilityLockEntry {
lockfile::CapabilityLockEntry {
capability_type: CapabilityType::Skill,
version: "1.0.0".to_string(),
version_scheme: lockfile::VersionScheme::Declared,
description: String::new(),
source,
targets: BTreeMap::new(),
implementation: None,
parameters: None,
workflow: None,
server: None,
}
}
#[test]
fn a_capability_with_no_source_is_reported_as_unchecked() {
let Verdict {
current,
latest,
status,
latest_release,
} = classify(
"example",
&entry(lockfile::CapabilitySource::local(
"agent-capabilities/example",
)),
&OciTransferOptions::default(),
&mut PackCheckCache::default(),
);
assert_eq!(latest_release, None);
assert_eq!(status, "not checked");
assert_ne!(
status, "up to date",
"an unchecked capability must not read as current"
);
assert_eq!(latest, "—");
assert_eq!(current, "1.0.0");
}
fn row(
id: &str,
scheme: lockfile::VersionScheme,
status: &str,
release: Option<&str>,
) -> OutdatedRow {
OutdatedRow {
id: id.to_string(),
capability_type: CapabilityType::Skill,
target: "open-agents".to_string(),
version_scheme: scheme,
from_git: true,
current: "abc1234".to_string(),
latest: "def5678".to_string(),
status: status.to_string(),
change: None,
latest_release: release.map(str::to_string),
}
}
#[test]
fn the_release_note_names_untagged_git_installs_once_each() {
let mut two_targets = row(
"commit-skill",
lockfile::VersionScheme::Sha,
"outdated",
Some("1.4.0"),
);
two_targets.target = "claude".to_string();
let rows = vec![
row(
"commit-skill",
lockfile::VersionScheme::Sha,
"outdated",
Some("1.4.0"),
),
two_targets,
row(
"declared-skill",
lockfile::VersionScheme::Declared,
"up to date",
Some("0.3.1"),
),
row(
"pinned-skill",
lockfile::VersionScheme::Semver,
"outdated",
Some("2.0.0"),
),
row(
"failed-skill",
lockfile::VersionScheme::Sha,
"error",
Some("1.0.0"),
),
row(
"untagged-skill",
lockfile::VersionScheme::Sha,
"outdated",
None,
),
];
let hints = release_hints(&rows);
assert_eq!(
hints,
vec![
"note: commit-skill follows a commit, but its repository publishes releases (newest 1.4.0); 'tuff update commit-skill@^1.4' pins one",
"note: declared-skill follows a commit, but its repository publishes releases (newest 0.3.1); 'tuff update declared-skill@^0.3' pins one",
]
);
}
#[test]
fn a_newer_semver_tag_is_reported_as_outdated() {
let tags = vec![
"1.0.0".to_string(),
"1.2.0".to_string(),
"0.9.0".to_string(),
];
let (current, latest, status) = pick_latest_pack_version("1.0.0", &tags);
assert_eq!(status, "outdated");
assert_eq!(current, "1.0.0");
assert_eq!(latest, "1.2.0");
}
#[test]
fn the_installed_tag_being_the_newest_reads_as_up_to_date() {
let tags = vec!["1.0.0".to_string(), "0.9.0".to_string()];
let (_, _, status) = pick_latest_pack_version("1.0.0", &tags);
assert_eq!(status, "up to date");
}
#[test]
fn version_ordering_is_semantic_not_lexicographic() {
let tags = vec!["1.10.0".to_string()];
let (_, latest, status) = pick_latest_pack_version("1.9.0", &tags);
assert_eq!(status, "outdated");
assert_eq!(latest, "1.10.0");
}
#[test]
fn tags_that_do_not_parse_as_semver_are_excluded_not_guessed_at() {
let tags = vec!["latest".to_string(), "nightly".to_string()];
let (_, latest, status) = pick_latest_pack_version("1.0.0", &tags);
assert_eq!(status, "not checked");
assert_eq!(latest, "—");
}
#[test]
fn an_unparsable_installed_version_is_reported_as_unchecked() {
let tags = vec!["1.0.0".to_string()];
let (current, latest, status) = pick_latest_pack_version("release-42", &tags);
assert_eq!(status, "not checked");
assert_eq!(latest, "—");
assert_eq!(current, "release-42");
}
#[test]
fn a_repointed_tag_is_reported_even_when_a_newer_version_exists() {
let tags = vec!["1.0.0".to_string(), "1.2.0".to_string()];
let integrity = PackTagIntegrity::Repointed {
live_digest: "sha256:beef".to_string(),
};
let (current, latest, status) = pack_row("1.0.0", &tags, &integrity);
assert_eq!(status, "repointed");
assert_eq!(current, "1.0.0");
assert_eq!(latest, "1.2.0");
}
#[test]
fn a_repointed_tag_never_reads_as_up_to_date() {
let tags = vec!["1.0.0".to_string()];
let integrity = PackTagIntegrity::Repointed {
live_digest: "sha256:beef".to_string(),
};
let (_, _, status) = pack_row("1.0.0", &tags, &integrity);
assert_eq!(status, "repointed");
}
#[test]
fn a_deleted_tag_is_its_own_status() {
let tags = vec!["1.2.0".to_string()];
let (_, latest, status) = pack_row("1.0.0", &tags, &PackTagIntegrity::Missing);
assert_eq!(status, "tag missing");
assert_eq!(latest, "1.2.0");
}
#[test]
fn a_matching_tag_leaves_the_version_verdict_alone() {
let tags = vec!["1.0.0".to_string(), "1.2.0".to_string()];
let (_, _, status) = pack_row("1.0.0", &tags, &PackTagIntegrity::Matches);
assert_eq!(status, "outdated");
let tags = vec!["1.0.0".to_string()];
let (_, _, status) = pack_row("1.0.0", &tags, &PackTagIntegrity::Matches);
assert_eq!(status, "up to date");
}
}