use cargo_metadata::{
Dependency, DependencyKind, Metadata, MetadataCommand, Node, Package, PackageId,
};
use semver::Version;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use super::run_with::run_with_output;
use crate::core::analyzers::audit::run_audit;
use crate::core::analyzers::crates_io::{CratesIoClient, VersionInfo};
use crate::core::error::{ErrorCode, Result, UpkeepError};
use crate::core::output::{
print_json, AuditSummary, DependencyType, DepsOutput, DepsSecurityOutput, DepsSecurityPackage,
DepsSecurityVulnerability, OutdatedPackage, Severity, SkipReason, SkippedDependency,
UpdateType,
};
pub async fn run(json: bool, include_security: bool) -> Result<()> {
run_with_output(json, analyze(include_security), print_json, |output| {
println!("{output}");
Ok(())
})
.await
}
pub async fn analyze(include_security: bool) -> Result<DepsOutput> {
let metadata = load_metadata()?;
let is_workspace = metadata.root_package().is_none() || metadata.workspace_members.len() > 1;
let resolve = get_resolve(&metadata)?;
let packages_by_id = build_packages_map(&metadata);
let member_packages = workspace_packages(&metadata, &packages_by_id)?;
let member_nodes = find_member_nodes(resolve, &member_packages)?;
let member_versions = build_resolved_versions(&member_packages, &member_nodes, &packages_by_id);
let (member_names, skipped_members) = compute_workspace_info(&metadata, &packages_by_id);
let mut skipped = SkippedCollector::new();
let (dependency_names, edges) = partition_dependencies(&member_packages, &mut skipped);
let total_dependencies = member_packages
.iter()
.map(|pkg| pkg.dependencies.len())
.sum();
let dependencies = resolve_dependencies(edges, &member_versions, &mut skipped);
let security = if include_security {
Some(fetch_security(&dependencies).await?)
} else {
None
};
let (latest_versions, registry_available, mut warnings) =
fetch_latest_versions(&dependency_names).await;
if include_security {
warnings.push(
"security scan uses Cargo.lock and reports direct workspace dependencies only"
.to_string(),
);
}
let (packages, major, minor, patch) = process_dependencies(
dependencies,
&latest_versions,
registry_available,
&mut skipped,
)?;
let skipped_packages = skipped.into_vec();
Ok(DepsOutput {
total: total_dependencies,
outdated: packages.len(),
major,
minor,
patch,
packages,
skipped: skipped_packages.len(),
skipped_packages,
warnings,
security,
workspace: is_workspace,
members: member_names,
skipped_members,
})
}
fn load_metadata() -> Result<Metadata> {
MetadataCommand::new().exec().map_err(|err| {
UpkeepError::context(ErrorCode::Metadata, "failed to load cargo metadata", err)
})
}
fn get_resolve(metadata: &Metadata) -> Result<&cargo_metadata::Resolve> {
metadata.resolve.as_ref().ok_or_else(|| {
UpkeepError::message(ErrorCode::InvalidData, "metadata missing resolve data")
})
}
fn build_packages_map(metadata: &Metadata) -> HashMap<PackageId, &Package> {
metadata
.packages
.iter()
.map(|pkg| (pkg.id.clone(), pkg))
.collect()
}
fn workspace_packages<'a>(
metadata: &'a Metadata,
packages_by_id: &'a HashMap<PackageId, &'a Package>,
) -> Result<Vec<&'a Package>> {
let mut members = Vec::new();
for id in &metadata.workspace_members {
let package = packages_by_id.get(id).ok_or_else(|| {
UpkeepError::message(
ErrorCode::InvalidData,
format!("workspace member {} missing from packages", id),
)
})?;
members.push(*package);
}
if members.is_empty() {
if let Some(root) = metadata.root_package() {
members.push(root);
}
}
if members.is_empty() {
return Err(UpkeepError::message(
ErrorCode::InvalidData,
"no workspace members found",
));
}
Ok(members)
}
fn find_member_nodes<'a>(
resolve: &'a cargo_metadata::Resolve,
members: &[&Package],
) -> Result<Vec<&'a Node>> {
let mut nodes = Vec::new();
for member in members {
let node = resolve
.nodes
.iter()
.find(|node| node.id == member.id)
.ok_or_else(|| {
UpkeepError::message(
ErrorCode::InvalidData,
format!(
"workspace member {} not found in resolve graph",
member.name
),
)
})?;
nodes.push(node);
}
Ok(nodes)
}
#[derive(Debug, Default)]
struct MemberVersions {
member: String,
by_dep_name: HashMap<String, Version>,
by_package_name: HashMap<String, Version>,
}
fn build_resolved_versions(
members: &[&Package],
nodes: &[&Node],
packages_by_id: &HashMap<PackageId, &Package>,
) -> Vec<MemberVersions> {
debug_assert_eq!(members.len(), nodes.len());
members
.iter()
.zip(nodes.iter())
.map(|(member, node)| {
let mut versions = MemberVersions {
member: member.name.to_string(),
..MemberVersions::default()
};
for dep in &node.deps {
if let Some(package) = packages_by_id.get(&dep.pkg) {
versions
.by_dep_name
.insert(dep.name.to_string(), package.version.clone());
versions
.by_package_name
.entry(package.name.to_string())
.or_insert_with(|| package.version.clone());
}
}
versions
})
.collect()
}
fn compute_workspace_info(
metadata: &Metadata,
packages_by_id: &HashMap<PackageId, &Package>,
) -> (Vec<String>, Vec<String>) {
let member_names: Vec<String> = metadata
.workspace_members
.iter()
.filter_map(|id| packages_by_id.get(id).map(|pkg| pkg.name.to_string()))
.collect();
(member_names, Vec::new())
}
#[derive(Debug, Default)]
struct SkippedCollector {
seen: HashSet<(String, Option<String>, String, SkipReason)>,
items: Vec<SkippedDependency>,
}
impl SkippedCollector {
fn new() -> Self {
Self::default()
}
fn push(&mut self, skipped: SkippedDependency) {
let key = (
skipped.name.clone(),
skipped.alias.clone(),
skipped.required.clone(),
skipped.reason,
);
if self.seen.insert(key) {
self.items.push(skipped);
}
}
fn into_vec(self) -> Vec<SkippedDependency> {
self.items
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DependencyEdge {
name: String,
alias: Option<String>,
required: String,
source: Option<String>,
target: Option<String>,
optional: bool,
dependency_type: DependencyType,
member_index: usize,
}
fn partition_dependencies(
members: &[&Package],
skipped: &mut SkippedCollector,
) -> (HashSet<String>, Vec<DependencyEdge>) {
let mut dependency_names = HashSet::new();
let mut edges = Vec::new();
for (member_index, member) in members.iter().enumerate() {
for dep in &member.dependencies {
let edge = build_edge(dep, member_index);
if !is_registry_source(edge.source.as_ref()) {
skipped.push(SkippedDependency {
name: edge.name,
alias: edge.alias,
required: edge.required,
reason: SkipReason::NonRegistry,
dependency_type: edge.dependency_type,
source: edge.source,
target: edge.target,
});
continue;
}
dependency_names.insert(edge.name.clone());
edges.push(edge);
}
}
(dependency_names, edges)
}
fn build_edge(dep: &Dependency, member_index: usize) -> DependencyEdge {
DependencyEdge {
name: dep.name.clone(),
alias: dep.rename.clone(),
required: dep.req.to_string(),
source: dep.source.as_ref().map(ToString::to_string),
target: dep.target.as_ref().map(ToString::to_string),
optional: dep.optional,
dependency_type: convert_dependency_kind(dep.kind),
member_index,
}
}
fn convert_dependency_kind(kind: DependencyKind) -> DependencyType {
match kind {
DependencyKind::Development => DependencyType::Dev,
DependencyKind::Build => DependencyType::Build,
_ => DependencyType::Normal,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ResolvedDependency {
name: String,
alias: Option<String>,
current: Version,
required: String,
source: Option<String>,
target: Option<String>,
dependency_type: DependencyType,
members: Vec<String>,
}
fn dependency_type_rank(dependency_type: DependencyType) -> u8 {
match dependency_type {
DependencyType::Normal => 2,
DependencyType::Build => 1,
DependencyType::Dev => 0,
}
}
fn merge_dependency_type(current: DependencyType, other: DependencyType) -> DependencyType {
if dependency_type_rank(other) > dependency_type_rank(current) {
other
} else {
current
}
}
type RepresentativeKey = (String, String, Option<String>);
#[derive(Debug)]
struct DependencyGroup {
representative: RepresentativeKey,
source: Option<String>,
target: Option<String>,
dependency_type: DependencyType,
members: BTreeSet<String>,
}
fn resolve_dependencies(
edges: Vec<DependencyEdge>,
member_versions: &[MemberVersions],
skipped: &mut SkippedCollector,
) -> Vec<ResolvedDependency> {
let mut groups: BTreeMap<(String, Version), DependencyGroup> = BTreeMap::new();
for edge in edges {
let Some(versions) = member_versions.get(edge.member_index) else {
skipped.push(unresolved_skip(&edge));
continue;
};
let Some(current) = resolve_current_version(edge.alias.as_deref(), &edge.name, versions)
else {
skipped.push(unresolved_skip(&edge));
continue;
};
let representative = (
versions.member.clone(),
edge.required.clone(),
edge.alias.clone(),
);
groups
.entry((edge.name.clone(), current))
.and_modify(|group| {
group.dependency_type =
merge_dependency_type(group.dependency_type, edge.dependency_type);
group.members.insert(versions.member.clone());
if representative < group.representative {
group.representative = representative.clone();
group.source = edge.source.clone();
group.target = edge.target.clone();
}
})
.or_insert_with(|| DependencyGroup {
representative,
source: edge.source.clone(),
target: edge.target.clone(),
dependency_type: edge.dependency_type,
members: BTreeSet::from_iter([versions.member.clone()]),
});
}
groups
.into_iter()
.map(|((name, current), group)| ResolvedDependency {
name,
alias: group.representative.2,
current,
required: group.representative.1,
source: group.source,
target: group.target,
dependency_type: group.dependency_type,
members: group.members.into_iter().collect(),
})
.collect()
}
fn unresolved_skip(edge: &DependencyEdge) -> SkippedDependency {
let reason = if edge.optional {
SkipReason::OptionalNotActivated
} else if edge.target.is_some() {
SkipReason::TargetSpecific
} else {
SkipReason::MissingResolve
};
SkippedDependency {
name: edge.name.clone(),
alias: edge.alias.clone(),
required: edge.required.clone(),
reason,
dependency_type: edge.dependency_type,
source: edge.source.clone(),
target: edge.target.clone(),
}
}
async fn fetch_latest_versions(
dependency_names: &HashSet<String>,
) -> (HashMap<String, VersionInfo>, bool, Vec<String>) {
let names: Vec<String> = dependency_names.iter().cloned().collect();
let mut warnings = Vec::new();
let crates_io = match CratesIoClient::new() {
Ok(client) => client,
Err(err) => {
warnings.push(format!("failed to create crates.io client: {}", err));
return (HashMap::new(), false, warnings);
}
};
match crates_io.fetch_latest_versions(&names, false).await {
Ok(versions) => (versions, true, warnings),
Err(err) => {
warnings.push(format!("failed to fetch latest crate versions: {}", err));
(HashMap::new(), false, warnings)
}
}
}
fn process_dependencies(
dependencies: Vec<ResolvedDependency>,
latest_versions: &HashMap<String, VersionInfo>,
registry_available: bool,
skipped: &mut SkippedCollector,
) -> Result<(Vec<OutdatedPackage>, usize, usize, usize)> {
let mut packages = Vec::new();
let mut major = 0;
let mut minor = 0;
let mut patch = 0;
for dependency in dependencies {
let latest = match get_latest_version(&dependency.name, latest_versions) {
Some(latest) => latest,
None => {
skipped.push(missing_registry_skip(&dependency, registry_available));
continue;
}
};
let latest_version = Version::parse(&latest).map_err(|err| {
UpkeepError::context(
ErrorCode::InvalidData,
format!(
"failed to parse latest version for {}: {}",
dependency.name, latest
),
err,
)
})?;
if latest_version <= dependency.current {
continue;
}
let update_type = classify_update(&dependency.current, &latest_version);
match update_type {
UpdateType::Major => major += 1,
UpdateType::Minor => minor += 1,
UpdateType::Patch => patch += 1,
}
packages.push(OutdatedPackage {
name: dependency.name,
alias: dependency.alias,
current: dependency.current.to_string(),
latest,
required: dependency.required,
update_type,
dependency_type: dependency.dependency_type,
members: dependency.members,
});
}
Ok((packages, major, minor, patch))
}
fn resolve_current_version(
alias: Option<&str>,
dep_name: &str,
versions: &MemberVersions,
) -> Option<Version> {
if let Some(alias) = alias {
return versions
.by_dep_name
.get(alias)
.or_else(|| versions.by_dep_name.get(&alias.replace('-', "_")))
.cloned();
}
versions
.by_dep_name
.get(dep_name)
.or_else(|| versions.by_dep_name.get(&dep_name.replace('-', "_")))
.or_else(|| versions.by_package_name.get(dep_name))
.cloned()
}
fn get_latest_version(
dep_name: &str,
latest_versions: &HashMap<String, VersionInfo>,
) -> Option<String> {
latest_versions
.get(dep_name)
.and_then(|info| info.latest.clone())
}
fn missing_registry_skip(
dependency: &ResolvedDependency,
registry_available: bool,
) -> SkippedDependency {
let reason = if registry_available {
SkipReason::RegistryMetadataMissing
} else {
SkipReason::RegistryUnavailable
};
SkippedDependency {
name: dependency.name.clone(),
alias: dependency.alias.clone(),
required: dependency.required.clone(),
reason,
dependency_type: dependency.dependency_type,
source: dependency.source.clone(),
target: dependency.target.clone(),
}
}
fn is_semver_breaking(current: &Version, latest: &Version) -> bool {
if current.major != latest.major {
return true;
}
if current.major != 0 {
return false;
}
if current.minor != latest.minor {
return true;
}
if current.minor != 0 {
return false;
}
current.patch != latest.patch
}
fn classify_update(current: &Version, latest: &Version) -> UpdateType {
if is_semver_breaking(current, latest) {
UpdateType::Major
} else if latest.minor > current.minor {
UpdateType::Minor
} else {
UpdateType::Patch
}
}
async fn fetch_security(dependencies: &[ResolvedDependency]) -> Result<DepsSecurityOutput> {
let audit_output = tokio::task::spawn_blocking(run_audit)
.await
.map_err(|err| {
let reason = if err.is_panic() {
"panicked"
} else {
"was cancelled"
};
UpkeepError::message(ErrorCode::TaskFailed, format!("audit task {reason}: {err}"))
})??;
let mut vulnerabilities_by_package: HashMap<(String, String), Vec<DepsSecurityVulnerability>> =
HashMap::new();
for vulnerability in audit_output.vulnerabilities {
vulnerabilities_by_package
.entry((vulnerability.package, vulnerability.package_version))
.or_default()
.push(DepsSecurityVulnerability {
advisory_id: vulnerability.id,
severity: vulnerability.severity,
title: vulnerability.title,
fix_available: vulnerability.fix_available,
});
}
let mut summary = AuditSummary {
critical: 0,
high: 0,
moderate: 0,
low: 0,
total: 0,
};
let mut packages = Vec::new();
for dependency in dependencies {
let current = dependency.current.to_string();
if let Some(vulns) =
vulnerabilities_by_package.remove(&(dependency.name.clone(), current.clone()))
{
for vuln in &vulns {
summary.total += 1;
match vuln.severity {
Severity::Critical => summary.critical += 1,
Severity::High => summary.high += 1,
Severity::Moderate => summary.moderate += 1,
Severity::Low => summary.low += 1,
}
}
packages.push(DepsSecurityPackage {
name: dependency.name.clone(),
alias: dependency.alias.clone(),
current,
dependency_type: dependency.dependency_type,
members: dependency.members.clone(),
vulnerabilities: vulns,
});
}
}
Ok(DepsSecurityOutput { summary, packages })
}
fn is_registry_source(source: Option<&String>) -> bool {
match source {
None => true, Some(s) => s.starts_with("registry+"),
}
}
#[cfg(test)]
mod tests {
use super::{
classify_update, get_latest_version, is_registry_source, merge_dependency_type,
process_dependencies, resolve_current_version, resolve_dependencies, run_with_output,
DependencyEdge, MemberVersions, SkippedCollector,
};
use crate::core::analyzers::crates_io::VersionInfo;
use crate::core::error::{ErrorCode, UpkeepError};
use crate::core::output::{
DependencyType, DepsOutput, OutdatedPackage, SkipReason, SkippedDependency, UpdateType,
};
use semver::Version;
use serde_json::Value;
use std::collections::HashMap;
fn member_versions(member: &str, deps: &[(&str, &str)]) -> MemberVersions {
let mut versions = MemberVersions {
member: member.to_string(),
..MemberVersions::default()
};
for (name, version) in deps {
let parsed = Version::parse(version).expect("parse version");
versions
.by_dep_name
.insert(name.replace('-', "_"), parsed.clone());
versions
.by_package_name
.entry((*name).to_string())
.or_insert(parsed);
}
versions
}
fn insert_renamed(versions: &mut MemberVersions, rename: &str, version: &str) {
versions.by_dep_name.insert(
rename.to_string(),
Version::parse(version).expect("parse version"),
);
}
fn aliased_edge(
name: &str,
alias: &str,
required: &str,
member_index: usize,
) -> DependencyEdge {
DependencyEdge {
alias: Some(alias.to_string()),
..edge(name, required, member_index)
}
}
fn edge(name: &str, required: &str, member_index: usize) -> DependencyEdge {
DependencyEdge {
name: name.to_string(),
alias: None,
required: required.to_string(),
source: None,
target: None,
optional: false,
dependency_type: DependencyType::Normal,
member_index,
}
}
#[test]
fn classify_update_detects_major_minor_patch() {
let cases: &[(&str, &str, UpdateType)] = &[
("1.2.3", "2.0.0", UpdateType::Major),
("1.2.3", "1.3.0", UpdateType::Minor),
("1.2.3", "1.2.4", UpdateType::Patch),
("0.8.5", "0.10.2", UpdateType::Major),
("0.1.0", "0.2.0", UpdateType::Major),
("0.8.1", "0.8.5", UpdateType::Patch),
("0.9.0", "1.0.0", UpdateType::Major),
("0.0.1", "0.0.2", UpdateType::Major),
("0.0.1", "0.1.0", UpdateType::Major),
];
for (current, latest, expected) in cases {
let current = Version::parse(current).expect("parse current");
let latest = Version::parse(latest).expect("parse latest");
let actual = classify_update(¤t, &latest);
assert_eq!(
&actual, expected,
"{current} -> {latest} classified {actual:?}, expected {expected:?}"
);
}
}
#[test]
fn resolve_current_version_prefers_alias_key() {
let mut versions = MemberVersions {
member: "member".to_string(),
..MemberVersions::default()
};
versions
.by_dep_name
.insert("alias".to_string(), Version::new(2, 0, 0));
versions
.by_dep_name
.insert("name".to_string(), Version::new(1, 0, 0));
let resolved = resolve_current_version(Some("alias"), "name", &versions);
assert_eq!(resolved, Some(Version::new(2, 0, 0)));
}
#[test]
fn resolve_current_version_does_not_fall_back_from_a_missing_alias() {
let mut versions = MemberVersions {
member: "member".to_string(),
..MemberVersions::default()
};
versions
.by_dep_name
.insert("name".to_string(), Version::new(1, 4, 2));
versions
.by_package_name
.insert("name".to_string(), Version::new(1, 4, 2));
assert_eq!(
resolve_current_version(Some("alias"), "name", &versions),
None
);
}
#[test]
fn resolve_current_version_normalizes_a_dashed_rename() {
let mut versions = MemberVersions {
member: "member".to_string(),
..MemberVersions::default()
};
versions
.by_dep_name
.insert("my_itoa".to_string(), Version::new(0, 4, 8));
versions
.by_dep_name
.insert("itoa".to_string(), Version::new(1, 0, 18));
assert_eq!(
resolve_current_version(Some("my-itoa"), "itoa", &versions),
Some(Version::new(0, 4, 8)),
"a dashed rename must resolve to its own edge, not the plain one"
);
}
#[test]
fn resolve_current_version_falls_back_to_lib_target_name() {
let versions = member_versions("member", &[("wasm-bindgen", "0.2.100")]);
assert!(!versions.by_dep_name.contains_key("wasm-bindgen"));
let resolved = resolve_current_version(None, "wasm-bindgen", &versions);
assert_eq!(resolved, Some(Version::parse("0.2.100").expect("parse")));
}
#[test]
fn resolve_current_version_falls_back_to_package_name() {
let mut versions = MemberVersions {
member: "member".to_string(),
..MemberVersions::default()
};
versions
.by_dep_name
.insert("custom_lib_name".to_string(), Version::new(1, 4, 0));
versions
.by_package_name
.insert("odd-crate".to_string(), Version::new(1, 4, 0));
let resolved = resolve_current_version(None, "odd-crate", &versions);
assert_eq!(resolved, Some(Version::new(1, 4, 0)));
}
#[test]
fn resolve_current_version_returns_none_when_absent() {
let versions = MemberVersions {
member: "member".to_string(),
..MemberVersions::default()
};
assert_eq!(resolve_current_version(None, "name", &versions), None);
assert_eq!(
resolve_current_version(Some("alias"), "name", &versions),
None
);
}
#[test]
fn merge_dependency_type_prefers_normal_then_build() {
assert_eq!(
merge_dependency_type(DependencyType::Dev, DependencyType::Normal),
DependencyType::Normal
);
assert_eq!(
merge_dependency_type(DependencyType::Normal, DependencyType::Dev),
DependencyType::Normal
);
assert_eq!(
merge_dependency_type(DependencyType::Dev, DependencyType::Build),
DependencyType::Build
);
assert_eq!(
merge_dependency_type(DependencyType::Build, DependencyType::Dev),
DependencyType::Build
);
}
#[test]
fn resolve_dependencies_splits_semver_incompatible_versions() {
let members = vec![
member_versions("core-lib", &[("rand", "0.8.5")]),
member_versions("cli-app", &[("rand", "0.9.2")]),
];
let edges = vec![edge("rand", "^0.8", 0), edge("rand", "^0.9", 1)];
let mut skipped = SkippedCollector::new();
let resolved = resolve_dependencies(edges, &members, &mut skipped);
assert_eq!(resolved.len(), 2);
assert_eq!(resolved[0].current, Version::new(0, 8, 5));
assert_eq!(resolved[0].members, vec!["core-lib".to_string()]);
assert_eq!(resolved[0].required, "^0.8");
assert_eq!(resolved[1].current, Version::new(0, 9, 2));
assert_eq!(resolved[1].members, vec!["cli-app".to_string()]);
assert_eq!(resolved[1].required, "^0.9");
assert!(skipped.into_vec().is_empty());
}
#[test]
fn resolve_dependencies_merges_agreeing_members() {
let members = vec![
member_versions("member-b", &[("serde", "1.0.200")]),
member_versions("member-a", &[("serde", "1.0.200")]),
];
let edges = vec![edge("serde", "^1.0", 0), edge("serde", "^1.0", 1)];
let mut skipped = SkippedCollector::new();
let resolved = resolve_dependencies(edges, &members, &mut skipped);
assert_eq!(resolved.len(), 1);
assert_eq!(
resolved[0].members,
vec!["member-a".to_string(), "member-b".to_string()]
);
}
#[test]
fn resolve_dependencies_merges_differing_requirements_on_one_version() {
let members = vec![
member_versions("member-b", &[("serde", "1.0.200")]),
member_versions("member-a", &[("serde", "1.0.200")]),
];
let edges = vec![edge("serde", "^1.0", 0), edge("serde", "^1.0.100", 1)];
let mut skipped = SkippedCollector::new();
let resolved = resolve_dependencies(edges, &members, &mut skipped);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].required, "^1.0.100");
assert_eq!(
resolved[0].members,
vec!["member-a".to_string(), "member-b".to_string()]
);
}
#[test]
fn resolve_dependencies_populates_members_for_single_crate() {
let members = vec![member_versions("solo", &[("serde", "1.0.200")])];
let edges = vec![edge("serde", "^1.0", 0)];
let mut skipped = SkippedCollector::new();
let resolved = resolve_dependencies(edges, &members, &mut skipped);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].members, vec!["solo".to_string()]);
}
#[test]
fn resolve_dependencies_uses_rename_for_lookup() {
let mut versions = MemberVersions {
member: "solo".to_string(),
..MemberVersions::default()
};
versions
.by_dep_name
.insert("rand".to_string(), Version::new(0, 8, 5));
versions
.by_dep_name
.insert("rand9".to_string(), Version::new(0, 9, 2));
versions
.by_package_name
.insert("rand".to_string(), Version::new(0, 8, 5));
let mut renamed = edge("rand", "^0.9", 0);
renamed.alias = Some("rand9".to_string());
let edges = vec![edge("rand", "^0.8", 0), renamed];
let mut skipped = SkippedCollector::new();
let resolved = resolve_dependencies(edges, &[versions], &mut skipped);
assert_eq!(resolved.len(), 2);
assert_eq!(resolved[0].current, Version::new(0, 8, 5));
assert_eq!(resolved[0].alias, None);
assert_eq!(resolved[1].current, Version::new(0, 9, 2));
assert_eq!(resolved[1].alias, Some("rand9".to_string()));
}
#[test]
fn resolve_dependencies_keeps_a_plain_dash_named_edge_off_an_aliased_instance() {
let mut versions = MemberVersions {
member: "solo".to_string(),
..MemberVersions::default()
};
versions.by_dep_name.insert(
"wasm_bindgen".to_string(),
Version::parse("0.2.100").expect("parse"),
);
insert_renamed(&mut versions, "wb1", "0.1.0");
versions
.by_package_name
.insert("wasm-bindgen".to_string(), Version::new(0, 1, 0));
let edges = vec![
edge("wasm-bindgen", "=0.2.100", 0),
aliased_edge("wasm-bindgen", "wb1", "^0.1", 0),
];
let mut skipped = SkippedCollector::new();
let resolved = resolve_dependencies(edges, &[versions], &mut skipped);
assert_eq!(resolved.len(), 2, "the two instances must not collapse");
assert_eq!(resolved[0].current, Version::new(0, 1, 0));
assert_eq!(resolved[0].alias, Some("wb1".to_string()));
assert_eq!(resolved[0].required, "^0.1");
assert_eq!(
resolved[1].current,
Version::parse("0.2.100").expect("parse")
);
assert_eq!(resolved[1].alias, None);
assert_eq!(resolved[1].required, "=0.2.100");
assert!(skipped.into_vec().is_empty());
}
#[test]
fn resolve_dependencies_skips_an_inactive_optional_alias_of_a_declared_crate() {
let versions = member_versions("solo", &[("rand", "0.8.8")]);
let mut optional = aliased_edge("rand", "rand7", "^0.7", 0);
optional.optional = true;
let edges = vec![edge("rand", "^0.8", 0), optional];
let mut skipped = SkippedCollector::new();
let resolved = resolve_dependencies(edges, &[versions], &mut skipped);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].current, Version::new(0, 8, 8));
assert_eq!(resolved[0].required, "^0.8");
assert_eq!(resolved[0].alias, None);
let skipped = skipped.into_vec();
assert_eq!(skipped.len(), 1);
assert_eq!(skipped[0].alias, Some("rand7".to_string()));
assert_eq!(skipped[0].reason, SkipReason::OptionalNotActivated);
}
#[test]
fn resolve_dependencies_does_not_alias_onto_a_dash_named_lib_target() {
let versions = member_versions("solo", &[("wasm-bindgen", "0.2.100")]);
let edges = vec![
edge("wasm-bindgen", "=0.2.100", 0),
aliased_edge("wasm-bindgen", "wb1", "^0.1", 0),
];
let mut skipped = SkippedCollector::new();
let resolved = resolve_dependencies(edges, &[versions], &mut skipped);
assert_eq!(resolved.len(), 1);
assert_eq!(
resolved[0].current,
Version::parse("0.2.100").expect("parse")
);
assert_eq!(resolved[0].required, "=0.2.100");
let skipped = skipped.into_vec();
assert_eq!(skipped.len(), 1);
assert_eq!(skipped[0].alias, Some("wb1".to_string()));
assert_eq!(skipped[0].reason, SkipReason::MissingResolve);
}
#[test]
fn resolve_dependencies_keys_security_attribution_per_resolved_version() {
let members = vec![
member_versions("core-lib", &[("time", "0.1.45")]),
member_versions("cli-app", &[("time", "0.2.27")]),
];
let edges = vec![edge("time", "^0.1", 0), edge("time", "^0.2", 1)];
let mut skipped = SkippedCollector::new();
let resolved = resolve_dependencies(edges, &members, &mut skipped);
let keys: Vec<(String, String)> = resolved
.iter()
.map(|dep| (dep.name.clone(), dep.current.to_string()))
.collect();
assert_eq!(
keys,
vec![
("time".to_string(), "0.1.45".to_string()),
("time".to_string(), "0.2.27".to_string()),
]
);
assert_eq!(resolved[0].members, vec!["core-lib".to_string()]);
assert_eq!(resolved[1].members, vec!["cli-app".to_string()]);
assert!(skipped.into_vec().is_empty());
}
#[test]
fn resolve_dependencies_merges_dependency_kinds_by_precedence() {
let members = vec![
member_versions("core-lib", &[("serde", "1.0.200")]),
member_versions("cli-app", &[("serde", "1.0.200")]),
];
let mut dev_edge = edge("serde", "^1.0", 0);
dev_edge.dependency_type = DependencyType::Dev;
let edges = vec![dev_edge, edge("serde", "^1.0", 1)];
let mut skipped = SkippedCollector::new();
let resolved = resolve_dependencies(edges, &members, &mut skipped);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].dependency_type, DependencyType::Normal);
}
#[test]
fn resolve_dependencies_is_sorted_by_name_then_version() {
let members = vec![member_versions(
"solo",
&[("serde", "1.0.200"), ("anyhow", "1.0.86")],
)];
let mut versions = members;
versions[0]
.by_dep_name
.insert("zzz".to_string(), Version::new(0, 1, 0));
versions[0]
.by_dep_name
.insert("aliased".to_string(), Version::new(0, 10, 0));
let mut older = edge("zzz", "^0.9", 0);
older.alias = Some("aliased".to_string());
let edges = vec![
edge("serde", "^1.0", 0),
older,
edge("anyhow", "^1.0", 0),
edge("zzz", "^0.1", 0),
];
let mut skipped = SkippedCollector::new();
let resolved = resolve_dependencies(edges, &versions, &mut skipped);
let order: Vec<(String, String)> = resolved
.iter()
.map(|dep| (dep.name.clone(), dep.current.to_string()))
.collect();
assert_eq!(
order,
vec![
("anyhow".to_string(), "1.0.86".to_string()),
("serde".to_string(), "1.0.200".to_string()),
("zzz".to_string(), "0.1.0".to_string()),
("zzz".to_string(), "0.10.0".to_string()),
]
);
}
#[test]
fn resolve_dependencies_skips_unresolved_edges_with_reasons() {
let members = vec![member_versions("solo", &[])];
let mut optional = edge("serde", "^1.0", 0);
optional.optional = true;
let mut target_specific = edge("winapi", "^0.3", 0);
target_specific.target = Some("cfg(windows)".to_string());
let edges = vec![optional, target_specific, edge("anyhow", "^1.0", 0)];
let mut skipped = SkippedCollector::new();
let resolved = resolve_dependencies(edges, &members, &mut skipped);
assert!(resolved.is_empty());
let skipped = skipped.into_vec();
assert_eq!(skipped.len(), 3);
assert_eq!(skipped[0].reason, SkipReason::OptionalNotActivated);
assert_eq!(skipped[1].reason, SkipReason::TargetSpecific);
assert_eq!(skipped[2].reason, SkipReason::MissingResolve);
}
#[test]
fn skipped_collector_deduplicates_identical_reports() {
let members = vec![
member_versions("member-a", &[]),
member_versions("member-b", &[]),
];
let edges = vec![edge("anyhow", "^1.0", 0), edge("anyhow", "^1.0", 1)];
let mut skipped = SkippedCollector::new();
resolve_dependencies(edges, &members, &mut skipped);
assert_eq!(skipped.into_vec().len(), 1);
}
#[test]
fn process_dependencies_reports_members_per_version() {
let members = vec![
member_versions("core-lib", &[("rand", "0.8.5")]),
member_versions("cli-app", &[("rand", "0.9.2")]),
];
let edges = vec![edge("rand", "^0.8", 0), edge("rand", "^0.9", 1)];
let mut skipped = SkippedCollector::new();
let resolved = resolve_dependencies(edges, &members, &mut skipped);
let mut latest_versions = HashMap::new();
latest_versions.insert(
"rand".to_string(),
VersionInfo {
name: "rand".to_string(),
latest: Some("0.10.2".to_string()),
latest_stable: None,
},
);
let (packages, major, minor, patch) =
process_dependencies(resolved, &latest_versions, true, &mut skipped)
.expect("process dependencies");
assert_eq!(packages.len(), 2);
assert_eq!(major, 2);
assert_eq!((minor, patch), (0, 0));
assert_eq!(packages[0].current, "0.8.5");
assert_eq!(packages[0].members, vec!["core-lib".to_string()]);
assert_eq!(packages[1].current, "0.9.2");
assert_eq!(packages[1].members, vec!["cli-app".to_string()]);
}
#[test]
fn process_dependencies_skips_when_registry_unavailable() {
let members = vec![member_versions("solo", &[("serde", "1.0.200")])];
let edges = vec![edge("serde", "^1.0", 0)];
let mut skipped = SkippedCollector::new();
let resolved = resolve_dependencies(edges, &members, &mut skipped);
let (packages, ..) = process_dependencies(resolved, &HashMap::new(), false, &mut skipped)
.expect("process dependencies");
assert!(packages.is_empty());
let skipped = skipped.into_vec();
assert_eq!(skipped.len(), 1);
assert_eq!(skipped[0].reason, SkipReason::RegistryUnavailable);
}
#[test]
fn get_latest_version_returns_latest_when_present() {
let mut versions = HashMap::new();
versions.insert(
"serde".to_string(),
VersionInfo {
name: "serde".to_string(),
latest: Some("1.0.200".to_string()),
latest_stable: None,
},
);
let latest = get_latest_version("serde", &versions);
assert_eq!(latest.as_deref(), Some("1.0.200"));
}
#[test]
fn is_registry_source_handles_registry_and_non_registry() {
let registry = Some("registry+https://example.com".to_string());
let git = Some("git+https://example.com/repo.git".to_string());
assert!(is_registry_source(None));
assert!(is_registry_source(registry.as_ref()));
assert!(!is_registry_source(git.as_ref()));
}
#[test]
fn semver_parsing_and_comparison_handles_prerelease() {
let stable = Version::parse("1.2.3").expect("parse stable");
let prerelease = Version::parse("1.2.3-beta.1").expect("parse prerelease");
let next_patch = Version::parse("1.2.4-beta.1").expect("parse next prerelease");
assert!(prerelease < stable);
assert!(next_patch > stable);
}
#[test]
fn classify_update_handles_prerelease_versions() {
let current = Version::parse("1.2.3").expect("parse current");
let prerelease_patch = Version::parse("1.2.4-beta.1").expect("parse prerelease");
assert!(matches!(
classify_update(¤t, &prerelease_patch),
UpdateType::Patch
));
}
fn sample_output() -> DepsOutput {
DepsOutput {
total: 2,
outdated: 1,
major: 1,
minor: 0,
patch: 0,
packages: vec![OutdatedPackage {
name: "serde".to_string(),
alias: None,
current: "1.0.0".to_string(),
latest: "2.0.0".to_string(),
required: "^1".to_string(),
update_type: UpdateType::Major,
dependency_type: DependencyType::Normal,
members: vec!["cargo-upkeep".to_string()],
}],
skipped: 1,
skipped_packages: vec![SkippedDependency {
name: "tokio".to_string(),
alias: None,
required: "^1".to_string(),
reason: SkipReason::NonRegistry,
dependency_type: DependencyType::Dev,
source: None,
target: None,
}],
warnings: vec!["registry unavailable".to_string()],
security: None,
workspace: false,
members: Vec::new(),
skipped_members: Vec::new(),
}
}
#[tokio::test]
async fn run_with_output_json_shape() {
run_with_output(
true,
async { Ok(sample_output()) },
|output| {
let value = serde_json::to_value(output)?;
assert_eq!(value["outdated"], Value::Number(1.into()));
assert_eq!(value["packages"][0]["name"], Value::String("serde".into()));
assert_eq!(
value["packages"][0]["update_type"],
Value::String("major".into())
);
assert_eq!(
value["packages"][0]["members"],
Value::Array(vec![Value::String("cargo-upkeep".into())])
);
Ok(())
},
|_| Ok(()),
)
.await
.unwrap();
}
#[tokio::test]
async fn run_with_output_propagates_error() {
let err = run_with_output(
true,
async { Err(UpkeepError::message(ErrorCode::InvalidData, "bad")) },
|_: &DepsOutput| Ok(()),
|_: &DepsOutput| Ok(()),
)
.await
.unwrap_err();
assert_eq!(err.code(), ErrorCode::InvalidData);
}
}