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/cli.rs b/src/cli.rs index 23620df..737d313 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, } @@ -104,6 +119,20 @@ 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, + + /// 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 @@ -112,6 +141,42 @@ 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, + + /// Print the dependency tree for a set of packages. + Tree { + /// Packages to resolve and visualise. + #[arg(required = true)] + 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/context.rs b/src/context.rs index 8ffe40d..2e98ec5 100644 --- a/src/context.rs +++ b/src/context.rs @@ -4,23 +4,99 @@ 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 /// 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, - /// Pinned versions: package name -> exact version string. - pub pins: HashMap, + /// 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 { @@ -40,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 d349797..49d7636 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<()> { @@ -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)?; @@ -66,12 +74,17 @@ 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, + upgrade_packages, + } => { + cmd_lock(&config, &packages, refresh, all_platforms, &upgrade_packages)?; } 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)?; @@ -99,7 +112,19 @@ 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::Sync => { + cmd_sync(&config, refresh)?; + } + 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)?; @@ -109,6 +134,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, @@ -116,8 +187,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(); @@ -143,13 +215,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(); @@ -214,8 +287,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(); @@ -392,33 +466,394 @@ 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 +// --------------------------------------------------------------------------- + +/// 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 +// --------------------------------------------------------------------------- + +/// 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 // --------------------------------------------------------------------------- /// 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). - let resolver = Resolver::new_unlocked(config, refresh)?; - let resolved = resolver.resolve(packages)?; +/// +/// 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, + upgrade_packages: &[String], +) -> Result<()> { + // 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) + }; - let mut pins = std::collections::HashMap::new(); - for pkg in resolved.packages() { - pins.insert(pkg.name.clone(), pkg.version.clone()); + // 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(()) } @@ -433,8 +868,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(); @@ -630,8 +1066,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/package.rs b/src/package.rs index e3740ad..0fe5469 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)] @@ -80,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); @@ -95,41 +107,59 @@ impl Package { .map(|p| p.to_path_buf()) .or_else(|| file_path.parent().map(|p| p.to_path_buf())) .unwrap_or_default(); - - // Apply variant for current platform - package.apply_current_variant(); + package.source_path = Some(file_path.to_path_buf()); 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) { - 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 @@ -233,6 +263,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 +334,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), } } } @@ -453,7 +511,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!( @@ -472,7 +530,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"); @@ -488,7 +546,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") { @@ -512,7 +570,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") { @@ -536,7 +594,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"); @@ -573,7 +631,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. @@ -590,7 +648,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 c3ad983..9b6bec9 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; @@ -7,8 +15,62 @@ 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::context::{Lockfile, Pin}; +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. 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 +/// 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)] @@ -76,18 +138,39 @@ 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, + /// 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); - 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 if frozen { + anyhow::bail!( + "--frozen requires anvil.lock, but none was found in this directory or any parent" + ); } else { HashMap::new() }; @@ -96,8 +179,10 @@ impl Resolver { config: config.clone(), package_cache: HashMap::new(), pins, + frozen, }; resolver.load_packages(refresh)?; + resolver.verify_pin_hashes(); Ok(resolver) } @@ -107,17 +192,61 @@ impl Resolver { config: config.clone(), package_cache: HashMap::new(), pins: HashMap::new(), + frozen: false, }; resolver.load_packages(refresh)?; 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 + /// 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(); // 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 { @@ -218,10 +347,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 resolved: Vec = Vec::new(); - let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + 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(); @@ -233,71 +374,142 @@ 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, 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(); - 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))?; - - // Lockfile pin takes priority - 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()); - } - warn!( - "Pinned version {}-{} not found, resolving normally", - request.name, pinned + 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 {})", + request.name, + requester, + ); + }; + + // Lockfile pin takes priority — but only if it satisfies the + // request's constraint, otherwise we'd silently break the request. + 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, pin.version); + return Ok(pkg.clone()); + } + warn!( + "Pinned version {}-{} does not satisfy {} (required by {}); resolving normally", + request.name, pin.version, request, requester, + ); + } else { + warn!( + "Pinned version {}-{} not found; resolving normally", + request.name, pin.version, + ); + } + } + let mut matching: Vec<&Package> = versions .values() .filter(|pkg| request.matches(&pkg.version)) .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(", "), ); } @@ -332,10 +544,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 @@ -343,11 +560,15 @@ 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)?; - 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..b20b4fc 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1055,3 +1055,724 @@ 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")); +} + +// ---- 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")); +} + +// ---- 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] +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] +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] +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] +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 +/// 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(); + 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")); +}