use cargo_metadata::{
Dependency, DependencyKind, Metadata, MetadataCommand, Node, Package, PackageId,
};
use semver::Version;
use std::collections::hash_map::Entry;
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_failures, 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 Processed {
packages,
major,
minor,
patch,
compared,
} = process_dependencies(
dependencies,
&latest_versions,
®istry_failures,
&mut skipped,
)?;
let skipped_packages = skipped.into_vec();
Ok(DepsOutput {
total: total_dependencies,
checked: compared + not_applicable_count(&skipped_packages),
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, DependencyType), Version>,
by_package_name: HashMap<String, Option<Version>>,
}
impl MemberVersions {
fn insert_dep_version(
&mut self,
name: impl Into<String>,
dependency_type: DependencyType,
version: Version,
) {
self.by_dep_name
.insert((name.into(), dependency_type), version);
}
fn dep_version(&self, name: &str, dependency_type: DependencyType) -> Option<&Version> {
self.by_dep_name.get(&(name.to_string(), dependency_type))
}
fn insert_package_version(&mut self, name: impl Into<String>, version: Version) {
match self.by_package_name.entry(name.into()) {
Entry::Vacant(slot) => {
slot.insert(Some(version));
}
Entry::Occupied(mut slot) => {
if slot.get().as_ref().is_some_and(|seen| *seen != version) {
slot.insert(None);
}
}
}
}
fn package_version(&self, name: &str) -> Option<&Version> {
self.by_package_name.get(name).and_then(Option::as_ref)
}
fn is_ambiguous_package_name(&self, name: &str) -> bool {
matches!(self.by_package_name.get(name), Some(None))
}
}
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) {
if dep.dep_kinds.is_empty() {
versions.insert_dep_version(
dep.name.to_string(),
DependencyType::Normal,
package.version.clone(),
);
} else {
for dep_kind in &dep.dep_kinds {
versions.insert_dep_version(
dep.name.to_string(),
convert_dependency_kind(dep_kind.kind),
package.version.clone(),
);
}
}
versions
.insert_package_version(package.name.to_string(), 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: HashMap<SkippedIdentity, usize>,
items: Vec<SkippedDependency>,
}
type SkippedIdentity = (
String,
Option<String>,
String,
SkipReason,
Option<String>,
Option<String>,
);
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,
skipped.source.clone(),
skipped.target.clone(),
);
if let Some(index) = self.seen.get(&key).copied() {
let existing = &mut self.items[index];
existing.dependency_type =
merge_dependency_type(existing.dependency_type, skipped.dependency_type);
} else {
let index = self.items.len();
self.seen.insert(key, index);
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>,
is_path: bool,
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);
partition_edge(edge, &mut dependency_names, &mut edges, skipped);
}
}
(dependency_names, edges)
}
fn partition_edge(
edge: DependencyEdge,
dependency_names: &mut HashSet<String>,
edges: &mut Vec<DependencyEdge>,
skipped: &mut SkippedCollector,
) {
let reason = if edge.is_path {
SkipReason::NonRegistry
} else {
match classify_registry_source(edge.source.as_ref()) {
RegistrySource::CratesIo => {
dependency_names.insert(edge.name.clone());
edges.push(edge);
return;
}
RegistrySource::Unsupported => SkipReason::UnsupportedRegistry,
RegistrySource::NonRegistry => SkipReason::NonRegistry,
}
};
skipped.push(SkippedDependency {
name: edge.name,
alias: edge.alias,
required: edge.required,
reason,
dependency_type: edge.dependency_type,
source: edge.source,
target: edge.target,
});
}
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),
is_path: dep.path.is_some(),
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, false));
continue;
};
let Some(current) = resolve_current_version(
edge.alias.as_deref(),
&edge.name,
edge.dependency_type,
versions,
) else {
let ambiguous = edge.alias.is_none() && versions.is_ambiguous_package_name(&edge.name);
skipped.push(unresolved_skip(&edge, ambiguous));
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, ambiguous: bool) -> SkippedDependency {
let reason = if ambiguous {
SkipReason::AmbiguousResolve
} else 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>, HashSet<String>, Vec<String>) {
let mut names: Vec<String> = dependency_names.iter().cloned().collect();
names.sort();
let crates_io = match CratesIoClient::new() {
Ok(client) => client,
Err(err) => {
let detail = format!("failed to create crates.io client: {err}");
let warnings = registry_batch_failure_warnings(&names, &detail);
return (HashMap::new(), names.into_iter().collect(), warnings);
}
};
match crates_io.fetch_latest_versions(&names, false).await {
Ok(batch) => {
let warnings = registry_failure_warnings(&batch.failures);
let failures = batch.failures.into_keys().collect();
(batch.versions, failures, warnings)
}
Err(err) => {
let detail = format!("batch lookup failed: {err}");
let warnings = registry_batch_failure_warnings(&names, &detail);
(HashMap::new(), names.into_iter().collect(), warnings)
}
}
}
fn registry_failure_warnings(failures: &BTreeMap<String, UpkeepError>) -> Vec<String> {
failures
.iter()
.map(|(name, err)| format!("failed to fetch latest version for {name}: {err}"))
.collect()
}
fn registry_batch_failure_warnings(names: &[String], detail: &str) -> Vec<String> {
names
.iter()
.collect::<BTreeSet<_>>()
.into_iter()
.map(|name| format!("failed to fetch latest version for {name}: {detail}"))
.collect()
}
fn not_applicable_count(skipped: &[SkippedDependency]) -> usize {
skipped
.iter()
.filter(|entry| {
matches!(
entry.reason,
SkipReason::NonRegistry
| SkipReason::TargetSpecific
| SkipReason::OptionalNotActivated
)
})
.count()
}
struct Processed {
packages: Vec<OutdatedPackage>,
major: usize,
minor: usize,
patch: usize,
compared: usize,
}
fn process_dependencies(
dependencies: Vec<ResolvedDependency>,
latest_versions: &HashMap<String, VersionInfo>,
registry_failures: &HashSet<String>,
skipped: &mut SkippedCollector,
) -> Result<Processed> {
let mut packages = Vec::new();
let mut major = 0;
let mut minor = 0;
let mut patch = 0;
let mut compared = 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_failures.contains(&dependency.name),
));
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,
)
})?;
compared += 1;
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(Processed {
packages,
major,
minor,
patch,
compared,
})
}
fn resolve_current_version(
alias: Option<&str>,
dep_name: &str,
dependency_type: DependencyType,
versions: &MemberVersions,
) -> Option<Version> {
if let Some(alias) = alias {
return versions
.dep_version(alias, dependency_type)
.or_else(|| versions.dep_version(&alias.replace('-', "_"), dependency_type))
.cloned();
}
versions
.dep_version(dep_name, dependency_type)
.or_else(|| versions.dep_version(&dep_name.replace('-', "_"), dependency_type))
.or_else(|| versions.package_version(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_unavailable: bool,
) -> SkippedDependency {
let reason = if registry_unavailable {
SkipReason::RegistryUnavailable
} else {
SkipReason::RegistryMetadataMissing
};
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 })
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RegistrySource {
CratesIo,
Unsupported,
NonRegistry,
}
fn classify_registry_source(source: Option<&String>) -> RegistrySource {
const CRATES_IO_GIT_INDEX: &str = "registry+https://github.com/rust-lang/crates.io-index";
const CRATES_IO_SPARSE_INDEX: &str = "sparse+https://index.crates.io/";
const CRATES_IO_REGISTRY_INDEX: &str = "registry+https://index.crates.io/";
match source.map(String::as_str) {
None
| Some(CRATES_IO_GIT_INDEX)
| Some(CRATES_IO_SPARSE_INDEX)
| Some(CRATES_IO_REGISTRY_INDEX) => RegistrySource::CratesIo,
Some(source) if source.starts_with("registry+") || source.starts_with("sparse+") => {
RegistrySource::Unsupported
}
Some(_) => RegistrySource::NonRegistry,
}
}
#[cfg(test)]
mod tests {
use super::{
classify_registry_source, classify_update, get_latest_version, merge_dependency_type,
not_applicable_count, partition_edge, process_dependencies, resolve_current_version,
resolve_dependencies, run_with_output, DependencyEdge, MemberVersions, Processed,
RegistrySource, 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.insert_dep_version(
name.replace('-', "_"),
DependencyType::Normal,
parsed.clone(),
);
versions.insert_package_version(*name, parsed);
}
versions
}
fn insert_renamed(versions: &mut MemberVersions, rename: &str, version: &str) {
versions.insert_dep_version(
rename.to_string(),
DependencyType::Normal,
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,
is_path: false,
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.insert_dep_version("alias", DependencyType::Normal, Version::new(2, 0, 0));
versions.insert_dep_version("name", DependencyType::Normal, Version::new(1, 0, 0));
let resolved =
resolve_current_version(Some("alias"), "name", DependencyType::Normal, &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.insert_dep_version("name", DependencyType::Normal, Version::new(1, 4, 2));
versions.insert_package_version("name", Version::new(1, 4, 2));
assert_eq!(
resolve_current_version(Some("alias"), "name", DependencyType::Normal, &versions),
None
);
}
#[test]
fn resolve_current_version_normalizes_a_dashed_rename() {
let mut versions = MemberVersions {
member: "member".to_string(),
..MemberVersions::default()
};
versions.insert_dep_version("my_itoa", DependencyType::Normal, Version::new(0, 4, 8));
versions.insert_dep_version("itoa", DependencyType::Normal, Version::new(1, 0, 18));
assert_eq!(
resolve_current_version(Some("my-itoa"), "itoa", DependencyType::Normal, &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
.dep_version("wasm-bindgen", DependencyType::Normal)
.is_none());
let resolved =
resolve_current_version(None, "wasm-bindgen", DependencyType::Normal, &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.insert_dep_version(
"custom_lib_name",
DependencyType::Normal,
Version::new(1, 4, 0),
);
versions.insert_package_version("odd-crate", Version::new(1, 4, 0));
let resolved =
resolve_current_version(None, "odd-crate", DependencyType::Normal, &versions);
assert_eq!(resolved, Some(Version::new(1, 4, 0)));
}
#[test]
fn insert_package_version_poisons_only_on_a_second_distinct_version() {
let mut versions = MemberVersions {
member: "member".to_string(),
..MemberVersions::default()
};
versions.insert_package_version("wasm-bindgen", Version::new(0, 2, 100));
assert_eq!(
versions.package_version("wasm-bindgen"),
Some(&Version::new(0, 2, 100))
);
assert!(!versions.is_ambiguous_package_name("wasm-bindgen"));
versions.insert_package_version("wasm-bindgen", Version::new(0, 2, 100));
assert_eq!(
versions.package_version("wasm-bindgen"),
Some(&Version::new(0, 2, 100)),
"a repeated identical version must not poison the entry"
);
versions.insert_package_version("wasm-bindgen", Version::new(0, 1, 0));
assert!(versions.is_ambiguous_package_name("wasm-bindgen"));
assert_eq!(versions.package_version("wasm-bindgen"), None);
versions.insert_package_version("wasm-bindgen", Version::new(0, 2, 100));
assert!(versions.is_ambiguous_package_name("wasm-bindgen"));
versions.insert_package_version("serde", Version::new(1, 0, 200));
assert_eq!(
versions.package_version("serde"),
Some(&Version::new(1, 0, 200))
);
}
#[test]
fn resolve_current_version_refuses_a_poisoned_package_name() {
let mut versions = MemberVersions {
member: "member".to_string(),
..MemberVersions::default()
};
versions.insert_dep_version("wb_lib", DependencyType::Normal, Version::new(0, 2, 100));
insert_renamed(&mut versions, "wb1", "0.1.0");
versions.insert_package_version("wasm-bindgen", Version::new(0, 1, 0));
versions.insert_package_version("wasm-bindgen", Version::new(0, 2, 100));
assert_eq!(
resolve_current_version(None, "wasm-bindgen", DependencyType::Normal, &versions),
None
);
}
#[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", DependencyType::Normal, &versions),
None
);
assert_eq!(
resolve_current_version(Some("alias"), "name", DependencyType::Normal, &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.insert_dep_version("rand", DependencyType::Normal, Version::new(0, 8, 5));
versions.insert_dep_version("rand9", DependencyType::Normal, Version::new(0, 9, 2));
versions.insert_package_version("rand", 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.insert_dep_version(
"wasm_bindgen",
DependencyType::Normal,
Version::parse("0.2.100").expect("parse"),
);
insert_renamed(&mut versions, "wb1", "0.1.0");
versions.insert_package_version("wasm-bindgen", 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_a_custom_lib_name_edge_with_an_ambiguous_package_name() {
let mut versions = MemberVersions {
member: "solo".to_string(),
..MemberVersions::default()
};
versions.insert_dep_version(
"wb_lib",
DependencyType::Normal,
Version::parse("0.2.100").expect("parse"),
);
insert_renamed(&mut versions, "wb1", "0.1.0");
versions.insert_package_version("wasm-bindgen", Version::new(0, 1, 0));
versions.insert_package_version("wasm-bindgen", Version::parse("0.2.100").expect("parse"));
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);
let skipped = skipped.into_vec();
assert_eq!(
skipped.len(),
1,
"the plain edge cannot be resolved and must be reported, not guessed at"
);
assert_eq!(skipped[0].name, "wasm-bindgen");
assert_eq!(skipped[0].alias, None);
assert_eq!(skipped[0].required, "=0.2.100");
assert_eq!(skipped[0].reason, SkipReason::AmbiguousResolve);
assert_eq!(not_applicable_count(&skipped), 0);
assert_eq!(resolved.len(), 1);
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");
}
#[test]
fn resolve_dependencies_prefers_ambiguity_over_optional_for_a_poisoned_name() {
let mut versions = MemberVersions {
member: "solo".to_string(),
..MemberVersions::default()
};
versions.insert_dep_version(
"wb_lib",
DependencyType::Normal,
Version::parse("0.2.100").expect("parse"),
);
insert_renamed(&mut versions, "wb1", "0.1.0");
versions.insert_package_version("wasm-bindgen", Version::new(0, 1, 0));
versions.insert_package_version("wasm-bindgen", Version::parse("0.2.100").expect("parse"));
let optional = DependencyEdge {
optional: true,
..edge("wasm-bindgen", "=0.2.100", 0)
};
let mut skipped = SkippedCollector::new();
let resolved = resolve_dependencies(vec![optional], &[versions], &mut skipped);
let skipped = skipped.into_vec();
assert!(resolved.is_empty());
assert_eq!(skipped.len(), 1);
assert_eq!(skipped[0].reason, SkipReason::AmbiguousResolve);
assert_eq!(not_applicable_count(&skipped), 0);
}
#[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].insert_dep_version("zzz", DependencyType::Normal, Version::new(0, 1, 0));
versions[0].insert_dep_version("aliased", DependencyType::Normal, 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 Processed {
packages,
major,
minor,
patch,
compared,
} = process_dependencies(
resolved,
&latest_versions,
&std::collections::HashSet::new(),
&mut skipped,
)
.expect("process dependencies");
assert_eq!(packages.len(), 2);
assert_eq!(compared, 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 processed = process_dependencies(
resolved,
&HashMap::new(),
&std::collections::HashSet::from(["serde".to_string()]),
&mut skipped,
)
.expect("process dependencies");
assert!(processed.packages.is_empty());
assert_eq!(processed.compared, 0);
let skipped = skipped.into_vec();
assert_eq!(skipped.len(), 1);
assert_eq!(skipped[0].reason, SkipReason::RegistryUnavailable);
}
#[test]
fn checked_counts_groups_not_edges_for_a_redeclared_crate() {
let members = vec![member_versions("solo", &[("serde", "1.0.200")])];
let edges = vec![edge("serde", "^1.0", 0), edge("serde", "^1.0", 0)];
let mut skipped = SkippedCollector::new();
let resolved = resolve_dependencies(edges, &members, &mut skipped);
assert_eq!(resolved.len(), 1);
let processed = process_dependencies(
resolved,
&HashMap::new(),
&std::collections::HashSet::from(["serde".to_string()]),
&mut skipped,
)
.expect("process dependencies");
let skipped = skipped.into_vec();
assert_eq!(processed.compared, 0);
assert_eq!(skipped.len(), 1);
assert_eq!(not_applicable_count(&skipped), 0);
let checked = processed.compared + not_applicable_count(&skipped);
assert_eq!(checked, 0, "nothing was compared, so nothing is checked");
assert_ne!(checked, 2 - skipped.len());
}
#[test]
fn not_applicable_count_covers_only_the_non_measurable_reasons() {
let skipped = vec![
skipped_with(SkipReason::NonRegistry),
skipped_with(SkipReason::TargetSpecific),
skipped_with(SkipReason::OptionalNotActivated),
skipped_with(SkipReason::RegistryUnavailable),
skipped_with(SkipReason::RegistryMetadataMissing),
skipped_with(SkipReason::MissingResolve),
skipped_with(SkipReason::AmbiguousResolve),
];
assert_eq!(not_applicable_count(&skipped), 3);
}
#[test]
fn process_dependencies_keeps_successes_when_a_sibling_lookup_failed() {
let members = vec![member_versions(
"solo",
&[("anyhow", "1.0.80"), ("serde", "1.0.200")],
)];
let edges = vec![edge("anyhow", "^1.0", 0), edge("serde", "^1.0", 0)];
let mut skipped = SkippedCollector::new();
let resolved = resolve_dependencies(edges, &members, &mut skipped);
let mut versions = HashMap::new();
versions.insert(
"serde".to_string(),
VersionInfo {
name: "serde".to_string(),
latest: Some("1.0.210".to_string()),
latest_stable: Some("1.0.210".to_string()),
},
);
let failed = std::collections::HashSet::from(["anyhow".to_string()]);
let processed = process_dependencies(resolved, &versions, &failed, &mut skipped)
.expect("process dependencies");
assert_eq!(processed.compared, 1);
assert_eq!(processed.packages.len(), 1);
assert_eq!(processed.packages[0].name, "serde");
let skipped = skipped.into_vec();
assert_eq!(skipped.len(), 1);
assert_eq!(skipped[0].name, "anyhow");
assert_eq!(skipped[0].reason, SkipReason::RegistryUnavailable);
}
#[test]
fn missing_version_in_successful_response_is_metadata_missing() {
let members = vec![member_versions("solo", &[("empty", "1.0.0")])];
let mut skipped = SkippedCollector::new();
let resolved = resolve_dependencies(vec![edge("empty", "^1.0", 0)], &members, &mut skipped);
let mut versions = HashMap::new();
versions.insert(
"empty".to_string(),
VersionInfo {
name: "empty".to_string(),
latest: None,
latest_stable: None,
},
);
let processed = process_dependencies(
resolved,
&versions,
&std::collections::HashSet::new(),
&mut skipped,
)
.expect("process dependencies");
assert_eq!(processed.compared, 0);
assert_eq!(
skipped.into_vec()[0].reason,
SkipReason::RegistryMetadataMissing
);
}
#[test]
fn registry_failure_warnings_are_sorted_and_name_each_crate() {
let failures = std::collections::BTreeMap::from([
(
"zeta".to_string(),
UpkeepError::message(ErrorCode::Http, "status 500"),
),
(
"alpha".to_string(),
UpkeepError::message(ErrorCode::Json, "invalid JSON"),
),
]);
let warnings = super::registry_failure_warnings(&failures);
assert_eq!(warnings.len(), 2);
assert!(warnings[0].starts_with("failed to fetch latest version for alpha:"));
assert!(warnings[1].starts_with("failed to fetch latest version for zeta:"));
}
#[test]
fn batch_wide_failure_warnings_are_sorted_deduplicated_and_name_every_crate() {
let warnings = super::registry_batch_failure_warnings(
&["zeta".to_string(), "alpha".to_string(), "zeta".to_string()],
"client construction failed",
);
assert_eq!(
warnings,
vec![
"failed to fetch latest version for alpha: client construction failed",
"failed to fetch latest version for zeta: client construction failed",
]
);
}
fn skipped_with(reason: SkipReason) -> SkippedDependency {
SkippedDependency {
name: format!("{reason:?}"),
alias: None,
required: "1.0".to_string(),
reason,
dependency_type: DependencyType::Normal,
source: None,
target: None,
}
}
#[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 registry_source_classification_distinguishes_crates_io_alternates_and_non_registry() {
let legacy = "registry+https://github.com/rust-lang/crates.io-index".to_string();
let sparse = "sparse+https://index.crates.io/".to_string();
let registry_index = "registry+https://index.crates.io/".to_string();
let alternate = "registry+https://packages.example.com/index".to_string();
let git = "git+https://example.com/repo.git".to_string();
assert_eq!(classify_registry_source(None), RegistrySource::CratesIo);
assert_eq!(
classify_registry_source(Some(&legacy)),
RegistrySource::CratesIo
);
assert_eq!(
classify_registry_source(Some(&sparse)),
RegistrySource::CratesIo
);
assert_eq!(
classify_registry_source(Some(®istry_index)),
RegistrySource::CratesIo
);
assert_eq!(
classify_registry_source(Some(&alternate)),
RegistrySource::Unsupported
);
assert_eq!(
classify_registry_source(Some(&git)),
RegistrySource::NonRegistry
);
}
#[test]
fn alternate_registry_is_skipped_without_being_queued_for_crates_io() {
let mut alternate = edge("private-crate", "^1.0", 0);
alternate.source = Some("registry+https://packages.example.com/index".to_string());
alternate.target = Some("cfg(unix)".to_string());
let mut names = std::collections::HashSet::new();
let mut edges = Vec::new();
let mut skipped = SkippedCollector::new();
partition_edge(alternate, &mut names, &mut edges, &mut skipped);
assert!(names.is_empty(), "alternate registry must not be queried");
assert!(edges.is_empty());
let skipped = skipped.into_vec();
assert_eq!(skipped.len(), 1);
assert_eq!(skipped[0].reason, SkipReason::UnsupportedRegistry);
assert_eq!(
skipped[0].source.as_deref(),
Some("registry+https://packages.example.com/index")
);
assert_eq!(skipped[0].target.as_deref(), Some("cfg(unix)"));
}
#[test]
fn skipped_collector_keeps_distinct_sources_and_targets() {
let mut names = std::collections::HashSet::new();
let mut edges = Vec::new();
let mut skipped = SkippedCollector::new();
for (source, target) in [
("registry+https://one.example/index", "cfg(unix)"),
("registry+https://two.example/index", "cfg(windows)"),
] {
let mut alternate = edge("private-crate", "^1.0", 0);
alternate.source = Some(source.to_string());
alternate.target = Some(target.to_string());
partition_edge(alternate, &mut names, &mut edges, &mut skipped);
}
let skipped = skipped.into_vec();
assert_eq!(skipped.len(), 2);
assert_eq!(
skipped[0].source.as_deref(),
Some("registry+https://one.example/index")
);
assert_eq!(
skipped[1].source.as_deref(),
Some("registry+https://two.example/index")
);
}
#[test]
fn path_dependency_is_non_registry_and_never_queued() {
let mut path = edge("private-path-crate", "*", 0);
path.is_path = true;
let mut names = std::collections::HashSet::new();
let mut edges = Vec::new();
let mut skipped = SkippedCollector::new();
partition_edge(path, &mut names, &mut edges, &mut skipped);
assert!(names.is_empty());
assert!(edges.is_empty());
assert_eq!(skipped.into_vec()[0].reason, SkipReason::NonRegistry);
}
#[test]
fn repeated_path_dependency_merges_kinds_into_one_checked_unit() {
let mut normal = edge("private-path-crate", "*", 0);
normal.is_path = true;
let mut dev = normal.clone();
dev.dependency_type = DependencyType::Dev;
let mut names = std::collections::HashSet::new();
let mut edges = Vec::new();
let mut skipped = SkippedCollector::new();
partition_edge(dev, &mut names, &mut edges, &mut skipped);
partition_edge(normal, &mut names, &mut edges, &mut skipped);
let skipped = skipped.into_vec();
assert_eq!(skipped.len(), 1);
assert_eq!(skipped[0].dependency_type, DependencyType::Normal);
assert_eq!(not_applicable_count(&skipped), 1);
}
#[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,
checked: 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);
}
}