// tests/m7_5_acceptance.rs --- Acceptance tests for T M7.5 (resolver). //! Drives the [`pmacs::packages::Resolver`] end-to-end against real //! bare Git repositories (constructed in tempdirs). The four spec //! acceptance bullets at `pmacs-tasks.tex:3244`: //! //! 1. Resolver succeeds on a three-package transitive dependency //! graph. //! 2. Unresolvable conflicts produce errors naming the conflicting //! constraint paths. //! 3. `conflicts` declarations in manifests are honored: two packages //! declared incompatible cannot both be installed. //! 4. Resolution is deterministic: the same input always produces the //! same output. //! //! Plus the pin-kind sub-cases agreed during M7.5 design: //! //! - Top-level branch pin: plan records both the pin and the resolved //! commit so M7.6's lockfile can re-resolve branches on `update`. //! - Top-level commit pin: degenerate path; manifest read straight //! off the commit. //! - `pmacs_required` filter is distinct from version-constraint //! filter: separate error variants make "loosen your constraint" //! and "upgrade pmacs" remediable independently. use std::ffi::OsStr; use std::path::{Path, PathBuf}; use std::process::Command; use semver::{Version, VersionReq}; use tempfile::TempDir; use pmacs::packages::{ Address, Fetcher, InstallPin, ResolveError, ResolveRequest, Resolver, Source, }; // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- /// One published version of a fixture package. #[derive(Debug, Clone)] struct VersionSpec<'a> { /// Semver to publish (used for both the manifest's `version` /// field and the Git tag, prefixed with `v`). version: &'a str, /// Manifest's `pmacs_required` for this version. pmacs_required: &'a str, /// Each entry is `(address, version_constraint)`. dependencies: Vec<(String, String)>, /// Each entry is `(address, version_constraint)`. conflicts: Vec<(String, String)>, } impl<'a> VersionSpec<'a> { fn new(version: &'a str) -> Self { Self { version, pmacs_required: ">= 0.1.0", dependencies: Vec::new(), conflicts: Vec::new(), } } fn with_pmacs_required(mut self, req: &'a str) -> Self { self.pmacs_required = req; self } fn with_dependency(mut self, address: &str, constraint: &str) -> Self { self.dependencies .push((address.to_string(), constraint.to_string())); self } fn with_conflict(mut self, address: &str, constraint: &str) -> Self { self.conflicts .push((address.to_string(), constraint.to_string())); self } } /// Build a bare Git repo for a package with one or more tagged /// versions. Each `VersionSpec` becomes a commit on `main` with a /// matching `vX.Y.Z` tag. 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"), ]); 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(&[ OsStr::new("-C"), work.as_os_str(), OsStr::new("add"), OsStr::new("."), ]); run_git(&[ OsStr::new("-C"), work.as_os_str(), OsStr::new("commit"), OsStr::new("-m"), OsStr::new(v.version), ]); run_git(&[ OsStr::new("-C"), work.as_os_str(), OsStr::new("tag"), OsStr::new(&format!("v{}", v.version)), ]); } run_git(&[ OsStr::new("clone"), OsStr::new("--bare"), work.as_os_str(), bare.as_os_str(), ]); (td, bare) } 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}\""); } for (addr, constraint) in &v.conflicts { out.push_str("[[conflicts]]\n"); let _ = writeln!(out, "address = \"{addr}\""); let _ = writeln!(out, "version = \"{constraint}\""); } out } /// Build a bare repo with a single tagged version + a feature branch /// whose head is a different commit. Returns `(tempdir, bare_path, /// feature_branch_head_sha)`. Useful for branch / commit pin tests. fn make_branched_package(name: &str) -> (TempDir, PathBuf, String) { 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"), ]); // v1.0.0 on main. let v1 = VersionSpec::new("1.0.0"); std::fs::write(work.join("pmacs.toml"), render_manifest(name, &v1)).expect("write manifest v1"); std::fs::write( work.join("init.lua"), format!("return {{ name = '{name}', version = '1.0.0' }}\n"), ) .expect("write init.lua v1"); run_git(&[ OsStr::new("-C"), work.as_os_str(), OsStr::new("add"), OsStr::new("."), ]); run_git(&[ OsStr::new("-C"), work.as_os_str(), OsStr::new("commit"), OsStr::new("-m"), OsStr::new("v1.0.0"), ]); run_git(&[ OsStr::new("-C"), work.as_os_str(), OsStr::new("tag"), OsStr::new("v1.0.0"), ]); // Feature branch with a different commit; same name+version in // manifest (the branch carries an in-progress build). run_git(&[ OsStr::new("-C"), work.as_os_str(), OsStr::new("checkout"), OsStr::new("-b"), OsStr::new("feature"), ]); std::fs::write( work.join("init.lua"), format!("return {{ name = '{name}', version = '1.0.0', flavor = 'feature' }}\n"), ) .expect("write init.lua feature"); run_git(&[ OsStr::new("-C"), work.as_os_str(), OsStr::new("add"), OsStr::new("."), ]); run_git(&[ OsStr::new("-C"), work.as_os_str(), OsStr::new("commit"), OsStr::new("-m"), OsStr::new("feature work"), ]); let feature_head = git_rev_parse_head(&work); run_git(&[ OsStr::new("clone"), OsStr::new("--bare"), work.as_os_str(), bare.as_os_str(), ]); (td, bare, feature_head) } fn git_rev_parse_head(repo: &Path) -> String { let out = Command::new("git") .arg("-C") .arg(repo) .arg("rev-parse") .arg("HEAD") .env("GIT_TERMINAL_PROMPT", "0") .output() .expect("git rev-parse HEAD"); assert!( out.status.success(), "git rev-parse HEAD failed: {}", String::from_utf8_lossy(&out.stderr), ); String::from_utf8(out.stdout) .expect("rev-parse output utf-8") .trim() .to_string() } fn run_git(args: &[&OsStr]) { let mut cmd = Command::new("git"); for a in args { cmd.arg(a); } cmd.env("GIT_TERMINAL_PROMPT", "0"); cmd.env("LC_ALL", "C"); 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: transitive dependency graph // --------------------------------------------------------------------------- #[test] fn resolver_resolves_transitive_dependency_chain() { // A depends on B (^1); B depends on C (^1); top-level installs A. // Expected plan: [C, B, A] — dependencies before dependents. 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 names: Vec<&str> = plan.packages.iter().map(|p| p.name.as_str()).collect(); assert_eq!( names, vec!["c-pkg", "b-pkg", "a-pkg"], "expected topological order C -> B -> A, got {names:?}", ); // Each entry has the manifest version and a 40-char commit. for entry in &plan.packages { assert_eq!(entry.version, Version::new(1, 0, 0)); assert!( entry.revision.is_empty() || entry.revision.len() == 40 || entry.revision.starts_with('v'), "expected revision to be a 40-char SHA or tag-resolved, got {:?}", entry.revision, ); } // Top-level pin is recorded on A only; B and C came in transitively. let a = plan .packages .iter() .find(|p| p.name.as_str() == "a-pkg") .unwrap(); assert!(matches!(a.top_level_pin, Some(InstallPin::Version(_)))); let b = plan .packages .iter() .find(|p| p.name.as_str() == "b-pkg") .unwrap(); assert!( b.top_level_pin.is_none(), "transitive deps have no top_level_pin" ); let c = plan .packages .iter() .find(|p| p.name.as_str() == "c-pkg") .unwrap(); assert!(c.top_level_pin.is_none()); } // --------------------------------------------------------------------------- // Acceptance bullet 2: unresolvable version conflict names paths // --------------------------------------------------------------------------- #[test] fn resolver_errors_on_unresolvable_version_conflict() { // C publishes v1.0.0 and v2.0.0. // A (top-level) requires C ^1; D (top-level) requires C ^2. // No version of C satisfies both. let (_c_td, c_bare) = make_versioned_package( "c-pkg", &[VersionSpec::new("1.0.0"), VersionSpec::new("2.0.0")], ); let c_addr = file_address(&c_bare); let (_a_td, a_bare) = make_versioned_package( "a-pkg", &[VersionSpec::new("1.0.0").with_dependency(&c_addr, "^1.0.0")], ); let a_addr = file_address(&a_bare); let (_d_td, d_bare) = make_versioned_package( "d-pkg", &[VersionSpec::new("1.0.0").with_dependency(&c_addr, "^2.0.0")], ); let d_addr = file_address(&d_bare); let (resolver, _cache) = make_resolver(); let err = resolver .resolve(&[ ResolveRequest { address: parse_address(&a_addr), pin: version_pin("^1.0.0"), }, ResolveRequest { address: parse_address(&d_addr), pin: version_pin("^1.0.0"), }, ]) .expect_err("expected unresolvable conflict"); let msg = err.to_string(); assert!( matches!(err, ResolveError::NoVersionMatchesConstraints { .. }), "expected NoVersionMatchesConstraints, got {err:?}", ); assert!( msg.contains("a-pkg") && msg.contains("d-pkg"), "error must name both constraint sources, got: {msg}", ); assert!( msg.contains("^1.0.0") && msg.contains("^2.0.0"), "error must name both constraints, got: {msg}", ); assert!( msg.contains("v1.0.0") && msg.contains("v2.0.0"), "error must list available tags, got: {msg}", ); } // --------------------------------------------------------------------------- // Acceptance bullet 3: manifest conflict declaration is honored // --------------------------------------------------------------------------- #[test] fn resolver_errors_on_manifest_conflict_declaration() { // A and B published independently. A's manifest declares // `conflicts: B = "*"`. Both top-level. Resolver must error // naming both packages and the conflict clause. let (_b_td, b_bare) = make_versioned_package("b-pkg", &[VersionSpec::new("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_conflict(&b_addr, "*")], ); let a_addr = file_address(&a_bare); let (resolver, _cache) = make_resolver(); let err = resolver .resolve(&[ ResolveRequest { address: parse_address(&a_addr), pin: version_pin("^1.0.0"), }, ResolveRequest { address: parse_address(&b_addr), pin: version_pin("^1.0.0"), }, ]) .expect_err("expected manifest conflict"); let msg = err.to_string(); assert!( matches!(err, ResolveError::ManifestConflict { .. }), "expected ManifestConflict, got {err:?}", ); assert!( msg.contains("a-pkg") && msg.contains("b-pkg"), "error must name both packages, got: {msg}", ); assert!( msg.contains("conflict"), "error must call this a conflict, got: {msg}", ); } // --------------------------------------------------------------------------- // Acceptance bullet 4: determinism // --------------------------------------------------------------------------- #[test] fn resolver_resolution_is_deterministic() { // Build the same A → B → C graph as the transitive test, run // the resolver twice, assert the plans are byte-equal under JSON // serialization (the eventual lockfile representation). 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"), }; let (resolver_one, _cache_one) = make_resolver(); let plan_one = resolver_one.resolve(&[request()]).expect("resolve 1"); let (resolver_two, _cache_two) = make_resolver(); let plan_two = resolver_two.resolve(&[request()]).expect("resolve 2"); let json_one = serde_json::to_string_pretty(&plan_one).expect("serialize plan 1"); let json_two = serde_json::to_string_pretty(&plan_two).expect("serialize plan 2"); assert_eq!( json_one, json_two, "two resolves of the same input must produce byte-identical plans", ); } // --------------------------------------------------------------------------- // pmacs_required filter is distinct from version-constraint filter // --------------------------------------------------------------------------- #[test] fn resolver_distinguishes_pmacs_required_from_user_constraint() { // A publishes v1.0.0 with pmacs_required = ">= 99.0.0" (this // pmacs is 0.1.0, so incompatible). User asks for any version. // Expect NoVersionMatchesPmacsRequirement, not // NoVersionMatchesConstraints — the user can satisfy the version // constraint, just not the pmacs requirement. let (_a_td, a_bare) = make_versioned_package( "a-pkg", &[VersionSpec::new("1.0.0").with_pmacs_required(">= 99.0.0")], ); let a_addr = file_address(&a_bare); let (resolver, _cache) = make_resolver(); let err = resolver .resolve(&[ResolveRequest { address: parse_address(&a_addr), pin: version_pin("*"), }]) .expect_err("expected pmacs incompatibility"); assert!( matches!(err, ResolveError::NoVersionMatchesPmacsRequirement { .. }), "expected NoVersionMatchesPmacsRequirement, got {err:?}", ); let msg = err.to_string(); assert!( msg.contains("pmacs") && msg.contains("99.0.0"), "error should mention pmacs and the required version, got: {msg}", ); } #[test] fn resolver_errors_with_constraint_variant_when_only_user_constraint_unsatisfied() { // A publishes v1.0.0 only. User asks for ^99. Expect // NoVersionMatchesConstraints — the user constraint excludes // every published version. 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(&[ResolveRequest { address: parse_address(&a_addr), pin: version_pin("^99.0.0"), }]) .expect_err("expected version constraint failure"); assert!( matches!(err, ResolveError::NoVersionMatchesConstraints { .. }), "expected NoVersionMatchesConstraints, got {err:?}", ); } // --------------------------------------------------------------------------- // Branch + commit pin paths // --------------------------------------------------------------------------- #[test] fn resolver_branch_pin_records_pin_and_resolved_commit() { let (_a_td, a_bare, feature_head) = make_branched_package("a-pkg"); let a_addr = file_address(&a_bare); let (resolver, _cache) = make_resolver(); let plan = resolver .resolve(&[ResolveRequest { address: parse_address(&a_addr), pin: InstallPin::Branch("feature".to_string()), }]) .expect("resolve branch pin"); assert_eq!(plan.packages.len(), 1); let entry = &plan.packages[0]; assert_eq!( entry.revision, feature_head, "branch HEAD must be the resolved commit" ); assert!( matches!(entry.top_level_pin, Some(InstallPin::Branch(ref b)) if b == "feature"), "plan must record the original branch pin, got {:?}", entry.top_level_pin, ); } #[test] fn resolver_commit_pin_uses_exact_revision() { let (_a_td, a_bare, feature_head) = make_branched_package("a-pkg"); let a_addr = file_address(&a_bare); let (resolver, _cache) = make_resolver(); let plan = resolver .resolve(&[ResolveRequest { address: parse_address(&a_addr), pin: InstallPin::Commit(feature_head.clone()), }]) .expect("resolve commit pin"); assert_eq!(plan.packages.len(), 1); let entry = &plan.packages[0]; assert_eq!(entry.revision, feature_head); assert!( matches!(entry.top_level_pin, Some(InstallPin::Commit(ref c)) if c == &feature_head), "plan must record the original commit pin, got {:?}", entry.top_level_pin, ); } // --------------------------------------------------------------------------- // Diamond dependency: both paths converge on a single chosen version // --------------------------------------------------------------------------- #[test] fn resolver_resolves_diamond_dependency() { // C publishes v1.0.0 and v1.1.0. B and D both depend on C ^1; // A depends on both B and D. Top-level: A. Expected: C@1.1.0 // (highest matching), B and D reuse the same C entry. let (_c_td, c_bare) = make_versioned_package( "c-pkg", &[VersionSpec::new("1.0.0"), VersionSpec::new("1.1.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 (_d_td, d_bare) = make_versioned_package( "d-pkg", &[VersionSpec::new("1.0.0").with_dependency(&c_addr, "^1.0.0")], ); let d_addr = file_address(&d_bare); let (_a_td, a_bare) = make_versioned_package( "a-pkg", &[VersionSpec::new("1.0.0") .with_dependency(&b_addr, "^1.0.0") .with_dependency(&d_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 diamond"); let c_entries: Vec<_> = plan .packages .iter() .filter(|p| p.name.as_str() == "c-pkg") .collect(); assert_eq!(c_entries.len(), 1, "C must appear exactly once in the plan"); assert_eq!(c_entries[0].version, Version::new(1, 1, 0)); // Topological invariant: C precedes both B and D, which precede A. let pos = |name: &str| { plan.packages .iter() .position(|p| p.name.as_str() == name) .unwrap() }; assert!(pos("c-pkg") < pos("b-pkg")); assert!(pos("c-pkg") < pos("d-pkg")); assert!(pos("b-pkg") < pos("a-pkg")); assert!(pos("d-pkg") < pos("a-pkg")); } // --------------------------------------------------------------------------- // Constraint narrowing: transitive dep narrows top-level choice // --------------------------------------------------------------------------- #[test] fn resolver_narrows_top_level_choice_via_transitive_constraint() { // C publishes v1.0.0 and v2.0.0. A depends on C ^1 (excludes v2). // Top-level: C @ "*" (would normally pick v2.0.0) and A @ ^1. // After resolution, C must be v1.0.0 — A's transitive constraint // dragged the top-level choice down. let (_c_td, c_bare) = make_versioned_package( "c-pkg", &[VersionSpec::new("1.0.0"), VersionSpec::new("2.0.0")], ); let c_addr = file_address(&c_bare); let (_a_td, a_bare) = make_versioned_package( "a-pkg", &[VersionSpec::new("1.0.0").with_dependency(&c_addr, "^1.0.0")], ); let a_addr = file_address(&a_bare); let (resolver, _cache) = make_resolver(); let plan = resolver .resolve(&[ ResolveRequest { address: parse_address(&c_addr), pin: version_pin("*"), }, ResolveRequest { address: parse_address(&a_addr), pin: version_pin("^1.0.0"), }, ]) .expect("resolve narrowing"); let c = plan .packages .iter() .find(|p| p.name.as_str() == "c-pkg") .expect("c-pkg in plan"); assert_eq!( c.version, Version::new(1, 0, 0), "transitive ^1 must override top-level *", ); } // --------------------------------------------------------------------------- // Source enum constraint paths land in the constraints error // --------------------------------------------------------------------------- #[test] fn resolver_constraint_error_attributes_each_path() { // Single overconstrained name (C). Two transitive constraints, // both incompatible with each other. The error's constraint list // should include both Source::DependencyOf entries. let (_c_td, c_bare) = make_versioned_package( "c-pkg", &[VersionSpec::new("1.0.0"), VersionSpec::new("2.0.0")], ); let c_addr = file_address(&c_bare); let (_a_td, a_bare) = make_versioned_package( "a-pkg", &[VersionSpec::new("1.0.0").with_dependency(&c_addr, "^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").with_dependency(&c_addr, "^2.0.0")], ); let b_addr = file_address(&b_bare); let (resolver, _cache) = make_resolver(); let err = resolver .resolve(&[ ResolveRequest { address: parse_address(&a_addr), pin: version_pin("^1.0.0"), }, ResolveRequest { address: parse_address(&b_addr), pin: version_pin("^1.0.0"), }, ]) .expect_err("expected version conflict on c-pkg"); let ResolveError::NoVersionMatchesConstraints { constraints, .. } = &err else { panic!("expected NoVersionMatchesConstraints, got {err:?}"); }; let has_source_for = |name: &str| { constraints.iter().any(|(source, _req)| match source { Source::DependencyOf { name: pkg_name, .. } => pkg_name.as_str() == name, Source::TopLevel => false, }) }; assert!( has_source_for("a-pkg"), "constraints must include Source::DependencyOf {{ name: \"a-pkg\", .. }}", ); assert!( has_source_for("b-pkg"), "constraints must include Source::DependencyOf {{ name: \"b-pkg\", .. }}", ); }