use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Path, PathBuf};
use super::shared::{registered_unmarkable_manifest_dirs, registry_self_dependency};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StaleGemfileLockFinding {
pub(crate) lock: PathBuf,
pub(crate) declared_in: PathBuf,
pub(crate) dependency: String,
pub(crate) requirement: String,
pub(crate) locked_version: String,
}
#[cfg(test)]
pub(crate) fn check_generated_gemfile_lock_freshness(
generated_paths: &HashSet<PathBuf>,
base_dir: &Path,
) -> Option<anyhow::Error> {
check_generated_gemfile_lock_freshness_tolerating_pending_publish(generated_paths, base_dir, None)
}
fn collect_generated_gemfile_lock_findings(
generated_paths: &HashSet<PathBuf>,
base_dir: &Path,
) -> Vec<StaleGemfileLockFinding> {
let mut directories = BTreeSet::new();
for path in generated_paths {
if path.file_name().and_then(|name| name.to_str()) != Some("Gemfile") {
continue;
}
if let Some(dir) = path.parent() {
directories.insert(dir.to_path_buf());
}
}
directories.extend(registered_unmarkable_manifest_dirs(base_dir, "Gemfile"));
let mut findings = Vec::new();
for dir in &directories {
findings.extend(stale_gemfile_lock_findings(dir));
}
tracing::debug!(
manifest_dirs = directories.len(),
findings = findings.len(),
"checked generated Gemfile files against their committed Gemfile.lock"
);
findings
}
pub(crate) fn check_generated_gemfile_lock_freshness_tolerating_pending_publish(
generated_paths: &HashSet<PathBuf>,
base_dir: &Path,
resolved_cfg: Option<&crate::core::config::ResolvedCrateConfig>,
) -> Option<anyhow::Error> {
let findings = collect_generated_gemfile_lock_findings(generated_paths, base_dir);
if findings.is_empty() {
return None;
}
let Some(self_dependency) = resolved_cfg.and_then(|cfg| {
registry_self_dependency(
cfg,
"ruby",
|package| package.name.clone(),
normalize_gemfile_requirement,
)
}) else {
return Some(anyhow::anyhow!(stale_gemfile_lock_message(&findings)));
};
let (pending, real): (Vec<_>, Vec<_>) = findings.into_iter().partition(|finding| {
finding.dependency == self_dependency.name && finding.requirement == self_dependency.requirement
});
if !pending.is_empty() {
tracing::warn!(
"{} committed Gemfile.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_gemfile_lock_message(&pending)
);
}
if real.is_empty() {
None
} else {
Some(anyhow::anyhow!(stale_gemfile_lock_message(&real)))
}
}
fn normalize_gemfile_requirement(raw: &str) -> String {
let trimmed = raw.trim_start();
if trimmed.starts_with(['~', '>', '<', '=', '!']) {
raw.to_string()
} else {
crate::core::version::to_rubygems_prerelease(raw)
}
}
pub(crate) fn stale_gemfile_lock_findings(gemfile_dir: &Path) -> Vec<StaleGemfileLockFinding> {
let manifest_path = gemfile_dir.join("Gemfile");
let lock_path = gemfile_dir.join("Gemfile.lock");
if !manifest_path.is_file() {
return Vec::new();
}
let Ok(manifest_text) = std::fs::read_to_string(&manifest_path) else {
return Vec::new();
};
let Ok(lock_text) = std::fs::read_to_string(&lock_path) else {
return Vec::new();
};
let locked = locked_gemfile_versions(&lock_text);
if locked.is_empty() {
return Vec::new();
}
let mut findings = Vec::new();
for (name, requirement) in declared_gem_constraints(&manifest_text) {
let Some(locked_version) = locked.get(&name) else {
continue;
};
match ruby_constraint_matches(&requirement, locked_version) {
Some(true) => {}
Some(false) => findings.push(StaleGemfileLockFinding {
lock: lock_path.clone(),
declared_in: manifest_path.clone(),
dependency: name,
requirement,
locked_version: locked_version.to_string(),
}),
None => {}
}
}
findings.sort_by(|left, right| left.dependency.cmp(&right.dependency));
findings
}
fn declared_gem_constraints(gemfile_text: &str) -> Vec<(String, String)> {
let mut declared = Vec::new();
for line in gemfile_text.lines() {
let trimmed = line.trim();
let Some(rest) = trimmed.strip_prefix("gem ").or_else(|| trimmed.strip_prefix("gem(")) else {
continue;
};
let quoted: Vec<&str> = rest.split(['\'', '"']).collect();
let Some(name) = quoted.get(1) else { continue };
let Some(requirement) = quoted.get(3) else { continue };
if rest.contains("path:") || rest.contains("git:") {
continue;
}
declared.push((name.to_string(), requirement.to_string()));
}
declared
}
fn locked_gemfile_versions(lock_text: &str) -> BTreeMap<String, String> {
let mut locked = BTreeMap::new();
let mut in_gem_specs = false;
for line in lock_text.lines() {
if line == "GEM" {
in_gem_specs = false;
continue;
}
if !line.starts_with(' ') {
in_gem_specs = false;
continue;
}
if line == " specs:" {
in_gem_specs = true;
continue;
}
if !in_gem_specs {
continue;
}
let Some(entry) = line.strip_prefix(" ").filter(|rest| !rest.starts_with(' ')) else {
continue;
};
let Some((name, rest)) = entry.split_once(" (") else {
continue;
};
let Some(version) = rest.strip_suffix(')') else {
continue;
};
locked.insert(name.to_string(), version.to_string());
}
locked
}
fn ruby_constraint_matches(constraint: &str, locked: &str) -> Option<bool> {
let trimmed = constraint.trim();
if trimmed.is_empty() || trimmed.contains(',') {
return None;
}
let locked_version = semver::Version::parse(&pad_to_three(locked)).ok()?;
if let Some(rest) = trimmed.strip_prefix("~>") {
return Some(ruby_pessimistic_matches(rest.trim(), &locked_version));
}
for op in ["<=", ">=", "!=", "<", ">", "="] {
if let Some(rest) = trimmed.strip_prefix(op) {
let target = semver::Version::parse(&pad_to_three(rest.trim())).ok()?;
return Some(match op {
"<=" => locked_version <= target,
">=" => locked_version >= target,
"!=" => locked_version != target,
"<" => locked_version < target,
">" => locked_version > target,
"=" => locked_version == target,
_ => unreachable!("op set above is exhaustive for this match"),
});
}
}
let target = semver::Version::parse(&pad_to_three(trimmed)).ok()?;
Some(locked_version == target)
}
fn pad_to_three(raw: &str) -> String {
let mut parts: Vec<&str> = raw.split('.').collect();
while parts.len() < 3 {
parts.push("0");
}
parts.join(".")
}
fn ruby_pessimistic_matches(rest: &str, locked: &semver::Version) -> bool {
let written_components = rest.split('.').filter(|part| !part.is_empty()).count();
let Ok(lower) = semver::Version::parse(&pad_to_three(rest)) else {
return false;
};
if *locked < lower {
return false;
}
let upper = if written_components >= 3 {
semver::Version::new(lower.major, lower.minor + 1, 0)
} else {
semver::Version::new(lower.major + 1, 0, 0)
};
*locked < upper
}
fn stale_gemfile_lock_message(findings: &[StaleGemfileLockFinding]) -> String {
let mut message = format!(
"{} committed Gemfile.lock pin(s) cannot satisfy a requirement from a Gemfile alef generated; \
`bundle install --deployment` (and `bundle check`) will fail in these directories:",
findings.len()
);
for finding in findings {
message.push_str(&format!(
"\n - {}: `{}` is required as `{}` by {}, but the lock pins {}. Fix with: bundle update {} \
--gemfile {}",
finding.lock.display(),
finding.dependency,
finding.requirement,
finding.declared_in.display(),
finding.locked_version,
finding.dependency,
finding.declared_in.display(),
));
}
message.push_str(
"\nA pin held back on purpose belongs in the Gemfile -- a lockfile cannot record an exception to \
its own resolution.",
);
message
}
#[cfg(test)]
#[path = "ruby_tests.rs"]
mod tests;