use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Path, PathBuf};
const MAX_REACHABLE_MANIFESTS: usize = 512;
const DEPENDENCY_TABLES: [&str; 3] = ["dependencies", "build-dependencies", "dev-dependencies"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StaleLockFinding {
pub(crate) lock: PathBuf,
pub(crate) declared_in: PathBuf,
pub(crate) dependency: String,
pub(crate) requirement: String,
pub(crate) locked_versions: Vec<String>,
}
struct DeclaredRequirement {
manifest: PathBuf,
name: String,
requirement: String,
}
#[cfg(test)]
pub(crate) fn check_generated_lock_freshness(generated_paths: &HashSet<PathBuf>) -> Option<anyhow::Error> {
check_generated_lock_freshness_tolerating_pending_publish(generated_paths, Path::new("."), None)
}
fn collect_generated_lock_findings(generated_paths: &HashSet<PathBuf>) -> Vec<StaleLockFinding> {
let mut directories = BTreeSet::new();
for path in generated_paths {
if path.file_name().and_then(|name| name.to_str()) != Some("Cargo.toml") {
continue;
}
if let Some(dir) = path.parent() {
directories.insert(dir.to_path_buf());
}
}
let mut findings = Vec::new();
for dir in &directories {
findings.extend(stale_lock_findings(dir));
}
tracing::debug!(
manifest_dirs = directories.len(),
findings = findings.len(),
"checked generated Rust manifests against their committed lockfiles"
);
findings
}
pub(crate) fn check_generated_lock_freshness_tolerating_pending_publish(
generated_paths: &HashSet<PathBuf>,
workspace_root: &Path,
canonical: Option<&str>,
) -> Option<anyhow::Error> {
let findings = collect_generated_lock_findings(generated_paths);
if findings.is_empty() {
return None;
}
let Some(canonical) = canonical else {
return Some(anyhow::anyhow!(stale_lock_message(&findings)));
};
let tracked = crate::cli::git::tracked_paths_under(workspace_root);
let blocked: std::collections::HashMap<PathBuf, String> =
crate::cli::commands::version_manifests::discover_cargo_locks(workspace_root, canonical, tracked.as_ref())
.into_iter()
.filter_map(|lock| lock.blocked_on_publish.map(|waiting_on| (lock.path, waiting_on)))
.collect();
let (pending, real): (Vec<_>, Vec<_>) = findings
.into_iter()
.partition(|finding| super::super::version_lockfiles::explained_by_pending_publish(finding, &blocked));
if !pending.is_empty() {
tracing::warn!(
"{} committed Cargo.lock pin(s) below require this crate's own version, which is not on the \
registry yet -- expected after a version bump; resolves once the release publishes:\n{}",
pending.len(),
stale_lock_message(&pending)
);
}
if real.is_empty() {
None
} else {
Some(anyhow::anyhow!(stale_lock_message(&real)))
}
}
pub(crate) fn stale_lock_findings(manifest_dir: &Path) -> Vec<StaleLockFinding> {
let manifest_path = manifest_dir.join("Cargo.toml");
let lock_path = manifest_dir.join("Cargo.lock");
if !manifest_path.is_file() {
return Vec::new();
}
let Ok(lock_text) = std::fs::read_to_string(&lock_path) else {
return Vec::new();
};
let locked = locked_versions(&lock_text);
if locked.is_empty() {
return Vec::new();
}
let mut findings = Vec::new();
for declared in reachable_requirements(&manifest_path) {
let Some(versions) = locked.get(&declared.name) else {
continue;
};
let Ok(requirement) = semver::VersionReq::parse(&declared.requirement) else {
continue;
};
if versions.iter().any(|version| requirement.matches(version)) {
continue;
}
findings.push(StaleLockFinding {
lock: lock_path.clone(),
declared_in: declared.manifest.clone(),
dependency: declared.name.clone(),
requirement: declared.requirement.clone(),
locked_versions: versions.iter().map(ToString::to_string).collect(),
});
}
findings.sort_by(|left, right| {
left.dependency
.cmp(&right.dependency)
.then_with(|| left.requirement.cmp(&right.requirement))
});
findings.dedup_by(|left, right| left.dependency == right.dependency && left.requirement == right.requirement);
findings
}
fn locked_versions(lock_text: &str) -> BTreeMap<String, Vec<semver::Version>> {
let mut locked: BTreeMap<String, Vec<semver::Version>> = BTreeMap::new();
let Some(packages) = toml::from_str::<toml::Value>(lock_text)
.ok()
.and_then(|value| value.get("package").and_then(toml::Value::as_array).cloned())
else {
return locked;
};
for package in packages {
let (Some(name), Some(version)) = (
package.get("name").and_then(toml::Value::as_str),
package.get("version").and_then(toml::Value::as_str),
) else {
continue;
};
if let Ok(parsed) = semver::Version::parse(version) {
locked.entry(name.to_string()).or_default().push(parsed);
}
}
for versions in locked.values_mut() {
versions.sort();
}
locked
}
struct QueuedManifest {
path: PathBuf,
requested_features: Vec<String>,
default_features: bool,
}
fn reachable_requirements(root_manifest: &Path) -> Vec<DeclaredRequirement> {
let mut requirements = Vec::new();
let mut queue = vec![QueuedManifest {
path: root_manifest.to_path_buf(),
requested_features: Vec::new(),
default_features: true,
}];
let mut visited: HashSet<PathBuf> = HashSet::new();
while let Some(item) = queue.pop() {
if visited.len() >= MAX_REACHABLE_MANIFESTS {
tracing::warn!(
root = %root_manifest.display(),
limit = MAX_REACHABLE_MANIFESTS,
"stopped walking path dependencies at the manifest limit; lock freshness for this \
crate was checked against a partial requirement set"
);
break;
}
let key = std::fs::canonicalize(&item.path).unwrap_or_else(|_| item.path.clone());
if !visited.insert(key) {
continue;
}
let Ok(text) = std::fs::read_to_string(&item.path) else {
continue;
};
let Ok(document) = toml::from_str::<toml::Value>(&text) else {
continue;
};
let is_root = item.path == root_manifest;
let activated_optional_deps =
activated_optional_dependencies(&document, &item.requested_features, item.default_features);
collect_requirements(
&item.path,
&document,
is_root,
&activated_optional_deps,
&mut requirements,
&mut queue,
);
}
requirements
}
fn activated_optional_dependencies(
document: &toml::Value,
requested: &[String],
default_features: bool,
) -> HashSet<String> {
let features_table = document.get("features").and_then(toml::Value::as_table);
let mut activated_deps = HashSet::new();
let mut queue: Vec<String> = requested.to_vec();
if default_features {
queue.push("default".to_string());
}
let mut visited_features: HashSet<String> = HashSet::new();
while let Some(feature) = queue.pop() {
if !visited_features.insert(feature.clone()) {
continue;
}
let Some(entries) = features_table
.and_then(|table| table.get(feature.as_str()))
.and_then(toml::Value::as_array)
else {
continue;
};
for entry in entries {
let Some(entry) = entry.as_str() else { continue };
if let Some(dep_key) = entry.strip_prefix("dep:") {
activated_deps.insert(dep_key.to_string());
} else if let Some((dep_key, _sub_feature)) = entry.split_once('/') {
activated_deps.insert(dep_key.trim_end_matches('?').to_string());
} else {
activated_deps.insert(entry.to_string());
queue.push(entry.to_string());
}
}
}
activated_deps
}
fn edge_feature_request(table: &toml::Table, inherited_table: Option<&toml::Table>) -> (Vec<String>, bool) {
let mut features: Vec<String> = inherited_table
.and_then(|entry| entry.get("features"))
.and_then(toml::Value::as_array)
.into_iter()
.flatten()
.chain(
table
.get("features")
.and_then(toml::Value::as_array)
.into_iter()
.flatten(),
)
.filter_map(|value| value.as_str().map(str::to_string))
.collect();
features.sort();
features.dedup();
let default_features = table
.get("default-features")
.or_else(|| inherited_table.and_then(|entry| entry.get("default-features")))
.and_then(toml::Value::as_bool)
.unwrap_or(true);
(features, default_features)
}
fn collect_requirements(
manifest_path: &Path,
document: &toml::Value,
include_dev: bool,
activated_optional_deps: &HashSet<String>,
requirements: &mut Vec<DeclaredRequirement>,
queue: &mut Vec<QueuedManifest>,
) {
let mut tables: Vec<&toml::Value> = vec![document];
if let Some(targets) = document.get("target").and_then(toml::Value::as_table) {
tables.extend(targets.values());
}
for table in tables {
for section in DEPENDENCY_TABLES {
if section == "dev-dependencies" && !include_dev {
continue;
}
let Some(entries) = table.get(section).and_then(toml::Value::as_table) else {
continue;
};
for (alias, spec) in entries {
collect_one_requirement(manifest_path, alias, spec, activated_optional_deps, requirements, queue);
}
}
}
}
fn resolve_dependency_identity(
manifest_path: &Path,
alias: &str,
table: &toml::Table,
) -> (Option<toml::Value>, String) {
let inherited = table
.get("workspace")
.and_then(toml::Value::as_bool)
.unwrap_or(false)
.then(|| workspace_dependency_spec(manifest_path, alias))
.flatten();
let name = inherited
.as_ref()
.and_then(toml::Value::as_table)
.and_then(|entry| entry.get("package"))
.or_else(|| table.get("package"))
.and_then(toml::Value::as_str)
.unwrap_or(alias)
.to_string();
(inherited, name)
}
fn collect_one_requirement(
manifest_path: &Path,
alias: &str,
spec: &toml::Value,
activated_optional_deps: &HashSet<String>,
requirements: &mut Vec<DeclaredRequirement>,
queue: &mut Vec<QueuedManifest>,
) {
if let Some(requirement) = spec.as_str() {
requirements.push(DeclaredRequirement {
manifest: manifest_path.to_path_buf(),
name: alias.to_string(),
requirement: requirement.to_string(),
});
return;
}
let Some(table) = spec.as_table() else {
return;
};
let (inherited, name) = resolve_dependency_identity(manifest_path, alias, table);
let inherited_table = inherited.as_ref().and_then(toml::Value::as_table);
let is_optional = table.get("optional").and_then(toml::Value::as_bool).unwrap_or(false);
if is_optional && !activated_optional_deps.contains(alias) {
return;
}
let (requested_features, default_features) = edge_feature_request(table, inherited_table);
if let Some(relative) = table.get("path").and_then(toml::Value::as_str)
&& let Some(dir) = manifest_path.parent()
{
queue.push(QueuedManifest {
path: normalize_lexically(&dir.join(relative).join("Cargo.toml")),
requested_features,
default_features,
});
}
let is_source_pinned = |entry: &toml::Table| entry.contains_key("path") || entry.contains_key("git");
if is_source_pinned(table) || inherited_table.is_some_and(is_source_pinned) {
return;
}
let requirement = match inherited.as_ref() {
Some(value) => value
.as_str()
.or_else(|| value.get("version").and_then(toml::Value::as_str)),
None => table.get("version").and_then(toml::Value::as_str),
};
let Some(requirement) = requirement else {
return;
};
requirements.push(DeclaredRequirement {
manifest: manifest_path.to_path_buf(),
name,
requirement: requirement.to_string(),
});
}
fn normalize_lexically(path: &Path) -> PathBuf {
let mut components: Vec<std::path::Component<'_>> = Vec::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir if matches!(components.last(), Some(std::path::Component::Normal(_))) => {
components.pop();
}
other => components.push(other),
}
}
components.into_iter().collect()
}
fn workspace_dependency_spec(manifest_path: &Path, alias: &str) -> Option<toml::Value> {
let mut directory = manifest_path.parent();
while let Some(current) = directory {
let candidate = current.join("Cargo.toml");
if let Ok(text) = std::fs::read_to_string(&candidate)
&& let Ok(document) = toml::from_str::<toml::Value>(&text)
&& let Some(workspace) = document.get("workspace")
{
return workspace
.get("dependencies")
.and_then(toml::Value::as_table)
.and_then(|table| table.get(alias))
.cloned();
}
directory = current.parent();
}
None
}
fn stale_lock_message(findings: &[StaleLockFinding]) -> String {
let mut message = format!(
"{} committed Cargo.lock pin(s) cannot satisfy a requirement from a manifest alef generated; \
`cargo metadata --locked` and `cargo build --locked` will fail in these directories:",
findings.len()
);
for finding in findings {
message.push_str(&format!(
"\n - {}: `{}` is required as `{}` by {}, but the lock pins only {}. Fix with: cargo \
update --manifest-path {} -p {}",
finding.lock.display(),
finding.dependency,
finding.requirement,
finding.declared_in.display(),
finding.locked_versions.join(", "),
finding
.lock
.parent()
.unwrap_or(Path::new("."))
.join("Cargo.toml")
.display(),
finding.dependency,
));
}
message.push_str(
"\nA pin held back on purpose belongs in the manifest that declares the requirement -- a lockfile \
cannot record an exception to its own resolution.",
);
message
}
#[cfg(test)]
#[path = "cargo_tests.rs"]
mod tests;