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,
}
pub(crate) fn check_generated_lock_freshness(generated_paths: &HashSet<PathBuf>) -> Option<anyhow::Error> {
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"
);
if findings.is_empty() {
return None;
}
Some(anyhow::anyhow!(stale_lock_message(&findings)))
}
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
}
fn reachable_requirements(root_manifest: &Path) -> Vec<DeclaredRequirement> {
let mut requirements = Vec::new();
let mut queue = vec![root_manifest.to_path_buf()];
let mut visited: HashSet<PathBuf> = HashSet::new();
while let Some(manifest_path) = 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(&manifest_path).unwrap_or_else(|_| manifest_path.clone());
if !visited.insert(key) {
continue;
}
let Ok(text) = std::fs::read_to_string(&manifest_path) else {
continue;
};
let Ok(document) = toml::from_str::<toml::Value>(&text) else {
continue;
};
let is_root = manifest_path == root_manifest;
collect_requirements(&manifest_path, &document, is_root, &mut requirements, &mut queue);
}
requirements
}
fn collect_requirements(
manifest_path: &Path,
document: &toml::Value,
include_dev: bool,
requirements: &mut Vec<DeclaredRequirement>,
queue: &mut Vec<PathBuf>,
) {
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, requirements, queue);
}
}
}
}
fn collect_one_requirement(
manifest_path: &Path,
alias: &str,
spec: &toml::Value,
requirements: &mut Vec<DeclaredRequirement>,
queue: &mut Vec<PathBuf>,
) {
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 = table
.get("workspace")
.and_then(toml::Value::as_bool)
.unwrap_or(false)
.then(|| workspace_dependency_spec(manifest_path, alias))
.flatten();
let inherited_table = inherited.as_ref().and_then(toml::Value::as_table);
let name = inherited_table
.and_then(|entry| entry.get("package"))
.or_else(|| table.get("package"))
.and_then(toml::Value::as_str)
.unwrap_or(alias);
if let Some(relative) = table.get("path").and_then(toml::Value::as_str)
&& let Some(dir) = manifest_path.parent()
{
queue.push(normalize_lexically(&dir.join(relative).join("Cargo.toml")));
}
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: name.to_string(),
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 reachable from a manifest \
alef generated. `cargo metadata --locked` (and every `cargo build --locked` / CI job) \
will fail in these directories even though generation itself succeeded. Alef does not \
author lockfiles, so this is reported rather than rewritten:",
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(
"\nIf a pin is intentionally held back, resolve it in the manifest that declares the \
requirement — a lockfile cannot record an exception to its own resolution.",
);
message
}
#[cfg(test)]
#[path = "lock_freshness_tests.rs"]
mod tests;