use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
use crate::cli::commands::version_manifests::discover_cargo_locks;
use crate::cli::git::tracked_paths_under;
use super::collect_alef_headered_paths;
use super::lock_freshness::{StaleLockFinding, stale_lock_findings};
pub(super) fn relock_cargo_lockfiles(canonical: &str) {
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let tracked = tracked_paths_under(&workspace_root);
if tracked.is_none() {
warn!(
"version-sync: cannot determine which files are git-tracked (not a git work tree, or `git` is \
unavailable) — lockfile relock falls back to an unfiltered disk walk and may touch build-staging \
copies"
);
}
for lock in discover_cargo_locks(&workspace_root, canonical, tracked.as_ref()) {
if lock.blocked_on_publish.is_some() {
debug!(lock = %lock.path.display(), "version-sync: skipping relock — blocked on publish");
continue;
}
let Some(dir) = lock.path.parent() else {
continue;
};
info!("Relocking {} after version sync", lock.path.display());
relock_one(dir, &lock.path);
}
}
pub(super) fn retry_blocked_lockfiles(canonical: &str) {
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let tracked = tracked_paths_under(&workspace_root);
for lock in discover_cargo_locks(&workspace_root, canonical, tracked.as_ref()) {
let Some(waiting_on) = lock.blocked_on_publish.as_deref() else {
continue;
};
let Some(dir) = lock.path.parent() else {
continue;
};
info!(
lock = %lock.path.display(),
waiting_on,
"version-sync: retrying relock for a lock previously blocked on a pending release"
);
relock_one(dir, &lock.path);
}
}
pub(super) fn relock_lockfiles_beside_changed_manifests(changed_paths: &HashSet<PathBuf>) {
for path in changed_paths {
if path.file_name().and_then(|name| name.to_str()) != Some("Cargo.toml") {
continue;
}
let Some(dir) = path.parent() else {
continue;
};
let lock_path = dir.join("Cargo.lock");
if !lock_path.exists() {
continue;
}
info!("Relocking {} after its generated manifest changed", lock_path.display());
relock_one(dir, &lock_path);
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RelockMode {
Offline,
Online,
}
#[derive(Clone, Copy, Debug)]
struct CargoStatus {
successful: bool,
code: Option<i32>,
}
impl CargoStatus {
fn from_exit_status(status: std::process::ExitStatus) -> Self {
Self {
successful: status.success(),
code: status.code(),
}
}
#[cfg(test)]
fn success() -> Self {
Self {
successful: true,
code: Some(0),
}
}
#[cfg(test)]
fn failed(code: Option<i32>) -> Self {
Self {
successful: false,
code,
}
}
}
#[derive(Debug)]
enum RelockFailure {
OfflineCommand(std::io::Error),
OnlineCommand {
offline_code: Option<i32>,
error: std::io::Error,
},
BothResolvers {
offline_code: Option<i32>,
online_code: Option<i32>,
},
}
fn attempt_relock_with<F>(mut run: F) -> Result<RelockMode, RelockFailure>
where
F: FnMut(RelockMode) -> std::io::Result<CargoStatus>,
{
let offline = run(RelockMode::Offline).map_err(RelockFailure::OfflineCommand)?;
if offline.successful {
return Ok(RelockMode::Offline);
}
let online = run(RelockMode::Online).map_err(|error| RelockFailure::OnlineCommand {
offline_code: offline.code,
error,
})?;
if online.successful {
return Ok(RelockMode::Online);
}
Err(RelockFailure::BothResolvers {
offline_code: offline.code,
online_code: online.code,
})
}
fn relock_args(mode: RelockMode) -> &'static [&'static str] {
match mode {
RelockMode::Offline => &["update", "--offline", "-w"],
RelockMode::Online => &["update", "-w"],
}
}
fn relock_one(dir: &Path, lock_path: &Path) {
let outcome = attempt_relock_with(|mode| {
std::process::Command::new("cargo")
.args(relock_args(mode))
.current_dir(dir)
.status()
.map(CargoStatus::from_exit_status)
});
match outcome {
Ok(RelockMode::Offline) => {}
Ok(RelockMode::Online) => {
info!(
lock = %lock_path.display(),
"Relocked with registry access after the offline attempt failed"
);
}
Err(RelockFailure::OfflineCommand(error)) => {
warn!(
lock = %lock_path.display(),
%error,
"could not run cargo update for this lockfile; it may still be stale against its manifest"
);
}
Err(RelockFailure::OnlineCommand { offline_code, error }) => {
warn!(
lock = %lock_path.display(),
?offline_code,
%error,
"cargo update failed offline, then the registry-enabled retry could not run; the lockfile may \
still be stale against its manifest"
);
}
Err(RelockFailure::BothResolvers {
offline_code,
online_code,
}) => {
warn!(
lock = %lock_path.display(),
?offline_code,
?online_code,
"cargo update -w failed both offline and with registry access; the lockfile may still be stale \
against its manifest. Resolve the dependency conflict in that directory before running \
`cargo check --locked`"
);
}
}
}
fn explained_by_pending_publish(finding: &StaleLockFinding, blocked: &HashMap<PathBuf, String>) -> bool {
let Some(waiting_on) = blocked.get(&finding.lock) else {
return false;
};
waiting_on.split('@').next() == Some(finding.dependency.as_str())
}
pub(crate) fn check_release_lock_freshness(workspace_root: &Path, canonical: &str) -> Option<anyhow::Error> {
let tracked = tracked_paths_under(workspace_root);
let blocked: HashMap<PathBuf, String> = 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 mut manifest_dirs: HashSet<PathBuf> = HashSet::new();
for path in collect_alef_headered_paths(workspace_root) {
if path.file_name().and_then(|name| name.to_str()) != Some("Cargo.toml") {
continue;
}
if let Some(dir) = path.parent() {
manifest_dirs.insert(dir.to_path_buf());
}
}
let mut findings: Vec<StaleLockFinding> = Vec::new();
for dir in &manifest_dirs {
findings.extend(
stale_lock_findings(dir)
.into_iter()
.filter(|finding| !explained_by_pending_publish(finding, &blocked)),
);
}
if findings.is_empty() {
return None;
}
Some(anyhow::anyhow!(release_lock_message(&findings)))
}
fn release_lock_message(findings: &[StaleLockFinding]) -> String {
let mut message = format!(
"{} committed Cargo.lock pin(s) cannot satisfy a requirement reachable from a manifest alef \
generated, and this is not this release's own pending, not-yet-published version. `cargo \
metadata --locked` (and every `cargo build --locked` / CI job) will fail in these \
directories once this release is tagged and pushed. 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
}
#[cfg(test)]
#[path = "version_lockfiles_tests.rs"]
mod tests;