// 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); }