diff --git a/Cargo.toml b/Cargo.toml index 38745f2..adff2e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "anvil-env" -version = "0.4.1" +version = "0.4.2" edition = "2021" authors = ["Alejandro Cabrera "] description = "A lightweight environment and configuration manager for VFX/Animation pipelines" diff --git a/README.md b/README.md index 0fa2b1e..874a6f5 100644 --- a/README.md +++ b/README.md @@ -132,11 +132,16 @@ only on the last hyphen when the suffix starts with a digit. ### Environment expansion -Values resolve in this order: `${PACKAGE_ROOT}`, `${VERSION}`, `${NAME}`, then -any `${VAR}` set by previously resolved packages or the inherited environment, -and finally a leading `~/`. When two packages set the same variable without -referencing `${VAR}` on the right, anvil emits a conflict warning so a silent -overwrite does not slip through. +Values resolve in this order: `${PACKAGE_ROOT}`, `${VERSION}`, `${NAME}`, +`${PATHSEP}` (`:` on Unix / `;` on Windows), `${EXE_SUFFIX}` (`""` on Unix / +`".exe"` on Windows), then any `${VAR}` set by previously resolved packages or +the inherited environment, and finally `~/` — which expands at every path +segment, so `~/USD/bin${PATHSEP}~/USD/lib` works as expected. On Windows +PowerShell sessions `~/` falls back to `USERPROFILE` when `HOME` is unset. + +When two packages set the same variable without referencing `${VAR}` on the +right, anvil emits a conflict warning so a silent overwrite does not slip +through. ### Command aliases diff --git a/src/main.rs b/src/main.rs index 2597fe5..d349797 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ use anyhow::{Context, Result}; use clap::Parser; +use tracing::info; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; mod cache; @@ -190,6 +191,10 @@ fn cmd_run( // Pre-run hooks Config::run_hooks(&config.hooks.pre_run, &env)?; + // Surface the resolved argv at `-v`/`-vv` so when an exec fails with + // "file not found" the user can see what anvil actually tried to run. + info!("exec: {} {:?}", executable, all_args); + let status = Command::new(&executable) .args(&all_args) .envs(&env) diff --git a/src/package.rs b/src/package.rs index 4d0a6f7..e3740ad 100644 --- a/src/package.rs +++ b/src/package.rs @@ -7,6 +7,18 @@ use anyhow::{Context, Result}; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; +/// Platform-native path-list separator, exposed in yaml as `${PATHSEP}`. +#[cfg(target_os = "windows")] +pub const PATHSEP: &str = ";"; +#[cfg(not(target_os = "windows"))] +pub const PATHSEP: &str = ":"; + +/// Platform-native executable suffix, exposed in yaml as `${EXE_SUFFIX}`. +#[cfg(target_os = "windows")] +pub const EXE_SUFFIX: &str = ".exe"; +#[cfg(not(target_os = "windows"))] +pub const EXE_SUFFIX: &str = ""; + /// A package definition #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Package { @@ -133,6 +145,11 @@ impl Package { // Replace ${NAME} with package name result = result.replace("${NAME}", &self.name); + // Platform-aware builtins so a single yaml line can compose path + // lists or binary names without a `variants:` fork per platform. + result = result.replace("${PATHSEP}", PATHSEP); + result = result.replace("${EXE_SUFFIX}", EXE_SUFFIX); + // Replace other ${VAR} references for (key, val) in env { result = result.replace(&format!("${{{}}}", key), val); @@ -145,11 +162,19 @@ impl Package { std::env::var(var).unwrap_or_default() }).to_string(); - // Expand ~ to home directory - if result.starts_with("~/") { - if let Ok(home) = std::env::var("HOME") { - result = format!("{}{}", home, &result[1..]); - } + // Expand `~/` everywhere it appears at a segment boundary + // (start-of-value, or after `:` / `;`). Path-list values like + // `~/USD/bin;~/USD/lib` need every occurrence expanded, not just + // the first. `dirs::home_dir()` resolves via `USERPROFILE` on + // Windows when `HOME` is unset (PowerShell sessions). + if let Some(home) = dirs::home_dir() { + let home_str = home.to_string_lossy(); + let tilde_re = regex::Regex::new(r"(^|[:;])~/").unwrap(); + result = tilde_re + .replace_all(&result, |caps: ®ex::Captures| { + format!("{}{}/", &caps[1], home_str) + }) + .to_string(); } result @@ -453,6 +478,108 @@ mod tests { assert_eq!(pkg.expand_env_value("${NAME}-${VERSION}", &env), "maya-2024"); } + #[test] + fn expand_pathsep_builtin() { + let pkg = Package { + name: "test".into(), + version: "1.0".into(), + description: None, + requires: vec![], + environment: IndexMap::new(), + commands: HashMap::new(), + variants: vec![], + root: PathBuf::from("/tmp"), + }; + let env = HashMap::new(); + let expected = if cfg!(target_os = "windows") { + "/a;/b;/c" + } else { + "/a:/b:/c" + }; + assert_eq!( + pkg.expand_env_value("/a${PATHSEP}/b${PATHSEP}/c", &env), + expected + ); + } + + #[test] + fn expand_exe_suffix_builtin() { + let pkg = Package { + name: "test".into(), + version: "1.0".into(), + description: None, + requires: vec![], + environment: IndexMap::new(), + commands: HashMap::new(), + variants: vec![], + root: PathBuf::from("/tmp"), + }; + let env = HashMap::new(); + let expected = if cfg!(target_os = "windows") { + "blender.exe" + } else { + "blender" + }; + assert_eq!(pkg.expand_env_value("blender${EXE_SUFFIX}", &env), expected); + } + + #[test] + fn expand_tilde_at_every_segment() { + // `~` should expand at the start of every path segment, not just the + // first occurrence in the value. Path-list values like + // `~/USD/bin;~/USD/lib` were leaving the second `~` literal before. + let pkg = Package { + name: "test".into(), + version: "1.0".into(), + description: None, + requires: vec![], + environment: IndexMap::new(), + commands: HashMap::new(), + variants: vec![], + root: PathBuf::from("/tmp"), + }; + let env = HashMap::new(); + let home = dirs::home_dir().expect("test needs a HOME"); + let home_str = home.to_string_lossy(); + + // Unix-style separator + let unix_in = "~/a:~/b:~/c"; + let unix_out = pkg.expand_env_value(unix_in, &env); + assert_eq!( + unix_out, + format!("{h}/a:{h}/b:{h}/c", h = home_str), + "Unix-style path list should expand every ~" + ); + + // Windows-style separator + let win_in = "~/a;~/b;~/c"; + let win_out = pkg.expand_env_value(win_in, &env); + assert_eq!( + win_out, + format!("{h}/a;{h}/b;{h}/c", h = home_str), + "Windows-style path list should expand every ~" + ); + } + + #[test] + fn expand_tilde_only_at_segment_boundary() { + // A `~` that's not at a segment boundary (e.g. embedded in a word) + // should be left alone. + let pkg = Package { + name: "test".into(), + version: "1.0".into(), + description: None, + requires: vec![], + environment: IndexMap::new(), + commands: HashMap::new(), + variants: vec![], + root: PathBuf::from("/tmp"), + }; + let env = HashMap::new(); + // No `~/` at start or after `:` / `;`, so nothing should change. + assert_eq!(pkg.expand_env_value("backup~/file", &env), "backup~/file"); + } + #[test] fn expand_from_env_map() { let pkg = Package {