use super::*;
const GEMFILE_DIR_RELATIVE: &str = "e2e/ruby";
const GEM_DEPENDENCY: &str = "demo_gem";
const GEM_STALE_REQUIREMENT: &str = "1.3.0";
const GEM_FRESH_PIN: &str = "1.2.3";
fn gemfile_dir(root: &Path) -> PathBuf {
root.join(GEMFILE_DIR_RELATIVE)
}
fn write_gemfile(root: &Path, requirement: &str) -> PathBuf {
let dir = gemfile_dir(root);
std::fs::create_dir_all(&dir).expect("create gemfile dir");
let manifest = dir.join("Gemfile");
std::fs::write(
&manifest,
format!("source 'https://rubygems.org'\n\ngem '{GEM_DEPENDENCY}', '{requirement}'\ngem 'rspec'\n"),
)
.expect("write Gemfile");
manifest
}
fn write_gemfile_lock(root: &Path, locked_version: &str) {
std::fs::write(
gemfile_dir(root).join("Gemfile.lock"),
format!(
"GEM\n remote: https://rubygems.org/\n specs:\n {GEM_DEPENDENCY} ({locked_version})\n \
rspec (3.12.0)\n rspec-core (~> 3.12.0)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n \
{GEM_DEPENDENCY} (= {locked_version})\n rspec\n\nBUNDLED WITH\n 2.4.10\n"
),
)
.expect("write Gemfile.lock");
}
#[test]
fn stale_gemfile_lock_findings_reports_a_requirement_no_locked_version_satisfies() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
write_gemfile(root, GEM_STALE_REQUIREMENT);
write_gemfile_lock(root, GEM_FRESH_PIN);
let findings = stale_gemfile_lock_findings(&gemfile_dir(root));
assert_eq!(findings.len(), 1, "expected exactly one finding, got: {findings:?}");
let finding = &findings[0];
assert_eq!(finding.dependency, GEM_DEPENDENCY);
assert_eq!(finding.requirement, GEM_STALE_REQUIREMENT);
assert_eq!(finding.locked_version, GEM_FRESH_PIN);
assert_eq!(finding.lock, gemfile_dir(root).join("Gemfile.lock"));
assert_eq!(finding.declared_in, gemfile_dir(root).join("Gemfile"));
}
#[test]
fn stale_gemfile_lock_findings_accepts_a_lock_that_matches() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
write_gemfile(root, GEM_FRESH_PIN);
write_gemfile_lock(root, GEM_FRESH_PIN);
let findings = stale_gemfile_lock_findings(&gemfile_dir(root));
assert!(
findings.is_empty(),
"a lock matching the Gemfile must be reported clean: {findings:?}"
);
}
#[test]
fn stale_gemfile_lock_findings_ignores_a_dependency_absent_from_the_lock() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
write_gemfile(root, GEM_STALE_REQUIREMENT);
std::fs::write(
gemfile_dir(root).join("Gemfile.lock"),
"GEM\n remote: https://rubygems.org/\n specs:\n other_gem (2.0.0)\n\nPLATFORMS\n ruby\n\n\
DEPENDENCIES\n other_gem\n\nBUNDLED WITH\n 2.4.10\n",
)
.expect("write Gemfile.lock");
let findings = stale_gemfile_lock_findings(&gemfile_dir(root));
assert!(
findings.is_empty(),
"a gem missing from the lock is not evidence of staleness: {findings:?}"
);
}
#[test]
fn stale_gemfile_lock_findings_ignores_a_path_dependency() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
let dir = gemfile_dir(root);
std::fs::create_dir_all(&dir).expect("create gemfile dir");
std::fs::write(
dir.join("Gemfile"),
format!("source 'https://rubygems.org'\n\ngem '{GEM_DEPENDENCY}', path: '../../packages/ruby'\n"),
)
.expect("write Gemfile");
write_gemfile_lock(root, GEM_FRESH_PIN);
let findings = stale_gemfile_lock_findings(&dir);
assert!(
findings.is_empty(),
"a path dependency carries no RubyGems version pin to compare: {findings:?}"
);
}
#[test]
fn stale_gemfile_lock_findings_accepts_a_pessimistic_two_component_range() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
write_gemfile(root, "~> 1.2");
write_gemfile_lock(root, "1.9.0");
let findings = stale_gemfile_lock_findings(&gemfile_dir(root));
assert!(
findings.is_empty(),
"~> 1.2 must accept 1.9.0 (only the major is bumped): {findings:?}"
);
}
#[test]
fn stale_gemfile_lock_findings_rejects_a_pessimistic_range_violation() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
write_gemfile(root, "~> 1.2");
write_gemfile_lock(root, "2.0.0");
let findings = stale_gemfile_lock_findings(&gemfile_dir(root));
assert_eq!(
findings.len(),
1,
"~> 1.2 must reject 2.0.0, which crossed the major-version boundary: {findings:?}"
);
}
#[test]
fn stale_gemfile_lock_findings_skips_a_directory_with_no_committed_lock() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
write_gemfile(root, GEM_STALE_REQUIREMENT);
let findings = stale_gemfile_lock_findings(&gemfile_dir(root));
assert!(
findings.is_empty(),
"a directory with no lock has nothing to check: {findings:?}"
);
}
#[test]
fn check_generated_gemfile_lock_freshness_names_the_dependency_and_the_remedy() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
let manifest = write_gemfile(root, GEM_STALE_REQUIREMENT);
write_gemfile_lock(root, GEM_FRESH_PIN);
let generated: HashSet<PathBuf> = [manifest].into_iter().collect();
let error = check_generated_gemfile_lock_freshness(&generated, root).expect("a stale lock must fail the run");
let message = format!("{error:#}");
assert!(
message.contains(GEM_DEPENDENCY),
"message must name the dependency: {message}"
);
assert!(
message.contains(GEM_STALE_REQUIREMENT),
"message must name the Gemfile requirement: {message}"
);
assert!(
message.contains(GEM_FRESH_PIN),
"message must name the locked version: {message}"
);
assert!(
message.contains("bundle update"),
"message must name the remedy: {message}"
);
assert!(
message.contains(&gemfile_dir(root).join("Gemfile.lock").display().to_string()),
"message must name the lock: {message}"
);
}
#[test]
fn check_generated_gemfile_lock_freshness_passes_a_resolvable_lock() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
let manifest = write_gemfile(root, GEM_FRESH_PIN);
write_gemfile_lock(root, GEM_FRESH_PIN);
let generated: HashSet<PathBuf> = [manifest].into_iter().collect();
assert!(
check_generated_gemfile_lock_freshness(&generated, root).is_none(),
"a resolvable lock must not fail the run"
);
}
#[test]
fn check_generated_gemfile_lock_freshness_ignores_non_manifest_paths() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
write_gemfile(root, GEM_STALE_REQUIREMENT);
write_gemfile_lock(root, GEM_FRESH_PIN);
let generated: HashSet<PathBuf> = [gemfile_dir(root).join("spec/basic_spec.rb")].into_iter().collect();
assert!(check_generated_gemfile_lock_freshness(&generated, root).is_none());
}
mod pending_publish {
use super::*;
use crate::core::config::ResolvedCrateConfig;
use crate::core::config::e2e::{E2eConfig, PackageRef, RegistryConfig};
fn resolved_cfg_with_ruby_registry_package(pkg_name: &str, pkg_version: &str) -> ResolvedCrateConfig {
let e2e = E2eConfig {
registry: RegistryConfig {
packages: [(
"ruby".to_string(),
PackageRef {
name: Some(pkg_name.to_string()),
version: Some(pkg_version.to_string()),
..PackageRef::default()
},
)]
.into_iter()
.collect(),
..RegistryConfig::default()
},
..E2eConfig::default()
};
ResolvedCrateConfig {
e2e: Some(e2e),
..ResolvedCrateConfig::default()
}
}
#[test]
fn plain_check_still_fails_on_a_pending_publish_disagreement() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
let manifest = write_gemfile(root, GEM_STALE_REQUIREMENT);
write_gemfile_lock(root, GEM_FRESH_PIN);
let generated: HashSet<PathBuf> = [manifest].into_iter().collect();
assert!(
check_generated_gemfile_lock_freshness(&generated, root).is_some(),
"control: the plain check has no pending-publish exemption and must still fail here"
);
}
#[test]
fn tolerating_variant_warns_instead_of_failing_when_the_requirement_matches_the_configured_registry_package() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
let manifest = write_gemfile(root, GEM_STALE_REQUIREMENT);
write_gemfile_lock(root, GEM_FRESH_PIN);
let generated: HashSet<PathBuf> = [manifest].into_iter().collect();
let resolved_cfg = resolved_cfg_with_ruby_registry_package(GEM_DEPENDENCY, GEM_STALE_REQUIREMENT);
let result =
check_generated_gemfile_lock_freshness_tolerating_pending_publish(&generated, root, Some(&resolved_cfg));
assert!(
result.is_none(),
"a disagreement fully explained by this crate's own configured registry \
self-dependency must not fail the run: {result:?}"
);
}
#[test]
fn tolerating_variant_without_resolved_cfg_behaves_like_the_plain_check() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
let manifest = write_gemfile(root, GEM_STALE_REQUIREMENT);
write_gemfile_lock(root, GEM_FRESH_PIN);
let generated: HashSet<PathBuf> = [manifest].into_iter().collect();
assert!(
check_generated_gemfile_lock_freshness_tolerating_pending_publish(&generated, root, None).is_some(),
"no resolved config means no exemption is possible; this must still fail"
);
}
#[test]
fn tolerating_variant_still_fails_on_a_disagreement_not_explained_by_pending_publish() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
let manifest = write_gemfile(root, GEM_STALE_REQUIREMENT);
write_gemfile_lock(root, GEM_FRESH_PIN);
let generated: HashSet<PathBuf> = [manifest].into_iter().collect();
let resolved_cfg = resolved_cfg_with_ruby_registry_package("unrelated_gem", "9.9.9");
assert!(
check_generated_gemfile_lock_freshness_tolerating_pending_publish(&generated, root, Some(&resolved_cfg))
.is_some(),
"a third-party lock drift unrelated to this crate's own registry self-dependency \
must still fail the run"
);
}
}