pmacs/tests/m7_6_acceptance.rs

862 lines
29 KiB
Rust

// tests/m7_6_acceptance.rs --- Acceptance tests for T M7.6 (lockfile).
//! Drives lockfile generation, round-trip, hash verification, and the
//! `UpdatePolicy` matrix end-to-end against real bare Git repositories
//! built in tempdirs. Spec acceptance bullets at
//! `pmacs-tasks.tex:3279-3288`:
//!
//! 1. Lockfile content is stable across regeneration of the same
//! resolve (no spurious diffs).
//! 2. Two users on different machines, given the same lockfile,
//! install identical commits.
//! 3. Content-hash mismatch (a tampered fetch) produces a clear error
//! and refuses to install.
//! 4. `pmacs.packages.update()` regenerates the whole lockfile;
//! `update("pkg")` regenerates only the named package.
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::Command;
use semver::VersionReq;
use tempfile::TempDir;
use pmacs::packages::{
Address, ContentHash, Fetcher, InstallPin, Lockfile, LockfileEntry, LockfileError, PackageName,
ResolveError, ResolveRequest, Resolver, UpdatePolicy,
};
// ---------------------------------------------------------------------------
// Test helpers (mirrors m7_5_acceptance.rs)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
struct VersionSpec<'a> {
version: &'a str,
pmacs_required: &'a str,
dependencies: Vec<(String, String)>,
}
impl<'a> VersionSpec<'a> {
fn new(version: &'a str) -> Self {
Self {
version,
pmacs_required: ">= 0.1.0",
dependencies: Vec::new(),
}
}
fn with_dependency(mut self, address: &str, constraint: &str) -> Self {
self.dependencies
.push((address.to_string(), constraint.to_string()));
self
}
}
/// Build a bare Git repo with one or more tagged versions.
fn make_versioned_package(name: &str, versions: &[VersionSpec<'_>]) -> (TempDir, PathBuf) {
assert!(
!versions.is_empty(),
"make_versioned_package: need at least one version",
);
let td = tempfile::tempdir().expect("tempdir");
let work = td.path().join("work");
let bare = td.path().join("upstream.git");
run_git(&[
OsStr::new("init"),
OsStr::new("--initial-branch=main"),
work.as_os_str(),
]);
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("config"),
OsStr::new("user.email"),
OsStr::new("test@example.com"),
]);
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("config"),
OsStr::new("user.name"),
OsStr::new("Tester"),
]);
// Determinism: pin commit timestamps so two runs of the same
// fixture produce the same SHAs (and therefore the same content
// hashes) even when wallclock time differs. Acceptance bullet 1
// ("stable across regeneration") relies on this for the cross-
// tempdir comparison test.
let env_pairs = [
("GIT_AUTHOR_DATE", "2026-01-01T00:00:00 +0000"),
("GIT_COMMITTER_DATE", "2026-01-01T00:00:00 +0000"),
];
for v in versions {
let manifest = render_manifest(name, v);
std::fs::write(work.join("pmacs.toml"), manifest).expect("write pmacs.toml");
std::fs::write(
work.join("init.lua"),
format!(
"return {{ name = '{name}', version = '{version}' }}\n",
version = v.version,
),
)
.expect("write init.lua");
run_git_env(
&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("add"),
OsStr::new("."),
],
&env_pairs,
);
run_git_env(
&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("commit"),
OsStr::new("-m"),
OsStr::new(v.version),
],
&env_pairs,
);
run_git_env(
&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("tag"),
OsStr::new(&format!("v{}", v.version)),
],
&env_pairs,
);
}
run_git(&[
OsStr::new("clone"),
OsStr::new("--bare"),
work.as_os_str(),
bare.as_os_str(),
]);
(td, bare)
}
/// Add a new tagged version on top of an existing bare-clone fixture's
/// working tree. Keeps the working tree's `td.path()` reusable so a
/// later `git push` (or re-clone) picks up the new tag.
fn add_version_to_existing(td: &TempDir, name: &str, v: &VersionSpec<'_>) {
let work = td.path().join("work");
let bare = td.path().join("upstream.git");
let manifest = render_manifest(name, v);
std::fs::write(work.join("pmacs.toml"), manifest).expect("write pmacs.toml");
std::fs::write(
work.join("init.lua"),
format!(
"return {{ name = '{name}', version = '{version}' }}\n",
version = v.version,
),
)
.expect("write init.lua");
let env_pairs = [
("GIT_AUTHOR_DATE", "2026-02-01T00:00:00 +0000"),
("GIT_COMMITTER_DATE", "2026-02-01T00:00:00 +0000"),
];
run_git_env(
&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("add"),
OsStr::new("."),
],
&env_pairs,
);
run_git_env(
&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("commit"),
OsStr::new("-m"),
OsStr::new(v.version),
],
&env_pairs,
);
run_git_env(
&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("tag"),
OsStr::new(&format!("v{}", v.version)),
],
&env_pairs,
);
// Push the new commits + tags into the bare upstream so the
// fetcher's next `git fetch` pulls them.
run_git(&[
OsStr::new("-C"),
work.as_os_str(),
OsStr::new("push"),
bare.as_os_str(),
OsStr::new("--tags"),
OsStr::new("main"),
]);
}
fn render_manifest(name: &str, v: &VersionSpec<'_>) -> String {
use std::fmt::Write as _;
let mut out = format!(
"name = \"{name}\"\n\
version = \"{version}\"\n\
summary = \"acceptance fixture\"\n\
pmacs_required = \"{pmacs_required}\"\n\
entry = \"init.lua\"\n\
exports = [\"{name}\"]\n",
version = v.version,
pmacs_required = v.pmacs_required,
);
for (addr, constraint) in &v.dependencies {
out.push_str("[[dependencies]]\n");
let _ = writeln!(out, "address = \"{addr}\"");
let _ = writeln!(out, "version = \"{constraint}\"");
}
out
}
fn run_git(args: &[&OsStr]) {
run_git_env(args, &[]);
}
fn run_git_env(args: &[&OsStr], env: &[(&str, &str)]) {
let mut cmd = Command::new("git");
for a in args {
cmd.arg(a);
}
cmd.env("GIT_TERMINAL_PROMPT", "0");
cmd.env("LC_ALL", "C");
for (k, v) in env {
cmd.env(k, v);
}
let out = cmd.output().expect("git spawn");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr),
);
}
fn file_address(p: &Path) -> String {
format!("git:file://{}", p.display())
}
fn make_resolver() -> (Resolver, TempDir) {
let cache = tempfile::tempdir().expect("cache tempdir");
let fetcher = Fetcher::with_cache_dir(cache.path().to_path_buf());
(Resolver::new(fetcher), cache)
}
fn version_pin(constraint: &str) -> InstallPin {
InstallPin::Version(VersionReq::parse(constraint).expect("parse VersionReq"))
}
fn parse_address(s: &str) -> Address {
Address::parse(s).expect("parse address")
}
// ---------------------------------------------------------------------------
// Acceptance bullet 1: lockfile content is stable across regenerations
// ---------------------------------------------------------------------------
#[test]
fn lockfile_bytes_are_stable_across_regenerations() {
// Build a 3-package transitive graph. Generate the lockfile twice
// from the same plan and assert byte-identical output. Then resolve
// again from scratch (new resolver, fresh cache) and assert the
// bytes still match — exercises the "no spurious diffs" property
// across runs, not just within a run.
let (_c_td, c_bare) = make_versioned_package("c-pkg", &[VersionSpec::new("1.0.0")]);
let c_addr = file_address(&c_bare);
let (_b_td, b_bare) = make_versioned_package(
"b-pkg",
&[VersionSpec::new("1.0.0").with_dependency(&c_addr, "^1.0.0")],
);
let b_addr = file_address(&b_bare);
let (_a_td, a_bare) = make_versioned_package(
"a-pkg",
&[VersionSpec::new("1.0.0").with_dependency(&b_addr, "^1.0.0")],
);
let a_addr = file_address(&a_bare);
let request = ResolveRequest {
address: parse_address(&a_addr),
pin: version_pin("^1.0.0"),
};
// Run 1.
let (resolver1, _cache1) = make_resolver();
let plan1 = resolver1
.resolve(std::slice::from_ref(&request))
.expect("resolve 1");
let lock1 = Lockfile::from_plan(&plan1, resolver1.fetcher()).expect("lockfile 1");
let bytes1 = lock1.to_bytes().expect("bytes 1");
// Run 2 (same plan, same fetcher).
let lock2 = Lockfile::from_plan(&plan1, resolver1.fetcher()).expect("lockfile 2");
let bytes2 = lock2.to_bytes().expect("bytes 2");
assert_eq!(
bytes1, bytes2,
"two regenerations of same plan must be byte-equal"
);
// Run 3 (cold cache, fresh resolver).
let (resolver3, _cache3) = make_resolver();
let plan3 = resolver3
.resolve(std::slice::from_ref(&request))
.expect("resolve 3");
let lock3 = Lockfile::from_plan(&plan3, resolver3.fetcher()).expect("lockfile 3");
let bytes3 = lock3.to_bytes().expect("bytes 3");
assert_eq!(
bytes1, bytes3,
"fresh-resolver lockfile must equal first-resolver lockfile",
);
}
// ---------------------------------------------------------------------------
// Acceptance bullet 2: same lockfile → identical commits across machines
// ---------------------------------------------------------------------------
#[test]
fn frozen_resolve_yields_lockfile_commits_on_a_second_machine() {
// Simulate "user A generates lockfile, user B installs from it":
// produce a lockfile with one resolver, throw away the fetcher
// cache, write the lockfile bytes, then load them on a fresh
// resolver / cache and run a Frozen resolve. The Frozen plan must
// pin to the same commits as the original lockfile.
let (_c_td, c_bare) = make_versioned_package("c-pkg", &[VersionSpec::new("1.0.0")]);
let c_addr = file_address(&c_bare);
let (_b_td, b_bare) = make_versioned_package(
"b-pkg",
&[VersionSpec::new("1.0.0").with_dependency(&c_addr, "^1.0.0")],
);
let b_addr = file_address(&b_bare);
// User A.
let (resolver_a, _cache_a) = make_resolver();
let plan_a = resolver_a
.resolve(&[ResolveRequest {
address: parse_address(&b_addr),
pin: version_pin("^1.0.0"),
}])
.expect("resolve A");
let lock = Lockfile::from_plan(&plan_a, resolver_a.fetcher()).expect("lockfile A");
let lock_bytes = lock.to_bytes().expect("serialize lockfile");
// Save lockfile commits for comparison. Sort by name so the
// comparison is stable regardless of whether the source is the
// alphabetical lockfile or the topological plan.
let mut commits_a: Vec<(String, String)> = lock
.packages
.iter()
.map(|e| (e.name.as_str().to_string(), e.commit.clone()))
.collect();
commits_a.sort();
// User B: cold cache, parse the lockfile, run Frozen.
let parsed = Lockfile::parse(&lock_bytes).expect("parse lockfile");
let (resolver_b, _cache_b) = make_resolver();
let plan_b = resolver_b
.resolve_with_policy(
&[ResolveRequest {
address: parse_address(&b_addr),
pin: version_pin("^1.0.0"),
}],
Some(&parsed),
&UpdatePolicy::Frozen,
)
.expect("frozen resolve");
let mut commits_b: Vec<(String, String)> = plan_b
.packages
.iter()
.map(|p| (p.name.as_str().to_string(), p.commit.clone()))
.collect();
commits_b.sort();
assert_eq!(
commits_a, commits_b,
"frozen resolve on a fresh machine must pin to the lockfile's commits",
);
}
// ---------------------------------------------------------------------------
// Acceptance bullet 3: content-hash mismatch refuses to install
// ---------------------------------------------------------------------------
#[test]
fn content_hash_mismatch_produces_clear_error() {
let (_a_td, a_bare) = make_versioned_package("a-pkg", &[VersionSpec::new("1.0.0")]);
let a_addr = file_address(&a_bare);
let (resolver, _cache) = make_resolver();
let plan = resolver
.resolve(&[ResolveRequest {
address: parse_address(&a_addr),
pin: version_pin("^1.0.0"),
}])
.expect("resolve");
let mut lock = Lockfile::from_plan(&plan, resolver.fetcher()).expect("lockfile");
// Tamper: replace the content_hash with a known-different value.
let original_hash = lock.packages[0].content_hash.clone();
lock.packages[0].content_hash = ContentHash::sha256_of(b"this is not the upstream content");
let entry = lock.packages[0].clone();
let err = lock
.verify_entry(&entry, resolver.fetcher())
.expect_err("hash mismatch must error");
match &err {
LockfileError::ContentHashMismatch {
name,
expected,
observed,
..
} => {
assert_eq!(name.as_str(), "a-pkg");
assert_ne!(expected, observed, "expected and observed must differ");
// Observed hash must equal the lockfile's *original* hash
// (the upstream itself is untouched, only the lockfile was
// tampered).
assert_eq!(
observed, &original_hash,
"observed hash must match the upstream-derived hash",
);
}
other => panic!("expected ContentHashMismatch, got {other:?}"),
}
let msg = err.to_string();
assert!(
msg.contains("a-pkg"),
"error message must name the package, got: {msg}",
);
assert!(
msg.contains("expected") && msg.contains("observed"),
"error message must show both hashes, got: {msg}",
);
assert!(
msg.contains("Refusing to install"),
"error message must explicitly refuse install, got: {msg}",
);
}
#[test]
fn frozen_resolve_detects_lockfile_content_hash_tamper() {
// The Frozen path through `to_resolve_plan` must verify each
// entry's content hash before reading the manifest. A tampered
// hash (e.g., a malicious lockfile served alongside a benign
// upstream) must abort the install with ContentHashMismatch
// rather than silently accept whatever the upstream now ships.
let (_a_td, a_bare) = make_versioned_package("a-pkg", &[VersionSpec::new("1.0.0")]);
let a_addr = file_address(&a_bare);
let (resolver, _cache) = make_resolver();
let req = ResolveRequest {
address: parse_address(&a_addr),
pin: version_pin("^1.0.0"),
};
let plan = resolver
.resolve(std::slice::from_ref(&req))
.expect("resolve");
let mut lock = Lockfile::from_plan(&plan, resolver.fetcher()).expect("lockfile");
// Tamper with the content_hash recorded in the lockfile.
lock.packages[0].content_hash = ContentHash::sha256_of(b"tampered");
// Frozen resolve must surface the mismatch as ContentHashMismatch.
let (resolver2, _cache2) = make_resolver();
let err = resolver2
.resolve_with_policy(&[req], Some(&lock), &UpdatePolicy::Frozen)
.expect_err("Frozen with tampered content_hash must error");
let msg = err.to_string();
assert!(
msg.contains("a-pkg") || msg.contains("ContentHash") || msg.contains("hash"),
"expected hash-mismatch error from Frozen resolve, got: {msg}",
);
}
#[test]
fn unmodified_lockfile_verifies_against_upstream() {
// Counterpart to the tampering test: the happy path. A lockfile
// generated from a plan must verify against its own upstream
// without error.
let (_a_td, a_bare) = make_versioned_package("a-pkg", &[VersionSpec::new("1.0.0")]);
let a_addr = file_address(&a_bare);
let (resolver, _cache) = make_resolver();
let plan = resolver
.resolve(&[ResolveRequest {
address: parse_address(&a_addr),
pin: version_pin("^1.0.0"),
}])
.expect("resolve");
let lock = Lockfile::from_plan(&plan, resolver.fetcher()).expect("lockfile");
for entry in &lock.packages {
lock.verify_entry(entry, resolver.fetcher())
.unwrap_or_else(|e| panic!("verify failed for {}: {e}", entry.name.as_str()));
}
}
// ---------------------------------------------------------------------------
// Acceptance bullet 4: update() vs update("pkg") regeneration semantics
// ---------------------------------------------------------------------------
#[test]
fn update_all_regenerates_full_lockfile_after_upstream_changes() {
// Lock at v1.0.0; upstream publishes v1.1.0; UpdateAll picks v1.1.0.
let (a_td, a_bare) = make_versioned_package("a-pkg", &[VersionSpec::new("1.0.0")]);
let a_addr = file_address(&a_bare);
let (resolver1, _cache1) = make_resolver();
let plan1 = resolver1
.resolve(&[ResolveRequest {
address: parse_address(&a_addr),
pin: version_pin("^1.0.0"),
}])
.expect("resolve initial");
let lock1 = Lockfile::from_plan(&plan1, resolver1.fetcher()).expect("lockfile 1");
let initial_version = lock1.packages[0].version.clone();
assert_eq!(initial_version.to_string(), "1.0.0");
// Upstream publishes v1.1.0.
add_version_to_existing(&a_td, "a-pkg", &VersionSpec::new("1.1.0"));
// UpdateAll on a fresh resolver should pick v1.1.0.
let (resolver2, _cache2) = make_resolver();
let plan2 = resolver2
.resolve_with_policy(
&[ResolveRequest {
address: parse_address(&a_addr),
pin: version_pin("^1.0.0"),
}],
Some(&lock1),
&UpdatePolicy::UpdateAll,
)
.expect("update all resolve");
let lock2 = Lockfile::from_plan(&plan2, resolver2.fetcher()).expect("lockfile 2");
assert_eq!(lock2.packages[0].version.to_string(), "1.1.0");
assert_ne!(
lock1.packages[0].commit, lock2.packages[0].commit,
"UpdateAll must produce a different commit when upstream advanced",
);
}
#[test]
fn update_one_only_touches_named_package() {
// Lock A and C at v1.0.0. Upstream publishes v1.1.0 for both.
// UpdateOne("a-pkg") bumps A to v1.1.0; C stays at v1.0.0
// because its constraints still allow the lockfile version (the
// hint cascade brake).
let (a_td, a_bare) = make_versioned_package("a-pkg", &[VersionSpec::new("1.0.0")]);
let a_addr = file_address(&a_bare);
let (c_td, c_bare) = make_versioned_package("c-pkg", &[VersionSpec::new("1.0.0")]);
let c_addr = file_address(&c_bare);
let (resolver1, _cache1) = make_resolver();
let plan1 = resolver1
.resolve(&[
ResolveRequest {
address: parse_address(&a_addr),
pin: version_pin("^1.0.0"),
},
ResolveRequest {
address: parse_address(&c_addr),
pin: version_pin("^1.0.0"),
},
])
.expect("resolve initial");
let lock1 = Lockfile::from_plan(&plan1, resolver1.fetcher()).expect("lockfile 1");
let lock1_a_commit = lock1
.entry(&PackageName::new("a-pkg").unwrap())
.unwrap()
.commit
.clone();
let lock1_c_commit = lock1
.entry(&PackageName::new("c-pkg").unwrap())
.unwrap()
.commit
.clone();
// Upstream advances both packages.
add_version_to_existing(&a_td, "a-pkg", &VersionSpec::new("1.1.0"));
add_version_to_existing(&c_td, "c-pkg", &VersionSpec::new("1.1.0"));
let (resolver2, _cache2) = make_resolver();
let plan2 = resolver2
.resolve_with_policy(
&[
ResolveRequest {
address: parse_address(&a_addr),
pin: version_pin("^1.0.0"),
},
ResolveRequest {
address: parse_address(&c_addr),
pin: version_pin("^1.0.0"),
},
],
Some(&lock1),
&UpdatePolicy::UpdateOne(PackageName::new("a-pkg").unwrap()),
)
.expect("update one resolve");
let lock2 = Lockfile::from_plan(&plan2, resolver2.fetcher()).expect("lockfile 2");
let a2 = lock2.entry(&PackageName::new("a-pkg").unwrap()).unwrap();
let c2 = lock2.entry(&PackageName::new("c-pkg").unwrap()).unwrap();
assert_eq!(
a2.version.to_string(),
"1.1.0",
"UpdateOne must advance the named package",
);
assert_ne!(
a2.commit, lock1_a_commit,
"named package must move to a new commit",
);
assert_eq!(
c2.version.to_string(),
"1.0.0",
"UpdateOne must not advance unrelated packages even when upstream has newer",
);
assert_eq!(
c2.commit, lock1_c_commit,
"unrelated package must keep its lockfile commit",
);
}
#[test]
fn update_one_errors_when_package_not_in_lockfile() {
let (_a_td, a_bare) = make_versioned_package("a-pkg", &[VersionSpec::new("1.0.0")]);
let a_addr = file_address(&a_bare);
let (resolver, _cache) = make_resolver();
let plan = resolver
.resolve(&[ResolveRequest {
address: parse_address(&a_addr),
pin: version_pin("^1.0.0"),
}])
.expect("resolve");
let lock = Lockfile::from_plan(&plan, resolver.fetcher()).expect("lockfile");
let err = resolver
.resolve_with_policy(
&[ResolveRequest {
address: parse_address(&a_addr),
pin: version_pin("^1.0.0"),
}],
Some(&lock),
&UpdatePolicy::UpdateOne(PackageName::new("nonexistent").unwrap()),
)
.expect_err("UpdateOne on missing package errors");
match err {
ResolveError::Lockfile(LockfileError::UpdateOneMissing { name }) => {
assert_eq!(name.as_str(), "nonexistent");
}
other => panic!("unexpected: {other:?}"),
}
}
// ---------------------------------------------------------------------------
// FrozenRequestMissing path
// ---------------------------------------------------------------------------
#[test]
fn frozen_resolve_errors_when_request_missing_from_lockfile() {
// Lockfile has only A; user tries Frozen resolve including B.
let (_a_td, a_bare) = make_versioned_package("a-pkg", &[VersionSpec::new("1.0.0")]);
let a_addr = file_address(&a_bare);
let (_b_td, b_bare) = make_versioned_package("b-pkg", &[VersionSpec::new("1.0.0")]);
let b_addr = file_address(&b_bare);
let (resolver, _cache) = make_resolver();
let plan = resolver
.resolve(&[ResolveRequest {
address: parse_address(&a_addr),
pin: version_pin("^1.0.0"),
}])
.expect("resolve A only");
let lock = Lockfile::from_plan(&plan, resolver.fetcher()).expect("lockfile A");
let err = resolver
.resolve_with_policy(
&[
ResolveRequest {
address: parse_address(&a_addr),
pin: version_pin("^1.0.0"),
},
ResolveRequest {
address: parse_address(&b_addr),
pin: version_pin("^1.0.0"),
},
],
Some(&lock),
&UpdatePolicy::Frozen,
)
.expect_err("frozen resolve with new package errors");
match err {
ResolveError::FrozenRequestMissing { address } => {
assert!(
address
.to_git_url()
.contains(b_bare.to_string_lossy().as_ref()),
"error must name the missing package's URL",
);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn lockfile_required_when_policy_demands_one() {
let (_a_td, a_bare) = make_versioned_package("a-pkg", &[VersionSpec::new("1.0.0")]);
let a_addr = file_address(&a_bare);
let (resolver, _cache) = make_resolver();
let err = resolver
.resolve_with_policy(
&[ResolveRequest {
address: parse_address(&a_addr),
pin: version_pin("^1.0.0"),
}],
None,
&UpdatePolicy::Frozen,
)
.expect_err("Frozen without lockfile errors");
assert!(matches!(err, ResolveError::LockfileRequired), "got {err:?}");
}
// ---------------------------------------------------------------------------
// Lockfile structure / serialization
// ---------------------------------------------------------------------------
#[test]
fn lockfile_records_transitive_closure_with_dependency_edges() {
// A → B → C. Lockfile must contain all three with correct edges.
let (_c_td, c_bare) = make_versioned_package("c-pkg", &[VersionSpec::new("1.0.0")]);
let c_addr = file_address(&c_bare);
let (_b_td, b_bare) = make_versioned_package(
"b-pkg",
&[VersionSpec::new("1.0.0").with_dependency(&c_addr, "^1.0.0")],
);
let b_addr = file_address(&b_bare);
let (_a_td, a_bare) = make_versioned_package(
"a-pkg",
&[VersionSpec::new("1.0.0").with_dependency(&b_addr, "^1.0.0")],
);
let a_addr = file_address(&a_bare);
let (resolver, _cache) = make_resolver();
let plan = resolver
.resolve(&[ResolveRequest {
address: parse_address(&a_addr),
pin: version_pin("^1.0.0"),
}])
.expect("resolve");
let lock = Lockfile::from_plan(&plan, resolver.fetcher()).expect("lockfile");
let names: Vec<&str> = lock.packages.iter().map(|e| e.name.as_str()).collect();
assert_eq!(
names,
vec!["a-pkg", "b-pkg", "c-pkg"],
"lockfile entries must be alphabetical by name",
);
let a = find_entry(&lock, "a-pkg");
let b = find_entry(&lock, "b-pkg");
let c = find_entry(&lock, "c-pkg");
assert_eq!(a.dependencies.len(), 1);
assert_eq!(a.dependencies[0].as_str(), "b-pkg");
assert_eq!(b.dependencies.len(), 1);
assert_eq!(b.dependencies[0].as_str(), "c-pkg");
assert!(c.dependencies.is_empty());
// Top-level pin only on A.
assert!(a.top_level_pin.is_some());
assert!(b.top_level_pin.is_none());
assert!(c.top_level_pin.is_none());
// Every commit is a 40-char SHA.
for e in &lock.packages {
assert_eq!(e.commit.len(), 40, "{}: {:?}", e.name.as_str(), e.commit);
assert!(e.commit.chars().all(|c| c.is_ascii_hexdigit()));
}
}
fn find_entry<'a>(lock: &'a Lockfile, name: &str) -> &'a LockfileEntry {
lock.packages
.iter()
.find(|e| e.name.as_str() == name)
.unwrap_or_else(|| panic!("no entry for {name}"))
}
#[test]
fn lockfile_to_resolve_plan_yields_topological_order() {
// Lockfile is alphabetical on disk; to_resolve_plan must restore
// dependency-first order via the recorded `dependencies` edges.
let (_c_td, c_bare) = make_versioned_package("c-pkg", &[VersionSpec::new("1.0.0")]);
let c_addr = file_address(&c_bare);
let (_b_td, b_bare) = make_versioned_package(
"b-pkg",
&[VersionSpec::new("1.0.0").with_dependency(&c_addr, "^1.0.0")],
);
let b_addr = file_address(&b_bare);
let (_a_td, a_bare) = make_versioned_package(
"a-pkg",
&[VersionSpec::new("1.0.0").with_dependency(&b_addr, "^1.0.0")],
);
let a_addr = file_address(&a_bare);
let (resolver, _cache) = make_resolver();
let plan_orig = resolver
.resolve(&[ResolveRequest {
address: parse_address(&a_addr),
pin: version_pin("^1.0.0"),
}])
.expect("resolve");
let lock = Lockfile::from_plan(&plan_orig, resolver.fetcher()).expect("lockfile");
let plan_via_lock = lock
.to_resolve_plan(resolver.fetcher())
.expect("plan via lockfile");
let names: Vec<&str> = plan_via_lock
.packages
.iter()
.map(|p| p.name.as_str())
.collect();
assert_eq!(names, vec!["c-pkg", "b-pkg", "a-pkg"]);
}
#[test]
fn lockfile_round_trip_via_disk_io() {
let (_a_td, a_bare) = make_versioned_package("a-pkg", &[VersionSpec::new("1.0.0")]);
let a_addr = file_address(&a_bare);
let (resolver, _cache) = make_resolver();
let plan = resolver
.resolve(&[ResolveRequest {
address: parse_address(&a_addr),
pin: version_pin("^1.0.0"),
}])
.expect("resolve");
let lock = Lockfile::from_plan(&plan, resolver.fetcher()).expect("lockfile");
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("pmacs.lock");
lock.write_to(&path).expect("write lockfile");
let read_back = Lockfile::read_from(&path).expect("read lockfile");
assert_eq!(read_back, lock);
}