From 694843bcc3241ba30c75af1f67915d480f20b322 Mon Sep 17 00:00:00 2001 From: voidreamer Date: Wed, 29 Apr 2026 16:40:14 -0400 Subject: [PATCH 1/8] resolver: explain version conflicts and missing-dep errors Track every (requester, constraint) pair encountered during depth-first resolution. When a later request is incompatible with an already-chosen version, emit a diagnostic that names the chosen version, lists every package that asked for the name, and marks the incompatible row. The "package not found" and "no matching version" messages now include the requester (parent package id, "" for top-level, or "" for stale pins) so deep dependency failures are traceable at a glance. Adds Display impls for VersionConstraint and PackageRequest so the diagnostics round-trip the original constraint syntax. The resolver still does not backtrack -- the first chosen version wins, and conflicts are surfaced rather than worked around -- but every error now identifies both sides of the conflict. --- src/package.rs | 50 ++++++++++--- src/resolver.rs | 181 +++++++++++++++++++++++++++++++++++++++--------- tests/cli.rs | 92 ++++++++++++++++++++++++ 3 files changed, 279 insertions(+), 44 deletions(-) diff --git a/src/package.rs b/src/package.rs index e3740ad..d49a8f6 100644 --- a/src/package.rs +++ b/src/package.rs @@ -233,6 +233,24 @@ pub enum VersionConstraint { Any, } +impl VersionConstraint { + /// Check if a version satisfies this constraint. + pub fn matches(&self, version: &str) -> bool { + match self { + VersionConstraint::Exact(v) => version == v, + VersionConstraint::Minimum(min) => { + version_compare(version, min) >= std::cmp::Ordering::Equal + } + VersionConstraint::Range(min, max) => { + version_compare(version, min) >= std::cmp::Ordering::Equal + && version_compare(version, max) <= std::cmp::Ordering::Equal + } + VersionConstraint::OneOf(versions) => versions.contains(&version.to_string()), + VersionConstraint::Any => true, + } + } +} + impl PackageRequest { /// Parse a package request string. /// @@ -286,19 +304,29 @@ impl PackageRequest { }) } - /// Check if a version matches this constraint + /// Check if a version matches this request's constraint. pub fn matches(&self, version: &str) -> bool { + self.version_constraint.matches(version) + } +} + +impl std::fmt::Display for VersionConstraint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + VersionConstraint::Exact(v) => write!(f, "{}", v), + VersionConstraint::Minimum(v) => write!(f, "{}+", v), + VersionConstraint::Range(a, b) => write!(f, "{}..{}", a, b), + VersionConstraint::OneOf(vs) => write!(f, "{}", vs.join("|")), + VersionConstraint::Any => write!(f, "*"), + } + } +} + +impl std::fmt::Display for PackageRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match &self.version_constraint { - VersionConstraint::Exact(v) => version == v, - VersionConstraint::Minimum(min) => { - version_compare(version, min) >= std::cmp::Ordering::Equal - } - VersionConstraint::Range(min, max) => { - version_compare(version, min) >= std::cmp::Ordering::Equal - && version_compare(version, max) <= std::cmp::Ordering::Equal - } - VersionConstraint::OneOf(versions) => versions.contains(&version.to_string()), - VersionConstraint::Any => true, + VersionConstraint::Any => write!(f, "{}", self.name), + c => write!(f, "{}-{}", self.name, c), } } } diff --git a/src/resolver.rs b/src/resolver.rs index c3ad983..e7260aa 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -1,4 +1,12 @@ //! Package resolution and dependency management +//! +//! The resolver is depth-first and deterministic: package requests resolve in +//! the order they're given, transitive dependencies before their parent, and +//! the first version chosen for a name is the version that ships. It does +//! not backtrack on conflict — instead, every constraint encountered for a +//! name is recorded against the chosen version, and a mismatch produces a +//! diagnostic naming both sides ("X chose 1.0 because A required *, but B +//! requires =2.0"). use std::collections::HashMap; @@ -8,7 +16,57 @@ use tracing::{debug, info, warn}; use crate::cache; use crate::config::Config; use crate::context::Lockfile; -use crate::package::{tokenize_command, Package, PackageRequest}; +use crate::package::{tokenize_command, Package, PackageRequest, VersionConstraint}; + +/// One constraint asked for a package, plus who asked. +#[derive(Debug, Clone)] +struct Requester { + who: String, + constraint: VersionConstraint, +} + +/// A package name that has already been picked. The `requesters` list +/// grows as more parts of the graph ask for the same name. +#[derive(Debug)] +struct ChosenPackage { + version: String, + requesters: Vec, +} + +/// Mutable state carried through depth-first resolution. +#[derive(Debug, Default)] +struct ResolveState { + /// Packages output in dependency order. + resolved: Vec, + /// Already-pushed package ids (`name-version`), for cycle short-circuit. + seen: std::collections::HashSet, + /// Picked version per package name, plus every constraint seen for it. + chosen: HashMap, +} + +/// Build a conflict message that names the chosen version, every requester +/// of that name (with their constraints), and pinpoints the failing one. +fn format_conflict(name: &str, chosen: &ChosenPackage) -> String { + let mut msg = format!( + "version conflict for '{}': chose {} but a later request is incompatible\n", + name, chosen.version, + ); + for r in &chosen.requesters { + let satisfied = if r.constraint.matches(&chosen.version) { + "ok" + } else { + "INCOMPATIBLE" + }; + msg.push_str(&format!( + " - {} required {}-{} [{}]\n", + r.who, name, r.constraint, satisfied, + )); + } + msg.push_str( + "Resolve by relaxing one side, or pinning the other in anvil.lock.", + ); + msg +} /// Resolved set of packages #[derive(Debug)] @@ -220,8 +278,7 @@ impl Resolver { /// Resolve a list of package requests pub fn resolve(&self, requests: &[String]) -> Result { - let mut resolved: Vec = Vec::new(); - let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut state = ResolveState::default(); // Expand aliases let mut expanded_requests: Vec = Vec::new(); @@ -233,59 +290,105 @@ impl Resolver { } } - // Resolve each request + // Resolve each top-level request for req_str in &expanded_requests { let request = PackageRequest::parse(req_str) .with_context(|| format!("Invalid package request: {}", req_str))?; - - self.resolve_request(&request, &mut resolved, &mut seen)?; + self.resolve_request(&request, "", &mut state)?; } - Ok(ResolvedPackages { packages: resolved }) + Ok(ResolvedPackages { + packages: state.resolved, + }) } - /// Resolve a single package request (with dependencies) + /// Resolve a single package request (with dependencies). + /// + /// `requester` is the id of the package that asked for this one + /// (or `""` for top-level requests / `""` for pins). + /// It's used for conflict diagnostics — every constraint encountered for + /// a package name is attributed back to whoever asked for it. fn resolve_request( &self, request: &PackageRequest, - resolved: &mut Vec, - seen: &mut std::collections::HashSet, + requester: &str, + state: &mut ResolveState, ) -> Result<()> { - let package = self.find_package(request)?; + // If this name has already been chosen, verify the new constraint + // is satisfied by the chosen version. No backtracking — the first + // version wins, and incompatible later constraints become errors. + if let Some(existing) = state.chosen.get_mut(&request.name) { + existing.requesters.push(Requester { + who: requester.to_string(), + constraint: request.version_constraint.clone(), + }); + if !request.matches(&existing.version) { + anyhow::bail!(format_conflict(&request.name, existing)); + } + return Ok(()); + } + + // Pick a version. + let package = self.find_package(request, requester)?; let pkg_id = package.id(); - if seen.contains(&pkg_id) { + // Record the choice before recursing into deps, so a cycle + // (A requires B requires A) terminates instead of looping. + state.chosen.insert( + request.name.clone(), + ChosenPackage { + version: package.version.clone(), + requesters: vec![Requester { + who: requester.to_string(), + constraint: request.version_constraint.clone(), + }], + }, + ); + + if state.seen.contains(&pkg_id) { return Ok(()); } + state.seen.insert(pkg_id.clone()); - // Resolve dependencies first + // Resolve dependencies first so parents land after their deps. for dep_str in &package.requires { let dep_request = PackageRequest::parse(dep_str) - .with_context(|| format!("Invalid dependency: {}", dep_str))?; - self.resolve_request(&dep_request, resolved, seen)?; + .with_context(|| format!("Invalid dependency in {}: {}", pkg_id, dep_str))?; + self.resolve_request(&dep_request, &pkg_id, state)?; } - seen.insert(pkg_id); - resolved.push(package); - + state.resolved.push(package); Ok(()) } /// Find a package matching a request, preferring a pinned version. - fn find_package(&self, request: &PackageRequest) -> Result { - let versions = self.package_cache.get(&request.name) - .ok_or_else(|| anyhow::anyhow!("Package not found: {}", request.name))?; + fn find_package(&self, request: &PackageRequest, requester: &str) -> Result { + let Some(versions) = self.package_cache.get(&request.name) else { + anyhow::bail!( + "Package not found: '{}' (required by {})", + request.name, + requester, + ); + }; - // Lockfile pin takes priority + // Lockfile pin takes priority — but only if it satisfies the + // request's constraint, otherwise we'd silently break the request. if let Some(pinned) = self.pins.get(&request.name) { if let Some(pkg) = versions.get(pinned) { - debug!("Using pinned version: {}-{}", request.name, pinned); - return Ok(pkg.clone()); + if request.matches(&pkg.version) { + debug!("Using pinned version: {}-{}", request.name, pinned); + return Ok(pkg.clone()); + } + warn!( + "Pinned version {}-{} does not satisfy {} (required by {}); resolving normally", + request.name, pinned, request, requester, + ); + } else { + warn!( + "Pinned version {}-{} not found; resolving normally", + request.name, pinned, + ); } - warn!( - "Pinned version {}-{} not found, resolving normally", - request.name, pinned - ); } let mut matching: Vec<&Package> = versions @@ -294,10 +397,22 @@ impl Resolver { .collect(); if matching.is_empty() { + let mut available: Vec<&String> = versions.keys().collect(); + available.sort(); + let constraint_note = match &request.version_constraint { + VersionConstraint::Any => String::new(), + c => format!(" matching '{}'", c), + }; anyhow::bail!( - "No matching version for {}: available versions are {:?}", + "No version of '{}'{} (required by {}). Available: [{}]", request.name, - versions.keys().collect::>() + constraint_note, + requester, + available + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", "), ); } @@ -335,7 +450,7 @@ impl Resolver { /// Get a specific package pub fn get_package(&self, id: &str) -> Result { let request = PackageRequest::parse(id)?; - self.find_package(&request) + self.find_package(&request, "") } /// Validate a package definition. Returns `Err` for fatal problems @@ -343,11 +458,11 @@ impl Resolver { /// non-fatal command-target issues (caller decides how to surface them). pub fn validate_package_report(&self, id: &str) -> Result> { let request = PackageRequest::parse(id)?; - let package = self.find_package(&request)?; + let package = self.find_package(&request, "")?; for dep_str in &package.requires { let dep_request = PackageRequest::parse(dep_str)?; - self.find_package(&dep_request) + self.find_package(&dep_request, &package.id()) .with_context(|| format!("Missing dependency: {}", dep_str))?; } diff --git a/tests/cli.rs b/tests/cli.rs index 39a8d73..ed70c29 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1055,3 +1055,95 @@ fn shell_help_exposes_shim_flags() { .stdout(predicate::str::contains("--env-only")) .stdout(predicate::str::contains("--no-sweep")); } + +// ---- resolver conflict diagnostics ---- + +/// Set up a temp dir with two python versions and two packages that pin +/// incompatible pythons. Returns (TempDir, config_path). +fn setup_conflicting_pythons() -> (TempDir, String) { + let dir = TempDir::new().unwrap(); + let pkg_dir = dir.path().join("packages"); + fs::create_dir_all(&pkg_dir).unwrap(); + + for v in ["3.10", "3.11"] { + let d = pkg_dir.join(format!("python/{}", v)); + fs::create_dir_all(&d).unwrap(); + fs::write( + d.join("package.yaml"), + format!("name: python\nversion: \"{}\"\n", v), + ) + .unwrap(); + } + + fs::write( + pkg_dir.join("alpha-1.0.yaml"), + "name: alpha\nversion: \"1.0\"\nrequires:\n - python-3.10\n", + ) + .unwrap(); + fs::write( + pkg_dir.join("beta-1.0.yaml"), + "name: beta\nversion: \"1.0\"\nrequires:\n - python-3.11\n", + ) + .unwrap(); + + let config_path = dir.path().join("config.yaml"); + fs::write( + &config_path, + format!("package_paths:\n - {}\n", pkg_dir.display()), + ) + .unwrap(); + (dir, config_path.to_string_lossy().to_string()) +} + +#[test] +fn conflict_lists_both_requesters_and_constraints() { + let (_dir, cfg) = setup_conflicting_pythons(); + anvil(&cfg) + .args(["env", "alpha-1.0", "beta-1.0"]) + .assert() + .failure() + .stderr(predicate::str::contains("version conflict for 'python'")) + .stderr(predicate::str::contains("alpha-1.0 required python-3.10")) + .stderr(predicate::str::contains("beta-1.0 required python-3.11")) + .stderr(predicate::str::contains("INCOMPATIBLE")); +} + +#[test] +fn missing_version_names_the_requester() { + let (_dir, cfg) = setup_conflicting_pythons(); + // Ask for a python version that doesn't exist; the error should + // attribute the failing constraint to the top-level request. + anvil(&cfg) + .args(["env", "python-3.99"]) + .assert() + .failure() + .stderr(predicate::str::contains("No version of 'python'")) + .stderr(predicate::str::contains("required by ")) + .stderr(predicate::str::contains("3.10")) + .stderr(predicate::str::contains("3.11")); +} + +#[test] +fn missing_dep_names_the_parent_package() { + let dir = TempDir::new().unwrap(); + let pkg_dir = dir.path().join("packages"); + fs::create_dir_all(&pkg_dir).unwrap(); + // alpha requires a package that doesn't exist anywhere. + fs::write( + pkg_dir.join("alpha-1.0.yaml"), + "name: alpha\nversion: \"1.0\"\nrequires:\n - missing-pkg-1.0\n", + ) + .unwrap(); + let config_path = dir.path().join("config.yaml"); + fs::write( + &config_path, + format!("package_paths:\n - {}\n", pkg_dir.display()), + ) + .unwrap(); + anvil(&config_path.to_string_lossy()) + .args(["env", "alpha-1.0"]) + .assert() + .failure() + .stderr(predicate::str::contains("Package not found: 'missing-pkg'")) + .stderr(predicate::str::contains("required by alpha-1.0")); +} From 93283393a11e973020d6774ff26cfc5166893699 Mon Sep 17 00:00:00 2001 From: voidreamer Date: Wed, 29 Apr 2026 16:45:08 -0400 Subject: [PATCH 2/8] lockfile: record content hashes and warn on drift Each pin now stores a SHA-256 of the package definition file alongside its version. When the resolver loads a lockfile it compares each recorded hash against the file currently on disk and emits a warning if they differ -- the case where someone edits a shared package on a studio filesystem after a project locked, which used to be silent. Pin gains a custom Deserialize that accepts both the new map form python: { version: "3.11", content_hash: "..." } and the legacy string form python: "3.11" so existing anvil.lock files keep working without re-locking. Adds the sha2 crate, a content_hash() helper on Package, and a source_path field that the loader populates so the hash can be recomputed without re-walking the package paths. --- Cargo.toml | 3 ++ src/context.rs | 49 +++++++++++++++++++++++++++-- src/main.rs | 10 ++++-- src/package.rs | 37 ++++++++++++++++------ src/resolver.rs | 48 +++++++++++++++++++++++----- tests/cli.rs | 83 +++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 208 insertions(+), 22 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index adff2e4..a815aef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,9 @@ regex = "1.10" cfg-if = "1.0" tempfile = "3.10" +# Hashing (lockfile drift detection) +sha2 = "0.10" + [dev-dependencies] assert_cmd = "2.0" predicates = "3.1" diff --git a/src/context.rs b/src/context.rs index 8ffe40d..0001da6 100644 --- a/src/context.rs +++ b/src/context.rs @@ -4,12 +4,55 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; // --------------------------------------------------------------------------- // Lockfile // --------------------------------------------------------------------------- +/// One entry in `Lockfile.pins`. Carries the version and an optional +/// content hash so drift in shared package directories is detectable. +/// +/// Deserializes from either the modern map form +/// `python: { version: "3.11", content_hash: "..." }` +/// or the pre-0.5 string form +/// `python: "3.11"` +/// so older `anvil.lock` files keep working. +#[derive(Debug, Clone, Serialize)] +pub struct Pin { + pub version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_hash: Option, +} + +impl<'de> Deserialize<'de> for Pin { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum PinForm { + Legacy(String), + Modern { + version: String, + #[serde(default)] + content_hash: Option, + }, + } + Ok(match PinForm::deserialize(deserializer)? { + PinForm::Legacy(version) => Pin { + version, + content_hash: None, + }, + PinForm::Modern { + version, + content_hash, + } => Pin { + version, + content_hash, + }, + }) + } +} + /// Pins package versions for reproducible resolution. /// /// Stored as `anvil.lock` in the project directory. When present, the @@ -19,8 +62,8 @@ use serde::{Deserialize, Serialize}; pub struct Lockfile { /// Original package requests that produced this lockfile. pub requests: Vec, - /// Pinned versions: package name -> exact version string. - pub pins: HashMap, + /// Pinned versions: package name -> pin entry. + pub pins: HashMap, } impl Lockfile { diff --git a/src/main.rs b/src/main.rs index d349797..321c2ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,7 @@ mod shell; use cli::{Cli, Commands, ContextAction}; use config::Config; -use context::{ContextPackage, Lockfile, SavedContext}; +use context::{ContextPackage, Lockfile, Pin, SavedContext}; use resolver::Resolver; fn main() -> Result<()> { @@ -404,7 +404,13 @@ fn cmd_lock(config: &Config, packages: &[String], refresh: bool) -> Result<()> { let mut pins = std::collections::HashMap::new(); for pkg in resolved.packages() { - pins.insert(pkg.name.clone(), pkg.version.clone()); + pins.insert( + pkg.name.clone(), + Pin { + version: pkg.version.clone(), + content_hash: pkg.content_hash(), + }, + ); } let lockfile = Lockfile { diff --git a/src/package.rs b/src/package.rs index d49a8f6..e216b30 100644 --- a/src/package.rs +++ b/src/package.rs @@ -20,7 +20,7 @@ pub const EXE_SUFFIX: &str = ".exe"; pub const EXE_SUFFIX: &str = ""; /// A package definition -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Package { /// Package name pub name: String, @@ -50,6 +50,13 @@ pub struct Package { /// Path to the package root (set after loading, omitted from package.yaml) #[serde(default)] pub root: PathBuf, + + /// Path to the YAML file this package was loaded from. Populated by + /// the loader; used to compute a content hash for lockfile drift + /// detection. Skipped from package.yaml itself but kept in the scan + /// cache so we don't have to rediscover it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_path: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -95,17 +102,29 @@ impl Package { .map(|p| p.to_path_buf()) .or_else(|| file_path.parent().map(|p| p.to_path_buf())) .unwrap_or_default(); + package.source_path = Some(file_path.to_path_buf()); // Apply variant for current platform package.apply_current_variant(); Ok(package) } - + /// Get the full package identifier (name-version) pub fn id(&self) -> String { format!("{}-{}", self.name, self.version) } + + /// Compute a SHA-256 hex digest of the package definition file. + /// Returns None if the source path isn't set or the file can't be read. + pub fn content_hash(&self) -> Option { + use sha2::{Digest, Sha256}; + let path = self.source_path.as_ref()?; + let bytes = std::fs::read(path).ok()?; + let mut hasher = Sha256::new(); + hasher.update(&bytes); + Some(format!("{:x}", hasher.finalize())) + } /// Apply the variant matching the current platform fn apply_current_variant(&mut self) { @@ -481,7 +500,7 @@ mod tests { environment: IndexMap::new(), commands: HashMap::new(), variants: vec![], - root: PathBuf::from("/opt/test/1.0"), + root: PathBuf::from("/opt/test/1.0"), source_path: None, }; let env = HashMap::new(); assert_eq!( @@ -500,7 +519,7 @@ mod tests { environment: IndexMap::new(), commands: HashMap::new(), variants: vec![], - root: PathBuf::from("/opt/maya"), + root: PathBuf::from("/opt/maya"), source_path: None, }; let env = HashMap::new(); assert_eq!(pkg.expand_env_value("${NAME}-${VERSION}", &env), "maya-2024"); @@ -516,7 +535,7 @@ mod tests { environment: IndexMap::new(), commands: HashMap::new(), variants: vec![], - root: PathBuf::from("/tmp"), + root: PathBuf::from("/tmp"), source_path: None, }; let env = HashMap::new(); let expected = if cfg!(target_os = "windows") { @@ -540,7 +559,7 @@ mod tests { environment: IndexMap::new(), commands: HashMap::new(), variants: vec![], - root: PathBuf::from("/tmp"), + root: PathBuf::from("/tmp"), source_path: None, }; let env = HashMap::new(); let expected = if cfg!(target_os = "windows") { @@ -564,7 +583,7 @@ mod tests { environment: IndexMap::new(), commands: HashMap::new(), variants: vec![], - root: PathBuf::from("/tmp"), + root: PathBuf::from("/tmp"), source_path: None, }; let env = HashMap::new(); let home = dirs::home_dir().expect("test needs a HOME"); @@ -601,7 +620,7 @@ mod tests { environment: IndexMap::new(), commands: HashMap::new(), variants: vec![], - root: PathBuf::from("/tmp"), + root: PathBuf::from("/tmp"), source_path: None, }; let env = HashMap::new(); // No `~/` at start or after `:` / `;`, so nothing should change. @@ -618,7 +637,7 @@ mod tests { environment: IndexMap::new(), commands: HashMap::new(), variants: vec![], - root: PathBuf::from("/tmp"), + root: PathBuf::from("/tmp"), source_path: None, }; let mut env = HashMap::new(); env.insert("HFS".into(), "/opt/houdini".into()); diff --git a/src/resolver.rs b/src/resolver.rs index e7260aa..795b769 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -15,7 +15,7 @@ use tracing::{debug, info, warn}; use crate::cache; use crate::config::Config; -use crate::context::Lockfile; +use crate::context::{Lockfile, Pin}; use crate::package::{tokenize_command, Package, PackageRequest, VersionConstraint}; /// One constraint asked for a package, plus who asked. @@ -134,8 +134,9 @@ pub struct Resolver { config: Config, /// Cache of loaded packages: name -> version -> Package package_cache: HashMap>, - /// Version pins from a lockfile (empty when unlocked). - pins: HashMap, + /// Pins from a lockfile (empty when unlocked). Carries version + /// plus optional content hash for drift detection. + pins: HashMap, } impl Resolver { @@ -156,6 +157,7 @@ impl Resolver { pins, }; resolver.load_packages(refresh)?; + resolver.verify_pin_hashes(); Ok(resolver) } @@ -170,6 +172,36 @@ impl Resolver { Ok(resolver) } + /// Compare each pin's recorded content hash against the package + /// definition currently on disk. Mismatches produce a warning so + /// teams sharing a `package_paths` filesystem can detect upstream + /// edits that would otherwise silently change resolution behaviour. + fn verify_pin_hashes(&self) { + for (name, pin) in &self.pins { + let Some(expected) = pin.content_hash.as_deref() else { + continue; + }; + let Some(versions) = self.package_cache.get(name) else { + continue; + }; + let Some(pkg) = versions.get(&pin.version) else { + continue; + }; + if let Some(actual) = pkg.content_hash() { + if actual != expected { + warn!( + "lockfile drift: {}-{} content hash differs from anvil.lock \ + (expected {}, got {}) -- re-run `anvil lock` to refresh", + name, + pin.version, + &expected[..12.min(expected.len())], + &actual[..12.min(actual.len())], + ); + } + } + } + } + /// Load packages: try the cache first (unless `refresh`), fall back to a full scan. fn load_packages(&mut self, refresh: bool) -> Result<()> { let paths = self.config.all_package_paths(); @@ -373,20 +405,20 @@ impl Resolver { // Lockfile pin takes priority — but only if it satisfies the // request's constraint, otherwise we'd silently break the request. - if let Some(pinned) = self.pins.get(&request.name) { - if let Some(pkg) = versions.get(pinned) { + if let Some(pin) = self.pins.get(&request.name) { + if let Some(pkg) = versions.get(&pin.version) { if request.matches(&pkg.version) { - debug!("Using pinned version: {}-{}", request.name, pinned); + debug!("Using pinned version: {}-{}", request.name, pin.version); return Ok(pkg.clone()); } warn!( "Pinned version {}-{} does not satisfy {} (required by {}); resolving normally", - request.name, pinned, request, requester, + request.name, pin.version, request, requester, ); } else { warn!( "Pinned version {}-{} not found; resolving normally", - request.name, pinned, + request.name, pin.version, ); } } diff --git a/tests/cli.rs b/tests/cli.rs index ed70c29..9a028a4 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1123,6 +1123,89 @@ fn missing_version_names_the_requester() { .stderr(predicate::str::contains("3.11")); } +// ---- lockfile content hashes ---- + +#[test] +fn lock_records_content_hashes() { + let (dir, cfg) = setup_env(); + anvil(&cfg) + .current_dir(dir.path()) + .args(["lock", "maya-2024"]) + .assert() + .success(); + + let lock = fs::read_to_string(dir.path().join("anvil.lock")).unwrap(); + assert!(lock.contains("content_hash:"), "lockfile should record hashes:\n{}", lock); + // SHA-256 hex digest is 64 chars; spot-check that something hex-shaped is there. + assert!( + lock.lines().any(|l| l.contains("content_hash:") + && l.split(':').last().unwrap().trim().len() >= 32), + "lockfile hash should be a long hex digest:\n{}", + lock, + ); +} + +#[test] +fn legacy_string_form_lockfile_still_parses() { + let (dir, cfg) = setup_env(); + // Write a legacy-format lockfile by hand (pre-0.5 string-valued pins). + fs::write( + dir.path().join("anvil.lock"), + "requests:\n - maya-2024\npins:\n maya: \"2024\"\n python: \"3.11\"\n", + ) + .unwrap(); + + anvil(&cfg) + .current_dir(dir.path()) + .args(["env", "maya-2024"]) + .assert() + .success() + .stdout(predicate::str::contains("MAYA_VERSION=2024")); +} + +#[test] +fn drift_warning_when_package_changes_after_lock() { + let dir = TempDir::new().unwrap(); + let pkg_dir = dir.path().join("packages"); + fs::create_dir_all(&pkg_dir).unwrap(); + let pkg_path = pkg_dir.join("widget-1.0.yaml"); + fs::write( + &pkg_path, + "name: widget\nversion: \"1.0\"\nenvironment:\n WIDGET: original\n", + ) + .unwrap(); + let config_path = dir.path().join("config.yaml"); + fs::write( + &config_path, + format!("package_paths:\n - {}\n", pkg_dir.display()), + ) + .unwrap(); + let cfg = config_path.to_string_lossy().to_string(); + + anvil(&cfg) + .current_dir(dir.path()) + .args(["lock", "widget-1.0"]) + .assert() + .success(); + + // Tamper: same version, different bytes. + fs::write( + &pkg_path, + "name: widget\nversion: \"1.0\"\nenvironment:\n WIDGET: TAMPERED\n", + ) + .unwrap(); + + let mut cmd = Command::cargo_bin("anvil").unwrap(); + cmd.env("ANVIL_CONFIG", &cfg); + cmd.env("RUST_LOG", "anvil=warn"); + cmd.current_dir(dir.path()) + .args(["env", "widget-1.0", "--refresh"]) + .assert() + .success() + .stderr(predicate::str::contains("lockfile drift")) + .stderr(predicate::str::contains("widget-1.0")); +} + #[test] fn missing_dep_names_the_parent_package() { let dir = TempDir::new().unwrap(); From b3c30d2887daf43506a5e9d0abe43c2c69298122 Mon Sep 17 00:00:00 2001 From: voidreamer Date: Wed, 29 Apr 2026 16:50:42 -0400 Subject: [PATCH 3/8] lockfile: cross-platform pins via --all-platforms Variant application moved from package load time to the resolver: the package cache now stores raw definitions (variants intact, requires/env not yet merged), and the resolver merges the variant for the chosen platform when materialising each resolved package. This lets `anvil lock --all-platforms` resolve the same request set as if running on linux, macos, and windows in turn from a single scan. The lockfile schema gains two fields, both with serde defaults so older files still parse: - platforms: which platforms were resolved at lock time - platform_pins: per-platform overlay applied on top of common `pins` Pins shared by every locked platform (same name + version + content hash) live in `pins`; pins that differ per platform live under `platform_pins[]`. When the resolver loads a lockfile it overlays the entry for the current running platform via the new Lockfile::effective_pins helper, so a single anvil.lock is correct on any locked target. The package scan cache salt is bumped so caches written by older anvil binaries (which stored variant-merged Packages) get re-built rather than confusing the new resolve-time variant logic. --- src/cli.rs | 7 +++ src/context.rs | 33 ++++++++++++++ src/main.rs | 117 ++++++++++++++++++++++++++++++++++++++++-------- src/package.rs | 51 ++++++++++++--------- src/resolver.rs | 56 ++++++++++++++++++----- tests/cli.rs | 94 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 310 insertions(+), 48 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 23620df..07571b6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -104,6 +104,13 @@ pub enum Commands { /// Re-resolve even if anvil.lock already exists #[arg(long)] update: bool, + + /// Resolve for every supported platform (linux, macos, windows) + /// and union the results, so a single lockfile is correct on + /// any of them. Variant-specific `requires:` are recorded under + /// the relevant platform. + #[arg(long)] + all_platforms: bool, }, /// Save and restore complete resolved environments diff --git a/src/context.rs b/src/context.rs index 0001da6..0da0a87 100644 --- a/src/context.rs +++ b/src/context.rs @@ -58,12 +58,45 @@ impl<'de> Deserialize<'de> for Pin { /// Stored as `anvil.lock` in the project directory. When present, the /// resolver prefers pinned versions over the default "highest matching" /// strategy. +/// +/// `pins` holds packages that resolve identically on every locked +/// platform. `platform_pins` holds per-platform overrides for cases +/// where a variant's `requires:` pulls in a different version (or a +/// different package entirely) on different platforms — `anvil lock +/// --all-platforms` records those, and the reader overlays the entry +/// for its current platform on top of `pins`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Lockfile { /// Original package requests that produced this lockfile. pub requests: Vec, + /// Platforms this lockfile was resolved for. Empty in legacy + /// lockfiles; treat empty as "current platform only." + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub platforms: Vec, /// Pinned versions: package name -> pin entry. pub pins: HashMap, + /// Per-platform pin overrides. Keys are platform names + /// (linux/macos/windows); values overlay `pins` for that platform. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub platform_pins: HashMap>, +} + +impl Lockfile { + /// Pins applicable to `platform`: start from common `pins`, overlay + /// any platform-specific entries. Used by the resolver to choose + /// the right pin when a single lockfile carries diffs across + /// platforms. + pub fn effective_pins(&self, platform: Option<&str>) -> HashMap { + let mut out = self.pins.clone(); + if let Some(p) = platform { + if let Some(over) = self.platform_pins.get(p) { + for (k, v) in over { + out.insert(k.clone(), v.clone()); + } + } + } + out + } } impl Lockfile { diff --git a/src/main.rs b/src/main.rs index 321c2ac..8e4c17f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -66,8 +66,12 @@ fn main() -> Result<()> { Commands::Validate { package, strict } => { cmd_validate(&config, package, strict, refresh)?; } - Commands::Lock { packages, update: _ } => { - cmd_lock(&config, &packages, refresh)?; + Commands::Lock { + packages, + update: _, + all_platforms, + } => { + cmd_lock(&config, &packages, refresh, all_platforms)?; } Commands::Context { action } => match action { ContextAction::Save { packages, output } => { @@ -397,34 +401,111 @@ fn cmd_validate( // --------------------------------------------------------------------------- /// Resolve packages and write pinned versions to `anvil.lock`. -fn cmd_lock(config: &Config, packages: &[String], refresh: bool) -> Result<()> { - // Always resolve fresh (ignore existing lockfile). +/// +/// When `all_platforms` is true, the resolver runs once per supported +/// platform and the resulting pins are unioned: pins shared by every +/// platform live in `pins`, and pins that differ live under +/// `platform_pins[]`. This makes a single lockfile correct +/// on Linux, macOS, and Windows even when a package's variant block +/// pulls in different transitive deps per platform. +fn cmd_lock( + config: &Config, + packages: &[String], + refresh: bool, + all_platforms: bool, +) -> Result<()> { let resolver = Resolver::new_unlocked(config, refresh)?; - let resolved = resolver.resolve(packages)?; - let mut pins = std::collections::HashMap::new(); - for pkg in resolved.packages() { - pins.insert( - pkg.name.clone(), - Pin { - version: pkg.version.clone(), - content_hash: pkg.content_hash(), - }, - ); + // Which platforms to resolve for. + let targets: Vec<&str> = if all_platforms { + vec!["linux", "macos", "windows"] + } else { + match package::Package::current_platform() { + Some(p) => vec![p], + None => vec![], + } + }; + + // Resolve per platform. + type PinMap = std::collections::HashMap; + let mut per_platform: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for &platform in &targets { + let resolved = resolver.resolve_for_platform(packages, Some(platform))?; + let mut pins = PinMap::new(); + for pkg in resolved.packages() { + pins.insert( + pkg.name.clone(), + Pin { + version: pkg.version.clone(), + content_hash: pkg.content_hash(), + }, + ); + } + per_platform.insert(platform.to_string(), pins); + } + + // Union: any (name, version, hash) shared by *every* resolved + // platform goes into common `pins`; the rest goes under + // `platform_pins`. + let mut common: PinMap = std::collections::HashMap::new(); + let mut platform_pins: std::collections::HashMap = + std::collections::HashMap::new(); + + if let Some(first) = per_platform.values().next().cloned() { + for (name, pin) in first { + let same_everywhere = per_platform.values().all(|m| { + m.get(&name) + .map(|p| p.version == pin.version && p.content_hash == pin.content_hash) + .unwrap_or(false) + }); + if same_everywhere { + common.insert(name, pin); + } + } + } + for (platform, pins) in &per_platform { + for (name, pin) in pins { + if !common.contains_key(name) { + platform_pins + .entry(platform.clone()) + .or_default() + .insert(name.clone(), pin.clone()); + } + } } let lockfile = Lockfile { requests: packages.to_vec(), - pins, + platforms: targets.iter().map(|s| s.to_string()).collect(), + pins: common, + platform_pins, }; let lock_path = std::path::PathBuf::from("anvil.lock"); lockfile.save(&lock_path)?; - println!("Locked {} packages to anvil.lock:", resolved.packages().len()); - for pkg in resolved.packages() { - println!(" {}-{}", pkg.name, pkg.version); + let total: usize = per_platform.values().map(|m| m.len()).sum(); + println!( + "Locked {} pin(s) across {} platform(s) to anvil.lock", + lockfile.pins.len() + + lockfile + .platform_pins + .values() + .map(|m| m.len()) + .sum::(), + targets.len(), + ); + for (name, pin) in &lockfile.pins { + println!(" {}-{}", name, pin.version); + } + for (platform, pins) in &lockfile.platform_pins { + println!(" [{}]", platform); + for (name, pin) in pins { + println!(" {}-{}", name, pin.version); + } } + let _ = total; // touched for clarity above Ok(()) } diff --git a/src/package.rs b/src/package.rs index e216b30..0fe5469 100644 --- a/src/package.rs +++ b/src/package.rs @@ -87,6 +87,11 @@ impl Package { /// Load a package from a YAML file directly. /// If `root` is None, the parent directory of the file is used as the package root. + /// + /// Variants are NOT applied here. The caller (typically the resolver) + /// chooses a target platform and calls `with_variant_for` to materialise + /// a per-platform copy. This lets the resolver do cross-platform lock + /// resolution from a single cached scan. pub fn load_from_file(file_path: &Path, root: Option<&Path>) -> Result { if !file_path.exists() { anyhow::bail!("Package file not found: {:?}", file_path); @@ -104,9 +109,6 @@ impl Package { .unwrap_or_default(); package.source_path = Some(file_path.to_path_buf()); - // Apply variant for current platform - package.apply_current_variant(); - Ok(package) } @@ -126,29 +128,38 @@ impl Package { Some(format!("{:x}", hasher.finalize())) } - /// Apply the variant matching the current platform - fn apply_current_variant(&mut self) { - let current_platform = if cfg!(target_os = "linux") { - "linux" + /// The platform name the running binary identifies as + /// (linux/macos/windows), or None on unsupported targets. + pub fn current_platform() -> Option<&'static str> { + if cfg!(target_os = "linux") { + Some("linux") } else if cfg!(target_os = "windows") { - "windows" + Some("windows") } else if cfg!(target_os = "macos") { - "macos" + Some("macos") } else { - return; - }; - - for variant in &self.variants { - if variant.platform.as_deref() == Some(current_platform) { - // Merge variant requires - self.requires.extend(variant.requires.clone()); - - // Merge variant environment - for (key, value) in &variant.environment { - self.environment.insert(key.clone(), value.clone()); + None + } + } + + /// Return a copy of this package with the variant for `platform` + /// merged into its requires/environment. If `platform` is None, + /// the current target's platform is used; on unsupported platforms + /// no variant is applied. + pub fn with_variant_for(&self, platform: Option<&str>) -> Self { + let mut out = self.clone(); + let target = platform.or(Self::current_platform()); + if let Some(target) = target { + for variant in &self.variants { + if variant.platform.as_deref() == Some(target) { + out.requires.extend(variant.requires.clone()); + for (key, value) in &variant.environment { + out.environment.insert(key.clone(), value.clone()); + } } } } + out } /// Expand environment variables and tilde in a value diff --git a/src/resolver.rs b/src/resolver.rs index 795b769..c62de7c 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -36,12 +36,16 @@ struct ChosenPackage { /// Mutable state carried through depth-first resolution. #[derive(Debug, Default)] struct ResolveState { - /// Packages output in dependency order. + /// Packages output in dependency order. Each one has the variant + /// for `target_platform` already merged. resolved: Vec, /// Already-pushed package ids (`name-version`), for cycle short-circuit. seen: std::collections::HashSet, /// Picked version per package name, plus every constraint seen for it. chosen: HashMap, + /// Platform whose variants should be merged into chosen packages. + /// None means "do not apply any variant." + target_platform: Option, } /// Build a conflict message that names the chosen version, every requester @@ -146,7 +150,9 @@ impl Resolver { let pins = if let Some(lock_path) = Lockfile::find() { let lockfile = Lockfile::load(&lock_path)?; info!("Using lockfile: {:?}", lock_path); - lockfile.pins + // Overlay per-platform pins so a single lockfile resolved + // for multiple platforms picks the right entry on each. + lockfile.effective_pins(Package::current_platform()) } else { HashMap::new() }; @@ -207,7 +213,12 @@ impl Resolver { let paths = self.config.all_package_paths(); // Include config state in the cache key so different configs // don't share a cache (e.g. different filters or package paths). - let salt = format!("{:?}{:?}", self.config.package_paths, self.config.filters); + // The schema tag invalidates caches written by older anvil binaries + // that pre-merged platform variants into the cached Package. + let salt = format!( + "schema=v2|{:?}|{:?}", + self.config.package_paths, self.config.filters, + ); // Try cache if !refresh { @@ -308,9 +319,22 @@ impl Resolver { Ok(()) } - /// Resolve a list of package requests + /// Resolve a list of package requests for the current platform. pub fn resolve(&self, requests: &[String]) -> Result { - let mut state = ResolveState::default(); + self.resolve_for_platform(requests, Package::current_platform()) + } + + /// Resolve a list of package requests as if running on `platform`. + /// `None` means "no variant filter" (treat variants as inert). + pub fn resolve_for_platform( + &self, + requests: &[String], + platform: Option<&str>, + ) -> Result { + let mut state = ResolveState { + target_platform: platform.map(|s| s.to_string()), + ..ResolveState::default() + }; // Expand aliases let mut expanded_requests: Vec = Vec::new(); @@ -360,8 +384,11 @@ impl Resolver { return Ok(()); } - // Pick a version. - let package = self.find_package(request, requester)?; + // Pick a version, then merge in the variant for the target + // platform so transitive `requires` and `environment` reflect + // what the locked-for platform actually pulls. + let raw = self.find_package(request, requester)?; + let package = raw.with_variant_for(state.target_platform.as_deref()); let pkg_id = package.id(); // Record the choice before recursing into deps, so a cycle @@ -479,10 +506,15 @@ impl Resolver { Ok(version_list) } - /// Get a specific package + /// Get a specific package, with the variant for the current + /// platform already merged in. Callers that want the raw package + /// (no variant applied) can use `find_package` via the resolver's + /// internal API. pub fn get_package(&self, id: &str) -> Result { let request = PackageRequest::parse(id)?; - self.find_package(&request, "") + Ok(self + .find_package(&request, "")? + .with_variant_for(Package::current_platform())) } /// Validate a package definition. Returns `Err` for fatal problems @@ -490,7 +522,11 @@ impl Resolver { /// non-fatal command-target issues (caller decides how to surface them). pub fn validate_package_report(&self, id: &str) -> Result> { let request = PackageRequest::parse(id)?; - let package = self.find_package(&request, "")?; + // Validate against the current platform's view of the package so + // variant-only commands and requires are checked. + let package = self + .find_package(&request, "")? + .with_variant_for(Package::current_platform()); for dep_str in &package.requires { let dep_request = PackageRequest::parse(dep_str)?; diff --git a/tests/cli.rs b/tests/cli.rs index 9a028a4..9dc9595 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1206,6 +1206,100 @@ fn drift_warning_when_package_changes_after_lock() { .stderr(predicate::str::contains("widget-1.0")); } +// ---- cross-platform lockfile ---- + +/// Set up a temp dir with a package whose `variants:` block adds a +/// different transitive dep on each platform, plus the per-platform +/// candidate packages. Returns (TempDir, config_path). +fn setup_per_platform_variants() -> (TempDir, String) { + let dir = TempDir::new().unwrap(); + let pkg_dir = dir.path().join("packages"); + fs::create_dir_all(&pkg_dir).unwrap(); + + // Three platform-specific runtimes that only one platform pulls in. + for (name, ver) in [("gcc-runtime", "7"), ("clang-runtime", "15"), ("msvc-runtime", "2022")] { + let d = pkg_dir.join(format!("{}/{}", name, ver)); + fs::create_dir_all(&d).unwrap(); + fs::write( + d.join("package.yaml"), + format!("name: {}\nversion: \"{}\"\n", name, ver), + ) + .unwrap(); + } + + // omega-1.0 pulls in a different runtime on each platform. + fs::write( + pkg_dir.join("omega-1.0.yaml"), + r#" +name: omega +version: "1.0" +variants: + - platform: linux + requires: + - gcc-runtime-7 + - platform: macos + requires: + - clang-runtime-15 + - platform: windows + requires: + - msvc-runtime-2022 +"#, + ) + .unwrap(); + + let config_path = dir.path().join("config.yaml"); + fs::write( + &config_path, + format!("package_paths:\n - {}\n", pkg_dir.display()), + ) + .unwrap(); + (dir, config_path.to_string_lossy().to_string()) +} + +#[test] +fn lock_all_platforms_records_per_platform_pins() { + let (dir, cfg) = setup_per_platform_variants(); + anvil(&cfg) + .current_dir(dir.path()) + .args(["lock", "omega-1.0", "--all-platforms"]) + .assert() + .success(); + + let lock = fs::read_to_string(dir.path().join("anvil.lock")).unwrap(); + // omega is the same on every platform — common pin. + assert!(lock.contains("omega"), "omega should be pinned:\n{}", lock); + // Each runtime shows up under its platform overlay. + assert!( + lock.contains("platform_pins:"), + "expected platform_pins overlay:\n{}", + lock, + ); + assert!(lock.contains("gcc-runtime"), "missing linux runtime:\n{}", lock); + assert!(lock.contains("clang-runtime"), "missing macos runtime:\n{}", lock); + assert!(lock.contains("msvc-runtime"), "missing windows runtime:\n{}", lock); + // Lockfile records which platforms it covers. + assert!(lock.contains("platforms:"), "missing platforms list:\n{}", lock); +} + +#[test] +fn current_platform_lock_skips_overlay() { + let (dir, cfg) = setup_per_platform_variants(); + anvil(&cfg) + .current_dir(dir.path()) + .args(["lock", "omega-1.0"]) + .assert() + .success(); + + let lock = fs::read_to_string(dir.path().join("anvil.lock")).unwrap(); + // Without --all-platforms, only the running platform is locked. + // The overlay should be absent (skip_serializing_if = empty). + assert!( + !lock.contains("platform_pins:"), + "single-platform lock should not emit overlay:\n{}", + lock, + ); +} + #[test] fn missing_dep_names_the_parent_package() { let dir = TempDir::new().unwrap(); From 4a3be451416992be698bf59eb7834a535b4e5a1c Mon Sep 17 00:00:00 2001 From: voidreamer Date: Wed, 29 Apr 2026 16:54:12 -0400 Subject: [PATCH 4/8] cli: add --locked and --frozen global flags --locked: re-resolve the locked request set fresh and diff against the pins on disk before running any command. Any drift -- different version, different content hash, missing or extra package -- aborts with a per-line breakdown. Fails fast when there is no lockfile. Useful for CI to catch a forgotten `anvil lock` re-run. --frozen: refuses to use anything not already pinned. Every name the resolver touches (top-level requests, transitive requires) must appear in anvil.lock; otherwise the command fails with the offending package and its requester. Skipping a fresh resolve was already the default whenever a lockfile is present, so this is purely a guard against unintended drift on render farms or shared workstations. The two flags are mutually exclusive (clap conflicts_with) and apply globally to every command that constructs a Resolver. Lockfile gains a Lockfile::diff_pins helper that the --locked path uses to format the failure message; the same helper will be reused by `anvil sync`. --- src/cli.rs | 15 +++++++ src/context.rs | 31 +++++++++++++++ src/main.rs | 79 ++++++++++++++++++++++++++++++++----- src/resolver.rs | 30 ++++++++++++++ tests/cli.rs | 101 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 246 insertions(+), 10 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 07571b6..7e9ac9b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -17,6 +17,21 @@ pub struct Cli { #[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, global = true)] pub verbose: u8, + /// Verify that anvil.lock is up to date. Re-resolves the locked + /// request set fresh and compares against the pins on disk; any + /// drift (different version, different content hash, missing or + /// extra package) fails the command. Useful in CI. + #[arg(long, global = true, conflicts_with = "frozen")] + pub locked: bool, + + /// Use anvil.lock verbatim and never fall back to fresh + /// resolution. Any package the resolver would otherwise pick + /// from the package paths must already be pinned, otherwise the + /// command fails. Useful for render farms and other non-mutating + /// runs that must never silently drift. + #[arg(long, global = true, conflicts_with = "locked")] + pub frozen: bool, + #[command(subcommand)] pub command: Commands, } diff --git a/src/context.rs b/src/context.rs index 0da0a87..2e98ec5 100644 --- a/src/context.rs +++ b/src/context.rs @@ -116,6 +116,37 @@ impl Lockfile { .with_context(|| format!("Failed to write lockfile: {:?}", path)) } + /// Compare two pin maps and return a list of human-readable + /// differences. An empty Vec means they agree; otherwise each + /// entry is one drift line ("python: 3.10 -> 3.11" etc.). + pub fn diff_pins(expected: &HashMap, actual: &HashMap) -> Vec { + let mut diffs = Vec::new(); + for (name, want) in expected { + match actual.get(name) { + None => diffs.push(format!("{}: pinned {} but not produced by fresh resolve", name, want.version)), + Some(got) if got.version != want.version => diffs + .push(format!("{}: pinned {} but fresh resolve picks {}", name, want.version, got.version)), + Some(got) if want.content_hash.is_some() + && got.content_hash.is_some() + && got.content_hash != want.content_hash => + { + diffs.push(format!( + "{}-{}: content hash differs (lockfile vs disk)", + name, want.version + )) + } + _ => {} + } + } + for (name, got) in actual { + if !expected.contains_key(name) { + diffs.push(format!("{}: fresh resolve adds {} (not in lockfile)", name, got.version)); + } + } + diffs.sort(); + diffs + } + /// Search for `anvil.lock` starting from CWD and walking upward. pub fn find() -> Option { let mut dir = std::env::current_dir().ok()?; diff --git a/src/main.rs b/src/main.rs index 8e4c17f..ee5dfae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,16 +46,24 @@ fn main() -> Result<()> { // Load config let config = Config::load()?; let refresh = cli.refresh; + let frozen = cli.frozen; + + // --locked: re-resolve the locked request set fresh and diff + // against the pins on disk before running any command. Any drift + // fails the run. + if cli.locked { + verify_lockfile_fresh(&config, refresh)?; + } match cli.command { Commands::Env { packages, export, json } => { - cmd_env(&config, &packages, export, json, refresh)?; + cmd_env(&config, &packages, export, json, refresh, frozen)?; } Commands::Run { packages, env_vars, command } => { - cmd_run(&config, &packages, &env_vars, &command, refresh)?; + cmd_run(&config, &packages, &env_vars, &command, refresh, frozen)?; } Commands::Shell { packages, shell, env_only, no_sweep } => { - cmd_shell(&config, &packages, shell, refresh, env_only, no_sweep)?; + cmd_shell(&config, &packages, shell, refresh, env_only, no_sweep, frozen)?; } Commands::List { package } => { cmd_list(&config, package, refresh)?; @@ -75,7 +83,7 @@ fn main() -> Result<()> { } Commands::Context { action } => match action { ContextAction::Save { packages, output } => { - cmd_context_save(&config, &packages, &output, refresh)?; + cmd_context_save(&config, &packages, &output, refresh, frozen)?; } ContextAction::Show { file, json, export } => { cmd_context_show(&file, json, export)?; @@ -103,7 +111,7 @@ fn main() -> Result<()> { Cli::print_completions(shell); } Commands::Wrap { packages, dir, shell } => { - cmd_wrap(&config, &packages, &dir, &shell, refresh)?; + cmd_wrap(&config, &packages, &dir, &shell, refresh, frozen)?; } Commands::Publish { target, path, flat } => { cmd_publish(&target, path.as_deref(), flat)?; @@ -113,6 +121,52 @@ fn main() -> Result<()> { Ok(()) } +/// Helper: build a Resolver honouring the `--frozen` flag. +fn build_resolver(config: &Config, refresh: bool, frozen: bool) -> Result { + if frozen { + Resolver::new_frozen(config, refresh) + } else { + Resolver::new(config, refresh) + } +} + +/// Verify that anvil.lock matches a fresh resolution of its own +/// recorded request set. Any drift -- different version, different +/// content hash, missing or extra package -- aborts with a diff. +/// Called from `main` when `--locked` is set. +fn verify_lockfile_fresh(config: &Config, refresh: bool) -> Result<()> { + let lock_path = Lockfile::find() + .ok_or_else(|| anyhow::anyhow!("--locked: no anvil.lock found in this directory or any parent"))?; + let lockfile = Lockfile::load(&lock_path)?; + let current = package::Package::current_platform(); + let expected = lockfile.effective_pins(current); + + // Resolve fresh against the same request set. + let resolver = Resolver::new_unlocked(config, refresh)?; + let resolved = resolver.resolve(&lockfile.requests)?; + let mut actual = std::collections::HashMap::new(); + for pkg in resolved.packages() { + actual.insert( + pkg.name.clone(), + Pin { + version: pkg.version.clone(), + content_hash: pkg.content_hash(), + }, + ); + } + + let diffs = Lockfile::diff_pins(&expected, &actual); + if !diffs.is_empty() { + let mut msg = String::from("--locked: anvil.lock is stale\n"); + for d in &diffs { + msg.push_str(&format!(" - {}\n", d)); + } + msg.push_str("Re-run `anvil lock` to refresh."); + anyhow::bail!(msg); + } + Ok(()) +} + /// Resolve packages and print environment fn cmd_env( config: &Config, @@ -120,8 +174,9 @@ fn cmd_env( export: bool, json: bool, refresh: bool, + frozen: bool, ) -> Result<()> { - let resolver = Resolver::new(config, refresh)?; + let resolver = build_resolver(config, refresh, frozen)?; let resolved = resolver.resolve(packages)?; let env = resolved.environment(); @@ -147,13 +202,14 @@ fn cmd_run( env_vars: &[String], command: &[String], refresh: bool, + frozen: bool, ) -> Result<()> { use std::process::Command; // Pre-resolve hooks Config::run_hooks(&config.hooks.pre_resolve, &std::env::vars().collect())?; - let resolver = Resolver::new(config, refresh)?; + let resolver = build_resolver(config, refresh, frozen)?; let resolved = resolver.resolve(packages)?; let mut env = resolved.environment(); @@ -218,8 +274,9 @@ fn cmd_shell( refresh: bool, env_only: bool, no_sweep: bool, + frozen: bool, ) -> Result<()> { - let resolver = Resolver::new(config, refresh)?; + let resolver = build_resolver(config, refresh, frozen)?; let resolved = resolver.resolve(packages)?; let mut env = resolved.environment(); @@ -520,8 +577,9 @@ fn cmd_context_save( packages: &[String], output: &str, refresh: bool, + frozen: bool, ) -> Result<()> { - let resolver = Resolver::new(config, refresh)?; + let resolver = build_resolver(config, refresh, frozen)?; let resolved = resolver.resolve(packages)?; let env = resolved.environment(); @@ -717,8 +775,9 @@ fn cmd_wrap( dir: &str, wrapper_shell: &str, refresh: bool, + frozen: bool, ) -> Result<()> { - let resolver = Resolver::new(config, refresh)?; + let resolver = build_resolver(config, refresh, frozen)?; let resolved = resolver.resolve(packages)?; let commands = resolved.commands(); diff --git a/src/resolver.rs b/src/resolver.rs index c62de7c..ca68dde 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -141,18 +141,36 @@ pub struct Resolver { /// Pins from a lockfile (empty when unlocked). Carries version /// plus optional content hash for drift detection. pins: HashMap, + /// Reject any resolution lookup whose name is not in `pins`. + /// Set by `--frozen` so commands can never silently fall back to + /// fresh resolution. + frozen: bool, } impl Resolver { /// Create a new resolver, automatically loading `anvil.lock` if present. /// When `refresh` is true, the package scan cache is bypassed. pub fn new(config: &Config, refresh: bool) -> Result { + Self::with_options(config, refresh, false) + } + + /// Like `new`, but rejects any lookup whose name is not pinned + /// (the `--frozen` semantics). A lockfile is required. + pub fn new_frozen(config: &Config, refresh: bool) -> Result { + Self::with_options(config, refresh, true) + } + + fn with_options(config: &Config, refresh: bool, frozen: bool) -> Result { let pins = if let Some(lock_path) = Lockfile::find() { let lockfile = Lockfile::load(&lock_path)?; info!("Using lockfile: {:?}", lock_path); // Overlay per-platform pins so a single lockfile resolved // for multiple platforms picks the right entry on each. lockfile.effective_pins(Package::current_platform()) + } else if frozen { + anyhow::bail!( + "--frozen requires anvil.lock, but none was found in this directory or any parent" + ); } else { HashMap::new() }; @@ -161,6 +179,7 @@ impl Resolver { config: config.clone(), package_cache: HashMap::new(), pins, + frozen, }; resolver.load_packages(refresh)?; resolver.verify_pin_hashes(); @@ -173,6 +192,7 @@ impl Resolver { config: config.clone(), package_cache: HashMap::new(), pins: HashMap::new(), + frozen: false, }; resolver.load_packages(refresh)?; Ok(resolver) @@ -422,6 +442,16 @@ impl Resolver { /// Find a package matching a request, preferring a pinned version. fn find_package(&self, request: &PackageRequest, requester: &str) -> Result { + // Frozen mode: every name we touch must already be pinned. + if self.frozen && !self.pins.contains_key(&request.name) { + anyhow::bail!( + "--frozen: '{}' (required by {}) is not pinned in anvil.lock; \ + add it to the locked request set or drop --frozen", + request.name, + requester, + ); + } + let Some(versions) = self.package_cache.get(&request.name) else { anyhow::bail!( "Package not found: '{}' (required by {})", diff --git a/tests/cli.rs b/tests/cli.rs index 9dc9595..e1f1802 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1206,6 +1206,107 @@ fn drift_warning_when_package_changes_after_lock() { .stderr(predicate::str::contains("widget-1.0")); } +// ---- --locked / --frozen ---- + +#[test] +fn locked_passes_when_lockfile_matches_disk() { + let (dir, cfg) = setup_env(); + anvil(&cfg) + .current_dir(dir.path()) + .args(["lock", "maya-2024"]) + .assert() + .success(); + + anvil(&cfg) + .current_dir(dir.path()) + .args(["--locked", "env", "maya-2024"]) + .assert() + .success() + .stdout(predicate::str::contains("MAYA_VERSION=2024")); +} + +#[test] +fn locked_fails_when_lockfile_is_stale() { + let (dir, cfg) = setup_env(); + anvil(&cfg) + .current_dir(dir.path()) + .args(["lock", "maya-2024"]) + .assert() + .success(); + + // Hand-edit the lock to a version that doesn't exist on disk. + let lock_path = dir.path().join("anvil.lock"); + let original = fs::read_to_string(&lock_path).unwrap(); + let stale = original.replace("version: '2024'", "version: '1999'"); + fs::write(&lock_path, &stale).unwrap(); + + anvil(&cfg) + .current_dir(dir.path()) + .args(["--locked", "env", "maya-2024"]) + .assert() + .failure() + .stderr(predicate::str::contains("--locked: anvil.lock is stale")); +} + +#[test] +fn locked_fails_without_a_lockfile() { + let (dir, cfg) = setup_env(); + anvil(&cfg) + .current_dir(dir.path()) + .args(["--locked", "env", "maya-2024"]) + .assert() + .failure() + .stderr(predicate::str::contains("--locked: no anvil.lock")); +} + +#[test] +fn frozen_uses_lockfile_only() { + let (dir, cfg) = setup_env(); + anvil(&cfg) + .current_dir(dir.path()) + .args(["lock", "maya-2024"]) + .assert() + .success(); + + anvil(&cfg) + .current_dir(dir.path()) + .args(["--frozen", "env", "maya-2024"]) + .assert() + .success() + .stdout(predicate::str::contains("MAYA_VERSION=2024")); +} + +#[test] +fn frozen_fails_for_unpinned_package() { + let (dir, cfg) = setup_env(); + // Lock only maya — python is a transitive dep that *will* be pinned. + // Then ask for studio-blender-tools which was never resolved/pinned. + anvil(&cfg) + .current_dir(dir.path()) + .args(["lock", "maya-2024"]) + .assert() + .success(); + + anvil(&cfg) + .current_dir(dir.path()) + .args(["--frozen", "env", "studio-blender-tools-1.0.0"]) + .assert() + .failure() + .stderr(predicate::str::contains("--frozen")) + .stderr(predicate::str::contains("studio-blender-tools")); +} + +#[test] +fn frozen_without_lockfile_fails() { + let (dir, cfg) = setup_env(); + anvil(&cfg) + .current_dir(dir.path()) + .args(["--frozen", "env", "maya-2024"]) + .assert() + .failure() + .stderr(predicate::str::contains("--frozen requires anvil.lock")); +} + // ---- cross-platform lockfile ---- /// Set up a temp dir with a package whose `variants:` block adds a From 70d1d2926fdac767c5a4fe57a9aa587e1bb715ca Mon Sep 17 00:00:00 2001 From: voidreamer Date: Wed, 29 Apr 2026 16:55:52 -0400 Subject: [PATCH 5/8] add anvil sync to verify a lockfile against disk Walks every effective pin for the current platform and, for each: - confirms the pinned name+version exists in the package paths - compares content hashes (if recorded) and reports drift - validates each command-alias target resolves to an executable Prints one line per pin (ok / warn / fail) plus a summary, and exits non-zero only when one or more pins fail outright (missing package). Hash drift and broken command targets are warnings -- the lockfile is intact, but the operator should know. Useful before farm jobs that must not fail mid-run on a missing alias, or as a smoke test after a workstation rsyncs a new package set. --- src/cli.rs | 6 ++++ src/main.rs | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++ tests/cli.rs | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 204 insertions(+) diff --git a/src/cli.rs b/src/cli.rs index 7e9ac9b..17d5bd8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -134,6 +134,12 @@ pub enum Commands { action: ContextAction, }, + /// Verify that every pin in anvil.lock is reachable, hash-matching, + /// and that each pinned package's commands resolve to existing + /// executables. Read-only; useful before farm jobs that must not + /// fail mid-run on a missing alias. + Sync, + /// Scaffold a new package definition (or `--config` for the global config) Init { /// Package name (e.g., my-tools). Omit when using `--config`. diff --git a/src/main.rs b/src/main.rs index ee5dfae..ba10b14 100644 --- a/src/main.rs +++ b/src/main.rs @@ -113,6 +113,9 @@ fn main() -> Result<()> { Commands::Wrap { packages, dir, shell } => { cmd_wrap(&config, &packages, &dir, &shell, refresh, frozen)?; } + Commands::Sync => { + cmd_sync(&config, refresh)?; + } Commands::Publish { target, path, flat } => { cmd_publish(&target, path.as_deref(), flat)?; } @@ -453,6 +456,101 @@ fn cmd_validate( Ok(()) } +// --------------------------------------------------------------------------- +// Sync +// --------------------------------------------------------------------------- + +/// Verify every pin in anvil.lock against the package paths on disk. +/// Walks pins (current-platform overlay applied), and for each: +/// - confirms the pinned name+version exists on disk +/// - compares content hashes (if recorded) and reports drift +/// - validates command-alias targets resolve to executables +/// Returns non-zero on any failure; warnings (hash drift, broken +/// command targets) print but don't change the exit code. +fn cmd_sync(config: &Config, refresh: bool) -> Result<()> { + let lock_path = Lockfile::find() + .ok_or_else(|| anyhow::anyhow!("anvil sync: no anvil.lock found in this directory or any parent"))?; + let lockfile = Lockfile::load(&lock_path)?; + let current = package::Package::current_platform(); + let pins = lockfile.effective_pins(current); + + let resolver = Resolver::new_unlocked(config, refresh)?; + + let platform_label = current.unwrap_or("unknown"); + println!("Checking {} for {}: {} pin(s)", lock_path.display(), platform_label, pins.len()); + + let mut ok = 0usize; + let mut warnings = 0usize; + let mut failures = 0usize; + let mut names: Vec<&String> = pins.keys().collect(); + names.sort(); + + for name in names { + let pin = &pins[name]; + let id = format!("{}-{}", name, pin.version); + + // Existence check. + let pkg = match resolver.get_package(&id) { + Ok(p) => p, + Err(e) => { + println!(" fail {} -- {}", id, e); + failures += 1; + continue; + } + }; + + // Content hash drift. + if let Some(expected) = &pin.content_hash { + match pkg.content_hash() { + Some(actual) if &actual != expected => { + println!( + " warn {} -- content hash drift (locked {}, on-disk {})", + id, + &expected[..12.min(expected.len())], + &actual[..12.min(actual.len())], + ); + warnings += 1; + continue; + } + None => { + println!(" warn {} -- pinned hash present but file unreadable", id); + warnings += 1; + continue; + } + _ => {} + } + } + + // Validate command targets. + match resolver.validate_package_report(&id) { + Ok(problems) if !problems.is_empty() => { + println!(" warn {} -- {} command issue(s):", id, problems.len()); + for p in &problems { + println!(" {}", p); + } + warnings += 1; + } + Ok(_) => { + println!(" ok {}", id); + ok += 1; + } + Err(e) => { + println!(" fail {} -- {}", id, e); + failures += 1; + } + } + } + + println!( + "{} ok, {} warning(s), {} failure(s)", + ok, warnings, failures, + ); + if failures > 0 { + anyhow::bail!("anvil sync: {} pin(s) failed verification", failures); + } + Ok(()) +} + // --------------------------------------------------------------------------- // Lock // --------------------------------------------------------------------------- diff --git a/tests/cli.rs b/tests/cli.rs index e1f1802..c918f32 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1206,6 +1206,106 @@ fn drift_warning_when_package_changes_after_lock() { .stderr(predicate::str::contains("widget-1.0")); } +// ---- anvil sync ---- + +#[test] +fn sync_succeeds_when_pinned_packages_are_present() { + // The test fixture's command targets point to placeholder paths + // that don't exist on disk, so sync prints warnings -- but as + // long as the pinned package definitions resolve and their + // content hashes match, sync should still exit 0. + let (dir, cfg) = setup_env(); + anvil(&cfg) + .current_dir(dir.path()) + .args(["lock", "maya-2024"]) + .assert() + .success(); + + anvil(&cfg) + .current_dir(dir.path()) + .args(["sync"]) + .assert() + .success() + .stdout(predicate::str::contains("maya-2024")) + .stdout(predicate::str::contains("python-3.11")) + .stdout(predicate::str::contains("0 failure(s)")); +} + +#[test] +fn sync_fails_when_pinned_version_missing() { + let (dir, cfg) = setup_env(); + anvil(&cfg) + .current_dir(dir.path()) + .args(["lock", "maya-2024"]) + .assert() + .success(); + + // Hand-edit the lock to a version that doesn't exist on disk. + let lock_path = dir.path().join("anvil.lock"); + let original = fs::read_to_string(&lock_path).unwrap(); + fs::write(&lock_path, original.replace("version: '2024'", "version: '1999'")).unwrap(); + + anvil(&cfg) + .current_dir(dir.path()) + .args(["sync"]) + .assert() + .failure() + .stdout(predicate::str::contains("fail maya-1999")) + .stderr(predicate::str::contains("anvil sync")); +} + +#[test] +fn sync_warns_on_hash_drift() { + let dir = TempDir::new().unwrap(); + let pkg_dir = dir.path().join("packages"); + fs::create_dir_all(&pkg_dir).unwrap(); + let pkg_path = pkg_dir.join("widget-1.0.yaml"); + fs::write( + &pkg_path, + "name: widget\nversion: \"1.0\"\nenvironment:\n WIDGET: original\n", + ) + .unwrap(); + let config_path = dir.path().join("config.yaml"); + fs::write( + &config_path, + format!("package_paths:\n - {}\n", pkg_dir.display()), + ) + .unwrap(); + let cfg = config_path.to_string_lossy().to_string(); + + anvil(&cfg) + .current_dir(dir.path()) + .args(["lock", "widget-1.0"]) + .assert() + .success(); + + fs::write( + &pkg_path, + "name: widget\nversion: \"1.0\"\nenvironment:\n WIDGET: tampered\n", + ) + .unwrap(); + + anvil(&cfg) + .current_dir(dir.path()) + .args(["sync", "--refresh"]) + .assert() + .success() + .stdout(predicate::str::contains("warn widget-1.0")) + .stdout(predicate::str::contains("content hash drift")) + .stdout(predicate::str::contains("1 warning(s)")); +} + +#[test] +fn sync_fails_without_a_lockfile() { + let (dir, cfg) = setup_env(); + anvil(&cfg) + .current_dir(dir.path()) + .args(["sync"]) + .assert() + .failure() + .stderr(predicate::str::contains("no anvil.lock")); +} + // ---- --locked / --frozen ---- #[test] From 5a5576191b258a8678d30735292b5445efd1f528 Mon Sep 17 00:00:00 2001 From: voidreamer Date: Wed, 29 Apr 2026 16:59:48 -0400 Subject: [PATCH 6/8] add anvil tree to print the resolved dependency graph Resolves the requested package set and walks the requires graph, printing a Unicode-box ASCII tree with one root per top-level request. Repeated nodes (diamond dependencies, shared transitive deps) are marked `name-version (*)` after their first appearance, which keeps the output compact and terminates cycles. --- src/cli.rs | 7 ++++ src/main.rs | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++++ tests/cli.rs | 52 +++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+) diff --git a/src/cli.rs b/src/cli.rs index 17d5bd8..b13c3fd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -140,6 +140,13 @@ pub enum Commands { /// fail mid-run on a missing alias. Sync, + /// Print the dependency tree for a set of packages. + Tree { + /// Packages to resolve and visualise. + #[arg(required = true)] + packages: Vec, + }, + /// Scaffold a new package definition (or `--config` for the global config) Init { /// Package name (e.g., my-tools). Omit when using `--config`. diff --git a/src/main.rs b/src/main.rs index ba10b14..328985f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -116,6 +116,9 @@ fn main() -> Result<()> { Commands::Sync => { cmd_sync(&config, refresh)?; } + Commands::Tree { packages } => { + cmd_tree(&config, &packages, refresh, frozen)?; + } Commands::Publish { target, path, flat } => { cmd_publish(&target, path.as_deref(), flat)?; } @@ -456,6 +459,96 @@ fn cmd_validate( Ok(()) } +// --------------------------------------------------------------------------- +// Tree +// --------------------------------------------------------------------------- + +/// Print the resolved dependency graph as an ASCII tree. Each top-level +/// request is a root; transitive `requires` form the children. A node +/// that's already been printed once is shown as `name-version (*)` so +/// shared deps don't multiply the output and cycles terminate. +fn cmd_tree( + config: &Config, + packages: &[String], + refresh: bool, + frozen: bool, +) -> Result<()> { + use std::collections::{HashMap, HashSet}; + + let resolver = build_resolver(config, refresh, frozen)?; + let resolved = resolver.resolve(packages)?; + + let by_name: HashMap = resolved + .packages() + .iter() + .map(|p| (p.name.clone(), p)) + .collect(); + + let mut shown: HashSet = HashSet::new(); + + for (i, req) in packages.iter().enumerate() { + let request = match package::PackageRequest::parse(req) { + Ok(r) => r, + Err(_) => { + println!("{} (unparseable request)", req); + continue; + } + }; + let Some(pkg) = by_name.get(&request.name) else { + println!("{} (not in resolution)", req); + continue; + }; + if i > 0 { + println!(); + } + // Roots print without a connector; descendants print under + // `print_descendants` which manages the column drawing. + let id = pkg.id(); + let suffix = if shown.contains(&id) { " (*)" } else { "" }; + println!("{}{}", id, suffix); + if shown.contains(&id) { + continue; + } + shown.insert(id); + print_descendants(pkg, &by_name, &mut shown, ""); + } + + Ok(()) +} + +/// Print the dependency subtree of `parent`. `prefix` is the column +/// drawing accumulated from ancestor branches ("│ " when the +/// ancestor was a non-last sibling, " " when it was last). +fn print_descendants( + parent: &package::Package, + by_name: &std::collections::HashMap, + shown: &mut std::collections::HashSet, + prefix: &str, +) { + let mut deps: Vec<&package::Package> = Vec::new(); + for dep_str in &parent.requires { + let Ok(req) = package::PackageRequest::parse(dep_str) else { continue }; + if let Some(dep) = by_name.get(&req.name) { + deps.push(*dep); + } + } + let n = deps.len(); + for (i, dep) in deps.iter().enumerate() { + let is_last = i + 1 == n; + let connector = if is_last { "└── " } else { "├── " }; + let id = dep.id(); + let already = shown.contains(&id); + let suffix = if already { " (*)" } else { "" }; + println!("{}{}{}{}", prefix, connector, id, suffix); + if already { + continue; + } + shown.insert(id); + let next_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " }); + print_descendants(dep, by_name, shown, &next_prefix); + } +} + // --------------------------------------------------------------------------- // Sync // --------------------------------------------------------------------------- diff --git a/tests/cli.rs b/tests/cli.rs index c918f32..3f724cc 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1206,6 +1206,58 @@ fn drift_warning_when_package_changes_after_lock() { .stderr(predicate::str::contains("widget-1.0")); } +// ---- anvil tree ---- + +#[test] +fn tree_renders_dependency_graph_with_connectors() { + // Build a tree: app -> [foo, bar]; foo -> shared; bar -> shared. + // The second occurrence of `shared` should be marked `(*)` so + // the diamond doesn't print twice. + let dir = TempDir::new().unwrap(); + let pkg_dir = dir.path().join("packages"); + fs::create_dir_all(&pkg_dir).unwrap(); + + fs::write( + pkg_dir.join("shared-1.0.yaml"), + "name: shared\nversion: \"1.0\"\n", + ) + .unwrap(); + fs::write( + pkg_dir.join("foo-1.0.yaml"), + "name: foo\nversion: \"1.0\"\nrequires:\n - shared-1.0\n", + ) + .unwrap(); + fs::write( + pkg_dir.join("bar-1.0.yaml"), + "name: bar\nversion: \"1.0\"\nrequires:\n - shared-1.0\n", + ) + .unwrap(); + fs::write( + pkg_dir.join("app-1.0.yaml"), + "name: app\nversion: \"1.0\"\nrequires:\n - foo-1.0\n - bar-1.0\n", + ) + .unwrap(); + + let config_path = dir.path().join("config.yaml"); + fs::write( + &config_path, + format!("package_paths:\n - {}\n", pkg_dir.display()), + ) + .unwrap(); + + let assert = anvil(&config_path.to_string_lossy()) + .args(["tree", "app-1.0"]) + .assert() + .success(); + let stdout = String::from_utf8_lossy(&assert.get_output().stdout).to_string(); + + assert!(stdout.starts_with("app-1.0"), "should print root first:\n{}", stdout); + assert!(stdout.contains("├── foo-1.0"), "non-last child uses ├──:\n{}", stdout); + assert!(stdout.contains("└── bar-1.0"), "last child uses └──:\n{}", stdout); + assert!(stdout.contains("shared-1.0"), "shared dep should appear:\n{}", stdout); + assert!(stdout.contains("(*)"), "repeat marker for diamond dep:\n{}", stdout); +} + // ---- anvil sync ---- #[test] From 038e1b00828bc41222616ea396850f11007ca989 Mon Sep 17 00:00:00 2001 From: voidreamer Date: Wed, 29 Apr 2026 17:01:13 -0400 Subject: [PATCH 7/8] lock: add --upgrade-package for surgical pin bumps Repeatable flag. Loads the existing lockfile, drops only the named pins, then resolves -- so the upgraded names get the highest matching version while every other pin stays exactly where it was. Matches the mid-show case where you want python to bump but every other dep must not move. Errors out clearly if there is no anvil.lock to start from. Without the flag, `anvil lock` keeps its existing always-fresh behaviour. --- src/cli.rs | 7 +++++ src/main.rs | 28 ++++++++++++++++-- src/resolver.rs | 8 +++++ tests/cli.rs | 79 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 2 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index b13c3fd..1cd2b66 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -126,6 +126,13 @@ pub enum Commands { /// the relevant platform. #[arg(long)] all_platforms: bool, + + /// Re-resolve only this package name (repeatable), keeping + /// every other existing pin untouched. Without this flag, + /// `anvil lock` re-resolves every package; with it, the + /// lockfile is updated surgically. + #[arg(long = "upgrade-package", value_name = "NAME")] + upgrade_packages: Vec, }, /// Save and restore complete resolved environments diff --git a/src/main.rs b/src/main.rs index 328985f..e69f6d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -78,8 +78,9 @@ fn main() -> Result<()> { packages, update: _, all_platforms, + upgrade_packages, } => { - cmd_lock(&config, &packages, refresh, all_platforms)?; + cmd_lock(&config, &packages, refresh, all_platforms, &upgrade_packages)?; } Commands::Context { action } => match action { ContextAction::Save { packages, output } => { @@ -661,8 +662,31 @@ fn cmd_lock( packages: &[String], refresh: bool, all_platforms: bool, + upgrade_packages: &[String], ) -> Result<()> { - let resolver = Resolver::new_unlocked(config, refresh)?; + // For surgical upgrades, load the existing lockfile and reuse all + // pins except the names being upgraded. Without --upgrade-package + // we still resolve fresh (the historical behaviour). + let resolver = if upgrade_packages.is_empty() { + Resolver::new_unlocked(config, refresh)? + } else { + let lock_path = Lockfile::find().ok_or_else(|| { + anyhow::anyhow!( + "--upgrade-package needs an existing anvil.lock; run `anvil lock` first" + ) + })?; + let existing = Lockfile::load(&lock_path)?; + let mut keep = existing.effective_pins(package::Package::current_platform()); + for name in upgrade_packages { + if keep.remove(name).is_none() { + tracing::warn!( + "--upgrade-package {}: no existing pin found; resolving fresh", + name, + ); + } + } + Resolver::new_unlocked(config, refresh)?.with_pins(keep) + }; // Which platforms to resolve for. let targets: Vec<&str> = if all_platforms { diff --git a/src/resolver.rs b/src/resolver.rs index ca68dde..9b6bec9 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -198,6 +198,14 @@ impl Resolver { Ok(resolver) } + /// Replace this resolver's pins. Used by `anvil lock + /// --upgrade-package` to reuse most of an existing lockfile + /// while letting a few names re-resolve to their highest match. + pub fn with_pins(mut self, pins: HashMap) -> Self { + self.pins = pins; + self + } + /// Compare each pin's recorded content hash against the package /// definition currently on disk. Mismatches produce a warning so /// teams sharing a `package_paths` filesystem can detect upstream diff --git a/tests/cli.rs b/tests/cli.rs index 3f724cc..2eacb91 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1206,6 +1206,85 @@ fn drift_warning_when_package_changes_after_lock() { .stderr(predicate::str::contains("widget-1.0")); } +// ---- anvil lock --upgrade-package ---- + +#[test] +fn upgrade_package_only_re_resolves_named_package() { + let dir = TempDir::new().unwrap(); + let pkg_dir = dir.path().join("packages"); + fs::create_dir_all(&pkg_dir).unwrap(); + + // Two python versions, two arnold versions. Both packages + // accept any version of either dep. + for v in ["3.10", "3.11"] { + fs::write( + pkg_dir.join(format!("python-{}.yaml", v)), + format!("name: python\nversion: \"{}\"\n", v), + ) + .unwrap(); + } + for v in ["7.1", "7.2"] { + fs::write( + pkg_dir.join(format!("arnold-{}.yaml", v)), + format!("name: arnold\nversion: \"{}\"\n", v), + ) + .unwrap(); + } + fs::write( + pkg_dir.join("maya-2024.yaml"), + "name: maya\nversion: \"2024\"\nrequires:\n - python\n - arnold\n", + ) + .unwrap(); + + let config_path = dir.path().join("config.yaml"); + fs::write( + &config_path, + format!("package_paths:\n - {}\n", pkg_dir.display()), + ) + .unwrap(); + let cfg = config_path.to_string_lossy().to_string(); + + // Initial lock — pins highest of each. + anvil(&cfg) + .current_dir(dir.path()) + .args(["lock", "maya-2024"]) + .assert() + .success(); + let initial = fs::read_to_string(dir.path().join("anvil.lock")).unwrap(); + assert!(initial.contains("version: '3.11'"), "{}", initial); + assert!(initial.contains("version: '7.2'"), "{}", initial); + + // Hand-edit the lock to pin python at 3.10 (simulate a project + // that's been on 3.10 for a while). + let edited = initial + .replace("version: '3.11'", "version: '3.10'") + .replace("version: '7.2'", "version: '7.1'"); + fs::write(dir.path().join("anvil.lock"), &edited).unwrap(); + + // Re-lock with --upgrade-package python: python should bump to + // 3.11, arnold should stay at the existing pin (7.1). + anvil(&cfg) + .current_dir(dir.path()) + .args(["lock", "maya-2024", "--upgrade-package", "python"]) + .assert() + .success(); + let after = fs::read_to_string(dir.path().join("anvil.lock")).unwrap(); + assert!(after.contains("version: '3.11'"), "python should upgrade:\n{}", after); + assert!(after.contains("version: '7.1'"), "arnold should stay pinned:\n{}", after); + assert!(!after.contains("version: '7.2'"), "arnold should NOT bump to 7.2:\n{}", after); +} + +#[test] +fn upgrade_package_without_lockfile_fails() { + let (dir, cfg) = setup_env(); + anvil(&cfg) + .current_dir(dir.path()) + .args(["lock", "maya-2024", "--upgrade-package", "python"]) + .assert() + .failure() + .stderr(predicate::str::contains("--upgrade-package needs an existing anvil.lock")); +} + // ---- anvil tree ---- #[test] From da9dc36a55e939d1d0475faecb4179ddb00c53fc Mon Sep 17 00:00:00 2001 From: voidreamer Date: Wed, 29 Apr 2026 17:02:44 -0400 Subject: [PATCH 8/8] add anvil add / anvil remove for editing the locked request set Both commands mutate anvil.lock's `requests` array and re-resolve so the lockfile reflects the new project package set. This lets users manage the project's package list without editing YAML or repeating themselves on the command line. anvil add maya-2024 -- create lockfile if missing, append maya-2024 anvil add maya-2025 -- replace any existing request for 'maya' with the new constraint anvil remove arnold -- drop every request whose package name is 'arnold' (constraints OK) Both refuse to write an empty lockfile (unusual state, probably a mistake), and `anvil remove` refuses to run without an existing anvil.lock to mutate. --- src/cli.rs | 23 ++++++++++ src/main.rs | 76 ++++++++++++++++++++++++++++++++ tests/cli.rs | 120 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 219 insertions(+) diff --git a/src/cli.rs b/src/cli.rs index 1cd2b66..737d313 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -154,6 +154,29 @@ pub enum Commands { packages: Vec, }, + /// Add packages to the project's locked request set. + /// + /// Reads anvil.lock (creating an empty request set if absent), + /// adds the given packages -- replacing any existing request for + /// the same name -- and re-resolves so the lockfile reflects the + /// new set. + Add { + /// Packages to add (e.g., maya-2024 arnold-7.2) + #[arg(required = true)] + packages: Vec, + }, + + /// Remove packages from the project's locked request set. + /// + /// Reads anvil.lock, drops every request whose package name + /// matches one of the given names, and re-resolves so the + /// lockfile reflects the smaller set. + Remove { + /// Package names to remove (e.g., arnold) + #[arg(required = true)] + names: Vec, + }, + /// Scaffold a new package definition (or `--config` for the global config) Init { /// Package name (e.g., my-tools). Omit when using `--config`. diff --git a/src/main.rs b/src/main.rs index e69f6d6..49d7636 100644 --- a/src/main.rs +++ b/src/main.rs @@ -120,6 +120,12 @@ fn main() -> Result<()> { Commands::Tree { packages } => { cmd_tree(&config, &packages, refresh, frozen)?; } + Commands::Add { packages } => { + cmd_add(&config, &packages, refresh)?; + } + Commands::Remove { names } => { + cmd_remove(&config, &names, refresh)?; + } Commands::Publish { target, path, flat } => { cmd_publish(&target, path.as_deref(), flat)?; } @@ -460,6 +466,76 @@ fn cmd_validate( Ok(()) } +// --------------------------------------------------------------------------- +// Add / Remove +// --------------------------------------------------------------------------- + +/// Read the existing lockfile's request set (or empty if there isn't +/// one), apply `mutate`, and re-lock. `mutate` is given the current +/// request list and returns the new one. Other lock options +/// (`--all-platforms`, `--upgrade-package`) intentionally don't apply +/// here — `anvil add` / `anvil remove` are the simple "edit the +/// project's package set" path; advanced cases still call `anvil +/// lock` directly. +fn re_lock_with(config: &Config, refresh: bool, mutate: F) -> Result<()> +where + F: FnOnce(Vec) -> Vec, +{ + let starting = match Lockfile::find() { + Some(p) => Lockfile::load(&p)?.requests, + None => Vec::new(), + }; + let new_requests = mutate(starting); + if new_requests.is_empty() { + // Don't write an empty lockfile — that's an unusual state and + // probably the user removed too much by mistake. + anyhow::bail!( + "no packages would remain after this change; refusing to write an empty lockfile" + ); + } + cmd_lock(config, &new_requests, refresh, false, &[]) +} + +fn cmd_add(config: &Config, additions: &[String], refresh: bool) -> Result<()> { + re_lock_with(config, refresh, |existing| { + // Replace any existing request whose package name matches one + // of the names being added — `anvil add maya-2025` should + // bump a previously-pinned `maya-2024`. + let new_names: std::collections::HashSet = additions + .iter() + .filter_map(|s| package::PackageRequest::parse(s).ok().map(|r| r.name)) + .collect(); + let mut out: Vec = existing + .into_iter() + .filter(|s| match package::PackageRequest::parse(s) { + Ok(r) => !new_names.contains(&r.name), + Err(_) => true, + }) + .collect(); + for a in additions { + out.push(a.clone()); + } + out + }) +} + +fn cmd_remove(config: &Config, names_to_remove: &[String], refresh: bool) -> Result<()> { + if Lockfile::find().is_none() { + anyhow::bail!("anvil remove: no anvil.lock to mutate -- run `anvil add` or `anvil lock` first"); + } + let removed_names: std::collections::HashSet<&str> = + names_to_remove.iter().map(String::as_str).collect(); + re_lock_with(config, refresh, |existing| { + existing + .into_iter() + .filter(|s| match package::PackageRequest::parse(s) { + Ok(r) => !removed_names.contains(r.name.as_str()), + Err(_) => true, + }) + .collect() + }) +} + // --------------------------------------------------------------------------- // Tree // --------------------------------------------------------------------------- diff --git a/tests/cli.rs b/tests/cli.rs index 2eacb91..b20b4fc 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1206,6 +1206,126 @@ fn drift_warning_when_package_changes_after_lock() { .stderr(predicate::str::contains("widget-1.0")); } +// ---- anvil add / anvil remove ---- + +#[test] +fn add_creates_lockfile_when_none_exists() { + let (dir, cfg) = setup_env(); + anvil(&cfg) + .current_dir(dir.path()) + .args(["add", "maya-2024"]) + .assert() + .success(); + let lock = fs::read_to_string(dir.path().join("anvil.lock")).unwrap(); + assert!(lock.contains("maya-2024")); + assert!(lock.contains("requests:")); +} + +#[test] +fn add_appends_to_existing_request_set() { + let (dir, cfg) = setup_env(); + anvil(&cfg) + .current_dir(dir.path()) + .args(["add", "maya-2024"]) + .assert() + .success(); + anvil(&cfg) + .current_dir(dir.path()) + .args(["add", "studio-blender-tools-1.0.0"]) + .assert() + .success(); + let lock = fs::read_to_string(dir.path().join("anvil.lock")).unwrap(); + assert!(lock.contains("maya-2024"), "{}", lock); + assert!(lock.contains("studio-blender-tools-1.0.0"), "{}", lock); +} + +#[test] +fn add_replaces_request_with_same_name() { + let dir = TempDir::new().unwrap(); + let pkg_dir = dir.path().join("packages"); + fs::create_dir_all(&pkg_dir).unwrap(); + for v in ["1.0", "2.0"] { + fs::write( + pkg_dir.join(format!("widget-{}.yaml", v)), + format!("name: widget\nversion: \"{}\"\n", v), + ) + .unwrap(); + } + let cfg_path = dir.path().join("config.yaml"); + fs::write(&cfg_path, format!("package_paths:\n - {}\n", pkg_dir.display())).unwrap(); + let cfg = cfg_path.to_string_lossy().to_string(); + + anvil(&cfg) + .current_dir(dir.path()) + .args(["add", "widget-1.0"]) + .assert() + .success(); + anvil(&cfg) + .current_dir(dir.path()) + .args(["add", "widget-2.0"]) + .assert() + .success(); + let lock = fs::read_to_string(dir.path().join("anvil.lock")).unwrap(); + // Both requests for widget should not coexist; the latest add wins. + assert!(lock.contains("widget-2.0"), "{}", lock); + assert!(!lock.contains("widget-1.0"), "old version should be replaced:\n{}", lock); +} + +#[test] +fn remove_drops_requested_name() { + let (dir, cfg) = setup_env(); + anvil(&cfg) + .current_dir(dir.path()) + .args(["add", "maya-2024"]) + .assert() + .success(); + anvil(&cfg) + .current_dir(dir.path()) + .args(["add", "studio-blender-tools-1.0.0"]) + .assert() + .success(); + + anvil(&cfg) + .current_dir(dir.path()) + .args(["remove", "studio-blender-tools"]) + .assert() + .success(); + let lock = fs::read_to_string(dir.path().join("anvil.lock")).unwrap(); + assert!(lock.contains("maya-2024"), "{}", lock); + assert!( + !lock.contains("studio-blender-tools"), + "studio-blender-tools should be gone:\n{}", + lock, + ); +} + +#[test] +fn remove_refuses_to_empty_the_lockfile() { + let (dir, cfg) = setup_env(); + anvil(&cfg) + .current_dir(dir.path()) + .args(["add", "maya-2024"]) + .assert() + .success(); + anvil(&cfg) + .current_dir(dir.path()) + .args(["remove", "maya"]) + .assert() + .failure() + .stderr(predicate::str::contains("empty lockfile")); +} + +#[test] +fn remove_without_lockfile_fails() { + let (dir, cfg) = setup_env(); + anvil(&cfg) + .current_dir(dir.path()) + .args(["remove", "anything"]) + .assert() + .failure() + .stderr(predicate::str::contains("no anvil.lock to mutate")); +} + // ---- anvil lock --upgrade-package ---- #[test]