diff --git a/README.md b/README.md index 874a6f5..b7c4423 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ only on the last hyphen when the suffix starts with a digit. 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 +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. @@ -147,7 +147,7 @@ through. The `commands` map lets `anvil run` pick a program from the package definition. Values expand the same way as `environment` values, and can include baked in -arguments with whitespace or tilde segments — anvil tokenises the value with +arguments with whitespace or tilde segments. Anvil tokenises the value with POSIX shell rules, expands `~/` in every token, then runs the first token with the remaining tokens prepended to whatever the user passes after `--`. @@ -167,7 +167,7 @@ commands: ``` `anvil run nukex -- --view` therefore exec's -`${NUKE_HOME}/Nuke${VERSION} --nukex --view` — packages can ship sane defaults +`${NUKE_HOME}/Nuke${VERSION} --nukex --view`. Packages can ship sane defaults for every tool they expose without forcing users to memorise flag soup. Quoted substrings are preserved as a single argv element, so paths with spaces work without escaping the whole value. diff --git a/docs/walkthrough.md b/docs/walkthrough.md index 0e17b47..a45f04b 100644 --- a/docs/walkthrough.md +++ b/docs/walkthrough.md @@ -62,13 +62,13 @@ anvil info houdini-20.5 ```bash anvil validate -# ✓ blender -# ✓ houdini -# ✓ nuke -# ✓ python -# ✓ studio-blender-tools -# ✓ studio-python -# ✓ usd +# OK blender +# OK houdini +# OK nuke +# OK python +# OK studio-blender-tools +# OK studio-python +# OK usd # All packages valid! ``` @@ -94,7 +94,7 @@ anvil env python-3.11 --json | python3 -c "import sys,json; print(json.load(sys. ### Dependency resolution -Houdini depends on python-3.11 — both are resolved automatically: +Houdini depends on python-3.11, and both are resolved automatically: ```bash anvil env houdini-20.5 | grep -E "PYTHON_VERSION|HOUDINI_VERSION" @@ -171,7 +171,7 @@ anvil run python-3.11 -e MY_VAR=hello -- env | grep MY_VAR ```bash # Start a shell with packages loaded -# (will replace the current process — type 'exit' to return) +# (will replace the current process; type 'exit' to return) anvil shell python-3.11 # Specify a different shell diff --git a/src/cache.rs b/src/cache.rs index fb4b308..185b0fc 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -41,14 +41,12 @@ pub fn compute_fingerprint(package_paths: &[PathBuf], config_salt: &str) -> u64 base.hash(&mut hasher); hash_dir_entries(base, &mut hasher); - // One level deeper for nested packages if let Ok(entries) = std::fs::read_dir(base) { for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { hash_dir_entries(&path, &mut hasher); - // Check package.yaml files inside version dirs if let Ok(sub_entries) = std::fs::read_dir(&path) { for sub in sub_entries.flatten() { if sub.path().is_dir() { @@ -116,7 +114,10 @@ fn hash_file_mtime(path: &Path, hasher: &mut impl Hasher) { /// Try to load cached packages. Returns `Some(packages)` if the cache /// is valid (fingerprint matches), `None` otherwise. -pub fn load(package_paths: &[PathBuf], config_salt: &str) -> Option>> { +pub fn load( + package_paths: &[PathBuf], + config_salt: &str, +) -> Option>> { let path = cache_path()?; if !path.exists() { return None; diff --git a/src/cli.rs b/src/cli.rs index 23620df..aa772fb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -6,7 +6,7 @@ use clap::{CommandFactory, Parser, Subcommand}; #[command(name = "anvil")] #[command(author = "Alejandro Cabrera ")] #[command(version)] -#[command(about = "Forge your environment 🔨 — Fast package resolution for VFX pipelines", long_about = None)] +#[command(about = "Forge your environment. Fast package resolution for VFX pipelines", long_about = None)] pub struct Cli { /// Ignore any cached package scan and re-read all package files. #[arg(long, global = true)] @@ -63,7 +63,7 @@ pub enum Commands { #[arg(short, long)] shell: Option, - /// Don't materialise `commands:` as PATH shims — compose the env only. + /// Don't materialise `commands:` as PATH shims; compose the env only. #[arg(long)] env_only: bool, @@ -170,12 +170,7 @@ pub enum Commands { impl Cli { /// Generate shell completions and write to stdout. pub fn print_completions(shell: clap_complete::Shell) { - clap_complete::generate( - shell, - &mut Self::command(), - "anvil", - &mut std::io::stdout(), - ); + clap_complete::generate(shell, &mut Self::command(), "anvil", &mut std::io::stdout()); } } diff --git a/src/config.rs b/src/config.rs index 0624294..96be324 100644 --- a/src/config.rs +++ b/src/config.rs @@ -182,7 +182,6 @@ impl Config { config.merge(project); } - // Apply platform overrides and expand paths after merging config.apply_platform_overrides(); config.expand_paths(); @@ -420,7 +419,10 @@ impl Config { } /// Run a list of hook commands. Returns Err if any hook exits non-zero. - pub fn run_hooks(hooks: &[String], env: &std::collections::HashMap) -> Result<()> { + pub fn run_hooks( + hooks: &[String], + env: &std::collections::HashMap, + ) -> Result<()> { for cmd in hooks { let shell = if cfg!(target_os = "windows") { "cmd" @@ -441,7 +443,11 @@ impl Config { .with_context(|| format!("Failed to run hook: {}", cmd))?; if !status.success() { - anyhow::bail!("Hook failed (exit {}): {}", status.code().unwrap_or(-1), cmd); + anyhow::bail!( + "Hook failed (exit {}): {}", + status.code().unwrap_or(-1), + cmd + ); } } Ok(()) diff --git a/src/context.rs b/src/context.rs index 8ffe40d..2c9c245 100644 --- a/src/context.rs +++ b/src/context.rs @@ -6,10 +6,6 @@ use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result}; use serde::{Deserialize, Serialize}; -// --------------------------------------------------------------------------- -// Lockfile -// --------------------------------------------------------------------------- - /// Pins package versions for reproducible resolution. /// /// Stored as `anvil.lock` in the project directory. When present, the @@ -35,7 +31,10 @@ impl Lockfile { /// Write the lockfile to a YAML file. pub fn save(&self, path: &Path) -> Result<()> { let content = serde_yaml::to_string(self)?; - let output = format!("# anvil.lock — generated by `anvil lock`, do not edit\n{}", content); + let output = format!( + "# anvil.lock: generated by `anvil lock`, do not edit\n{}", + content + ); std::fs::write(path, output) .with_context(|| format!("Failed to write lockfile: {:?}", path)) } @@ -55,10 +54,6 @@ impl Lockfile { } } -// --------------------------------------------------------------------------- -// Saved context -// --------------------------------------------------------------------------- - /// A fully resolved environment that can be saved, shared, and re-loaded /// without re-running resolution. Useful for render farms, CI, and /// sharing reproducible environments across machines. diff --git a/src/main.rs b/src/main.rs index d349797..f4c22f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,18 +43,30 @@ fn main() -> Result<()> { ) .init(); - // Load config let config = Config::load()?; let refresh = cli.refresh; match cli.command { - Commands::Env { packages, export, json } => { + Commands::Env { + packages, + export, + json, + } => { cmd_env(&config, &packages, export, json, refresh)?; } - Commands::Run { packages, env_vars, command } => { + Commands::Run { + packages, + env_vars, + command, + } => { cmd_run(&config, &packages, &env_vars, &command, refresh)?; } - Commands::Shell { packages, shell, env_only, no_sweep } => { + Commands::Shell { + packages, + shell, + env_only, + no_sweep, + } => { cmd_shell(&config, &packages, shell, refresh, env_only, no_sweep)?; } Commands::List { package } => { @@ -66,7 +78,10 @@ fn main() -> Result<()> { Commands::Validate { package, strict } => { cmd_validate(&config, package, strict, refresh)?; } - Commands::Lock { packages, update: _ } => { + Commands::Lock { + packages, + update: _, + } => { cmd_lock(&config, &packages, refresh)?; } Commands::Context { action } => match action { @@ -83,7 +98,12 @@ fn main() -> Result<()> { cmd_context_shell(&config, &file, shell)?; } }, - Commands::Init { name, version, flat, config: scaffold_config } => { + Commands::Init { + name, + version, + flat, + config: scaffold_config, + } => { if scaffold_config { cmd_init_config()?; } else { @@ -98,7 +118,11 @@ fn main() -> Result<()> { Commands::Completions { shell } => { Cli::print_completions(shell); } - Commands::Wrap { packages, dir, shell } => { + Commands::Wrap { + packages, + dir, + shell, + } => { cmd_wrap(&config, &packages, &dir, &shell, refresh)?; } Commands::Publish { target, path, flat } => { @@ -146,17 +170,14 @@ fn cmd_run( ) -> 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 resolved = resolver.resolve(packages)?; let mut env = resolved.environment(); - // Post-resolve hooks Config::run_hooks(&config.hooks.post_resolve, &env)?; - // Add user-specified env vars for var in env_vars { if let Some((key, value)) = var.split_once('=') { env.insert(key.to_string(), value.to_string()); @@ -188,7 +209,6 @@ fn cmd_run( let mut all_args = tokens; all_args.extend(command[1..].iter().cloned()); - // Pre-run hooks Config::run_hooks(&config.hooks.pre_run, &env)?; // Surface the resolved argv at `-v`/`-vv` so when an exec fails with @@ -256,14 +276,12 @@ fn cmd_list(config: &Config, package: Option, refresh: bool) -> Result<( let resolver = Resolver::new(config, refresh)?; if let Some(name) = package { - // List versions of specific package let versions = resolver.list_versions(&name)?; println!("{}:", name); for version in versions { println!(" - {}", version); } } else { - // List all packages let packages = resolver.list_packages()?; if packages.is_empty() { if let Some(hint) = config.first_run_hint() { @@ -356,9 +374,9 @@ fn cmd_validate( match report { Ok(cmd_problems) => { if cmd_problems.is_empty() { - println!("✓ {}", pkg_name); + println!("OK {}", pkg_name); } else { - let label = if strict { "✗" } else { "!" }; + let label = if strict { "FAIL" } else { "WARN" }; println!("{} {}: command problems:", label, pkg_name); for p in &cmd_problems { println!(" - {}", p); @@ -371,7 +389,7 @@ fn cmd_validate( } } Err(e) => { - println!("✗ {}: {}", pkg_name, e); + println!("FAIL {}: {}", pkg_name, e); errors += 1; } } @@ -383,7 +401,7 @@ fn cmd_validate( if warnings > 0 { println!( - "\nAll dependencies resolve ({} package(s) with command warnings — use --strict to fail on these).", + "\nAll dependencies resolve ({} package(s) with command warnings; use --strict to fail on these).", warnings ); } else { @@ -392,13 +410,9 @@ fn cmd_validate( 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). + // Always resolve fresh, ignoring any existing lockfile. let resolver = Resolver::new_unlocked(config, refresh)?; let resolved = resolver.resolve(packages)?; @@ -415,7 +429,10 @@ fn cmd_lock(config: &Config, packages: &[String], refresh: bool) -> Result<()> { let lock_path = std::path::PathBuf::from("anvil.lock"); lockfile.save(&lock_path)?; - println!("Locked {} packages to anvil.lock:", resolved.packages().len()); + println!( + "Locked {} packages to anvil.lock:", + resolved.packages().len() + ); for pkg in resolved.packages() { println!(" {}-{}", pkg.name, pkg.version); } @@ -423,10 +440,6 @@ fn cmd_lock(config: &Config, packages: &[String], refresh: bool) -> Result<()> { Ok(()) } -// --------------------------------------------------------------------------- -// Context -// --------------------------------------------------------------------------- - /// Resolve packages and save the full environment to a context file. fn cmd_context_save( config: &Config, @@ -526,10 +539,6 @@ fn cmd_context_shell(config: &Config, file: &str, shell_override: Option Ok(()) } -// --------------------------------------------------------------------------- -// Init -// --------------------------------------------------------------------------- - /// Scaffold a new package definition. fn cmd_init(name: &str, version: &str, flat: bool) -> Result<()> { let template = format!( @@ -586,7 +595,7 @@ fn cmd_init_config() -> Result<()> { .with_context(|| format!("Failed to create {}", parent.display()))?; } - let template = r#"# Anvil global config — see https://github.com/voidreamer/anvil + let template = r#"# Anvil global config. See https://github.com/voidreamer/anvil # Where to look for package definitions, in priority order. # Each entry can be a directory of flat `-.yaml` files @@ -619,10 +628,6 @@ package_paths: Ok(()) } -// --------------------------------------------------------------------------- -// Wrap -// --------------------------------------------------------------------------- - /// Generate wrapper scripts for all commands defined by the resolved packages. fn cmd_wrap( config: &Config, @@ -644,7 +649,6 @@ fn cmd_wrap( let dir_path = std::path::Path::new(dir); std::fs::create_dir_all(dir_path)?; - // Build the package request string for the wrapper let pkg_args: Vec = resolved.packages().iter().map(|p| p.id()).collect(); let pkg_str = pkg_args.join(" "); @@ -654,7 +658,6 @@ fn cmd_wrap( let out_path = dir_path.join(alias); std::fs::write(&out_path, script)?; - // Make executable on Unix #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -691,10 +694,6 @@ fn generate_wrapper(shell: &str, packages: &str, command: &str) -> String { } } -// --------------------------------------------------------------------------- -// Publish -// --------------------------------------------------------------------------- - /// Publish a package to a target package path. fn cmd_publish(target: &str, source: Option<&str>, flat: bool) -> Result<()> { use crate::package::Package; @@ -704,7 +703,6 @@ fn cmd_publish(target: &str, source: Option<&str>, flat: bool) -> Result<()> { None => std::env::current_dir()?, }; - // Load and validate the package let pkg = if source_dir.is_file() { Package::load_from_file(&source_dir, None)? } else { @@ -717,23 +715,25 @@ fn cmd_publish(target: &str, source: Option<&str>, flat: bool) -> Result<()> { } if flat { - // Publish as flat YAML file let filename = format!("{}-{}.yaml", pkg.name, pkg.version); let dest = target_path.join(&filename); if dest.exists() { anyhow::bail!("{} already exists in target", filename); } - // Re-read the source YAML to publish it verbatim let src_file = if source_dir.is_file() { source_dir.clone() } else { source_dir.join("package.yaml") }; std::fs::copy(&src_file, &dest)?; - println!("Published {}-{} to {}", pkg.name, pkg.version, dest.display()); + println!( + "Published {}-{} to {}", + pkg.name, + pkg.version, + dest.display() + ); } else { - // Publish as nested directory let dest_dir = target_path.join(&pkg.name).join(&pkg.version); if dest_dir.exists() { anyhow::bail!( @@ -744,7 +744,6 @@ fn cmd_publish(target: &str, source: Option<&str>, flat: bool) -> Result<()> { ); } - // Copy the entire source directory tree let src = if source_dir.is_file() { source_dir .parent() diff --git a/src/package.rs b/src/package.rs index e3740ad..271106a 100644 --- a/src/package.rs +++ b/src/package.rs @@ -24,29 +24,29 @@ pub const EXE_SUFFIX: &str = ""; pub struct Package { /// Package name pub name: String, - + /// Package version pub version: String, - + /// Human-readable description pub description: Option, - + /// Required packages (dependencies) #[serde(default)] pub requires: Vec, - + /// Environment variables to set #[serde(default)] pub environment: IndexMap, - + /// Command aliases #[serde(default)] pub commands: HashMap, - + /// Platform-specific variants #[serde(default)] pub variants: Vec, - + /// Path to the package root (set after loading, omitted from package.yaml) #[serde(default)] pub root: PathBuf, @@ -56,11 +56,11 @@ pub struct Package { pub struct PackageVariant { /// Platform filter (linux, windows, macos) pub platform: Option, - + /// Additional requires for this variant #[serde(default)] pub requires: Vec, - + /// Additional environment for this variant #[serde(default)] pub environment: IndexMap, @@ -101,12 +101,12 @@ impl Package { Ok(package) } - + /// Get the full package identifier (name-version) pub fn id(&self) -> String { format!("{}-{}", self.name, self.version) } - + /// Apply the variant matching the current platform fn apply_current_variant(&mut self) { let current_platform = if cfg!(target_os = "linux") { @@ -118,31 +118,23 @@ impl Package { } 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()); } } } } - + /// Expand environment variables and tilde in a value pub fn expand_env_value(&self, value: &str, env: &HashMap) -> String { let mut result = value.to_string(); - // Replace ${PACKAGE_ROOT} with actual path result = result.replace("${PACKAGE_ROOT}", &self.root.to_string_lossy()); - - // Replace ${VERSION} with package version result = result.replace("${VERSION}", &self.version); - - // Replace ${NAME} with package name result = result.replace("${NAME}", &self.name); // Platform-aware builtins so a single yaml line can compose path @@ -150,17 +142,18 @@ impl Package { 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); } - // Replace remaining ${VAR} with current environment + // Anything still unresolved falls back to the current process env. let re = regex::Regex::new(r"\$\{([^}]+)\}").unwrap(); - result = re.replace_all(&result, |caps: ®ex::Captures| { - let var = &caps[1]; - std::env::var(var).unwrap_or_default() - }).to_string(); + result = re + .replace_all(&result, |caps: ®ex::Captures| { + let var = &caps[1]; + std::env::var(var).unwrap_or_default() + }) + .to_string(); // Expand `~/` everywhere it appears at a segment boundary // (start-of-value, or after `:` / `;`). Path-list values like @@ -179,23 +172,26 @@ impl Package { result } - + /// Get resolved environment for this package - pub fn resolved_environment(&self, base_env: &HashMap) -> HashMap { + pub fn resolved_environment( + &self, + base_env: &HashMap, + ) -> HashMap { let mut env = base_env.clone(); - + for (key, value) in &self.environment { let expanded = self.expand_env_value(value, &env); env.insert(key.clone(), expanded); } - + env } } /// Tokenize a command-alias value into `[program, args...]`. /// -/// If the whole value — after tilde expansion — names an existing file, it's +/// If the whole value, after tilde expansion, names an existing file, it's /// treated as a single executable path (so paths with spaces like /// `/Applications/Houdini 20/bin/hython` work without quoting). Otherwise the /// value is split with POSIX shell rules and each token is tilde-expanded. @@ -240,7 +236,6 @@ impl PackageRequest { /// with an ASCII digit). This allows hyphenated package names such as /// `studio-blender-tools` to be used without being misinterpreted. pub fn parse(s: &str) -> Result { - // Try to split name and version on the last '-' if let Some(idx) = s.rfind('-') { let name = &s[..idx]; let version_part = &s[idx + 1..]; @@ -254,7 +249,6 @@ impl PackageRequest { .map_or(false, |c| c.is_ascii_digit()); if starts_with_digit { - // Parse version constraint let constraint = if version_part.ends_with('+') { VersionConstraint::Minimum(version_part.trim_end_matches('+').to_string()) } else if version_part.contains("..") { @@ -285,7 +279,7 @@ impl PackageRequest { version_constraint: VersionConstraint::Any, }) } - + /// Check if a version matches this constraint pub fn matches(&self, version: &str) -> bool { match &self.version_constraint { @@ -345,14 +339,18 @@ mod tests { fn parse_range_version() { let req = PackageRequest::parse("maya-2024..2025").unwrap(); assert_eq!(req.name, "maya"); - assert!(matches!(req.version_constraint, VersionConstraint::Range(a, b) if a == "2024" && b == "2025")); + assert!( + matches!(req.version_constraint, VersionConstraint::Range(a, b) if a == "2024" && b == "2025") + ); } #[test] fn parse_oneof_version() { let req = PackageRequest::parse("python-3.10|3.11").unwrap(); assert_eq!(req.name, "python"); - assert!(matches!(req.version_constraint, VersionConstraint::OneOf(ref v) if v == &["3.10", "3.11"])); + assert!( + matches!(req.version_constraint, VersionConstraint::OneOf(ref v) if v == &["3.10", "3.11"]) + ); } #[test] @@ -475,7 +473,10 @@ mod tests { root: PathBuf::from("/opt/maya"), }; let env = HashMap::new(); - assert_eq!(pkg.expand_env_value("${NAME}-${VERSION}", &env), "maya-2024"); + assert_eq!( + pkg.expand_env_value("${NAME}-${VERSION}", &env), + "maya-2024" + ); } #[test] diff --git a/src/resolver.rs b/src/resolver.rs index c3ad983..75e59e2 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -119,7 +119,6 @@ impl Resolver { // don't share a cache (e.g. different filters or package paths). let salt = format!("{:?}{:?}", self.config.package_paths, self.config.filters); - // Try cache if !refresh { if let Some(cached) = cache::load(&paths, &salt) { self.package_cache = cached; @@ -131,11 +130,10 @@ impl Resolver { info!("Bypassing package scan cache (--refresh)"); } - // Full scan self.scan_packages()?; self.apply_filters(); - // Save to cache (best-effort, before filter so cache stores everything) + // Best-effort: a save failure shouldn't block resolution. if let Err(e) = cache::save(&paths, &salt, &self.package_cache) { debug!("Failed to save cache: {}", e); } @@ -150,8 +148,7 @@ impl Resolver { return; } - self.package_cache - .retain(|name, _| filters.allows(name)); + self.package_cache.retain(|name, _| filters.allows(name)); } /// Scan package paths and load all packages. @@ -223,7 +220,6 @@ impl Resolver { let mut resolved: Vec = Vec::new(); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); - // Expand aliases let mut expanded_requests: Vec = Vec::new(); for req in requests { if let Some(alias_packages) = self.config.resolve_alias(req) { @@ -233,7 +229,6 @@ impl Resolver { } } - // Resolve each request for req_str in &expanded_requests { let request = PackageRequest::parse(req_str) .with_context(|| format!("Invalid package request: {}", req_str))?; @@ -273,7 +268,9 @@ impl Resolver { /// 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) + let versions = self + .package_cache + .get(&request.name) .ok_or_else(|| anyhow::anyhow!("Package not found: {}", request.name))?; // Lockfile pin takes priority @@ -324,7 +321,9 @@ impl Resolver { /// List versions of a specific package pub fn list_versions(&self, name: &str) -> Result> { - let versions = self.package_cache.get(name) + let versions = self + .package_cache + .get(name) .ok_or_else(|| anyhow::anyhow!("Package not found: {}", name))?; let mut version_list: Vec = versions.keys().cloned().collect(); @@ -407,8 +406,7 @@ fn check_executable(program: &str) -> std::result::Result<(), String> { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let meta = std::fs::metadata(&resolved) - .map_err(|e| format!("stat failed: {}", e))?; + let meta = std::fs::metadata(&resolved).map_err(|e| format!("stat failed: {}", e))?; if !meta.is_file() { return Err("not a regular file".into()); } @@ -418,8 +416,7 @@ fn check_executable(program: &str) -> std::result::Result<(), String> { } #[cfg(not(unix))] { - let meta = std::fs::metadata(&resolved) - .map_err(|e| format!("stat failed: {}", e))?; + let meta = std::fs::metadata(&resolved).map_err(|e| format!("stat failed: {}", e))?; if !meta.is_file() { return Err("not a regular file".into()); } diff --git a/src/shell.rs b/src/shell.rs index 54c4eb9..18419ea 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -13,21 +13,17 @@ pub const SHIM_DIR_PREFIX: &str = "anvil-shell-"; /// Detect the user's preferred shell pub fn detect_shell() -> String { - // Check SHELL environment variable if let Ok(shell) = std::env::var("SHELL") { return shell; } - - // Platform defaults + if cfg!(target_os = "windows") { - // Check for PowerShell first if which::which("pwsh").is_ok() { return "pwsh".to_string(); } return "cmd".to_string(); } - - // Unix default + "bash".to_string() } @@ -37,34 +33,31 @@ pub fn spawn_shell(shell: &str, env: &HashMap) -> Result<()> { .file_name() .and_then(|s| s.to_str()) .unwrap_or(shell); - + println!("Starting {} shell with resolved environment...", shell_name); println!("Type 'exit' to return to your original shell.\n"); - + let mut cmd = Command::new(shell); - - // Set up environment + cmd.env_clear(); cmd.envs(env); - - // Add anvil indicator to prompt + + // Tag the prompt so users see when they're inside an anvil shell. if let Some(prompt) = env.get("PS1") { let new_prompt = format!("[anvil] {}", prompt); cmd.env("PS1", new_prompt); } else { - // Set a simple prompt for bash cmd.env("PS1", "[anvil] \\u@\\h:\\w\\$ "); } - - // Platform-specific setup + cfg_if::cfg_if! { if #[cfg(unix)] { use std::os::unix::process::CommandExt; - // Replace current process with shell + // Replace the current process with the shell so the user's exit + // returns them to their original parent. let err = cmd.exec(); Err(err.into()) } else { - // Windows: spawn and wait let status = cmd.status()?; if !status.success() { anyhow::bail!("Shell exited with status: {:?}", status.code()); @@ -80,12 +73,11 @@ pub fn generate_env_script(shell: &str, env: &HashMap) -> String .file_name() .and_then(|s| s.to_str()) .unwrap_or(shell); - + match shell_name { "bash" | "sh" | "zsh" => { let mut script = String::new(); for (key, value) in env { - // Escape special characters let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); script.push_str(&format!("export {}=\"{}\"\n", key, escaped)); } @@ -115,7 +107,6 @@ pub fn generate_env_script(shell: &str, env: &HashMap) -> String script } _ => { - // Default to bash-style let mut script = String::new(); for (key, value) in env { let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); @@ -127,8 +118,8 @@ pub fn generate_env_script(shell: &str, env: &HashMap) -> String } /// Write a PATH shim for each `(alias, command)` pair into a fresh tempdir -/// and return the path. The tempdir is *leaked* on purpose — its lifetime -/// is the interactive subshell the caller is about to spawn, and the sweeper +/// and return the path. The tempdir is *leaked* on purpose: its lifetime is +/// the interactive subshell the caller is about to spawn, and the sweeper /// reclaims it on the next `anvil shell` invocation. /// /// On POSIX the shim is a `chmod 755` shebang script; on Windows it's a @@ -155,8 +146,8 @@ pub fn materialize_commands(commands: &HashMap) -> Result Result<()> { use std::os::unix::fs::PermissionsExt; - // `target` is already the expanded command string — e.g. a bare path or - // `"/Applications/... Painter" --flag`. `exec "$@"` with the command + // `target` is already the expanded command string (e.g. a bare path or + // `"/Applications/... Painter" --flag`). `exec "$@"` with the command // embedded raw preserves any baked-in arguments and lets the user append // their own. let script = format!("#!/usr/bin/env bash\nexec {} \"$@\"\n", target); @@ -192,7 +183,9 @@ pub fn sweep_stale_shims_in(root: &Path, ttl: std::time::Duration) { for entry in entries.flatten() { let name = entry.file_name(); - let Some(name_str) = name.to_str() else { continue }; + let Some(name_str) = name.to_str() else { + continue; + }; if !name_str.starts_with(SHIM_DIR_PREFIX) { continue; } @@ -202,7 +195,9 @@ pub fn sweep_stale_shims_in(root: &Path, ttl: std::time::Duration) { continue; } let Ok(mtime) = meta.modified() else { continue }; - let Ok(age) = now.duration_since(mtime) else { continue }; + let Ok(age) = now.duration_since(mtime) else { + continue; + }; if age < ttl { continue; } diff --git a/tests/cli.rs b/tests/cli.rs index 39a8d73..89d6052 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -174,11 +174,10 @@ fn validate_all() { // (e.g. /usr/autodesk/maya2024/bin/maya), so validate reports // command warnings but still succeeds. Dependencies do resolve. let (_dir, cfg) = setup_env(); - anvil(&cfg) - .args(["validate"]) - .assert() - .success() - .stdout(predicate::str::contains("All dependencies resolve").or(predicate::str::contains("All packages valid!"))); + anvil(&cfg).args(["validate"]).assert().success().stdout( + predicate::str::contains("All dependencies resolve") + .or(predicate::str::contains("All packages valid!")), + ); } #[test] @@ -325,7 +324,7 @@ fn run_quoted_tokens_in_alias() { #[test] fn flat_and_nested_coexist() { let (_dir, cfg) = setup_env(); - // maya is flat, python is nested — both should appear + // maya is flat, python is nested; both should appear anvil(&cfg) .args(["list"]) .assert() @@ -423,7 +422,13 @@ fn context_save_and_show() { // Save anvil(&cfg) - .args(["context", "save", "maya-2024", "-o", ctx_path.to_str().unwrap()]) + .args([ + "context", + "save", + "maya-2024", + "-o", + ctx_path.to_str().unwrap(), + ]) .assert() .success() .stdout(predicate::str::contains("Saved context")); @@ -445,7 +450,13 @@ fn context_show_json() { let ctx_path = dir.path().join("test.ctx.json"); anvil(&cfg) - .args(["context", "save", "maya-2024", "-o", ctx_path.to_str().unwrap()]) + .args([ + "context", + "save", + "maya-2024", + "-o", + ctx_path.to_str().unwrap(), + ]) .assert() .success(); @@ -462,7 +473,13 @@ fn context_show_export() { let ctx_path = dir.path().join("test.ctx.json"); anvil(&cfg) - .args(["context", "save", "maya-2024", "-o", ctx_path.to_str().unwrap()]) + .args([ + "context", + "save", + "maya-2024", + "-o", + ctx_path.to_str().unwrap(), + ]) .assert() .success(); @@ -866,7 +883,12 @@ fn publish_refuses_overwrite() { Command::cargo_bin("anvil") .unwrap() .env("RUST_LOG", "anvil=error") - .args(["publish", target.to_str().unwrap(), "--path", src.to_str().unwrap()]) + .args([ + "publish", + target.to_str().unwrap(), + "--path", + src.to_str().unwrap(), + ]) .assert() .success(); @@ -874,7 +896,12 @@ fn publish_refuses_overwrite() { Command::cargo_bin("anvil") .unwrap() .env("RUST_LOG", "anvil=error") - .args(["publish", target.to_str().unwrap(), "--path", src.to_str().unwrap()]) + .args([ + "publish", + target.to_str().unwrap(), + "--path", + src.to_str().unwrap(), + ]) .assert() .failure(); } @@ -917,7 +944,9 @@ fn list_hint_when_package_paths_missing() { .args(["list"]) .assert() .success() - .stderr(predicate::str::contains("None of the configured package_paths exist")); + .stderr(predicate::str::contains( + "None of the configured package_paths exist", + )); } #[test]