Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "anvil-env"
version = "0.4.1"
version = "0.4.2"
edition = "2021"
authors = ["Alejandro Cabrera <voidreamer@gmail.com>"]
description = "A lightweight environment and configuration manager for VFX/Animation pipelines"
Expand Down
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use anyhow::{Context, Result};
use clap::Parser;
use tracing::info;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

mod cache;
Expand Down Expand Up @@ -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)
Expand Down
137 changes: 132 additions & 5 deletions src/package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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: &regex::Captures| {
format!("{}{}/", &caps[1], home_str)
})
.to_string();
}

result
Expand Down Expand Up @@ -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 {
Expand Down
Loading