Skip to content

Commit b0f3a91

Browse files
author
acabrera
committed
add tokenizer, and handle paths with spaces
1 parent 01fdfe2 commit b0f3a91

7 files changed

Lines changed: 357 additions & 81 deletions

File tree

src/cache.rs

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -68,23 +68,35 @@ pub fn compute_fingerprint(package_paths: &[PathBuf], config_salt: &str) -> u64
6868
}
6969

7070
fn hash_dir_entries(dir: &Path, hasher: &mut impl Hasher) {
71-
if let Ok(entries) = std::fs::read_dir(dir) {
72-
let mut items: Vec<(String, u64)> = entries
73-
.flatten()
74-
.map(|e| {
75-
let name = e.file_name().to_string_lossy().to_string();
76-
let mtime = e
77-
.metadata()
78-
.and_then(|m| m.modified())
79-
.ok()
80-
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
81-
.map(|d| d.as_secs())
82-
.unwrap_or(0);
83-
(name, mtime)
84-
})
85-
.collect();
86-
items.sort();
87-
items.hash(hasher);
71+
match std::fs::read_dir(dir) {
72+
Ok(entries) => {
73+
// Tuple: (name, mtime_nanos, size). Including size catches
74+
// edits where mtime didn't advance at second-resolution, and
75+
// the count of entries is implicit in the vec length.
76+
let mut items: Vec<(String, u128, u64)> = entries
77+
.flatten()
78+
.map(|e| {
79+
let name = e.file_name().to_string_lossy().to_string();
80+
let meta = e.metadata().ok();
81+
let mtime = meta
82+
.as_ref()
83+
.and_then(|m| m.modified().ok())
84+
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
85+
.map(|d| d.as_nanos())
86+
.unwrap_or(0);
87+
let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
88+
(name, mtime, size)
89+
})
90+
.collect();
91+
items.sort();
92+
items.len().hash(hasher);
93+
items.hash(hasher);
94+
}
95+
Err(_) => {
96+
// Signal "unreadable" so the hash still changes if a dir flips
97+
// between readable and not.
98+
"ERR".hash(hasher);
99+
}
88100
}
89101
}
90102

@@ -95,9 +107,10 @@ fn hash_file_mtime(path: &Path, hasher: &mut impl Hasher) {
95107
mtime
96108
.duration_since(std::time::UNIX_EPOCH)
97109
.unwrap_or_default()
98-
.as_secs()
110+
.as_nanos()
99111
.hash(hasher);
100112
}
113+
meta.len().hash(hasher);
101114
}
102115
}
103116

src/cli.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ use clap::{CommandFactory, Parser, Subcommand};
88
#[command(version)]
99
#[command(about = "Forge your environment 🔨 — Fast package resolution for VFX pipelines", long_about = None)]
1010
pub struct Cli {
11+
/// Ignore any cached package scan and re-read all package files.
12+
#[arg(long, global = true)]
13+
pub refresh: bool,
14+
1115
#[command(subcommand)]
1216
pub command: Commands,
1317
}
@@ -71,6 +75,11 @@ pub enum Commands {
7175
Validate {
7276
/// Package to validate (optional, validates all if not specified)
7377
package: Option<String>,
78+
79+
/// Treat command-target warnings (missing / non-executable files)
80+
/// as validation failures.
81+
#[arg(long)]
82+
strict: bool,
7483
},
7584

7685
/// Pin resolved versions to a lockfile for reproducible environments

src/config.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,9 @@ impl FiltersConfig {
9292

9393
/// Simple glob matching supporting `*` (any chars) and `?` (single char).
9494
fn glob_match(pattern: &str, text: &str) -> bool {
95-
let mut p = pattern.chars().peekable();
96-
let mut t = text.chars().peekable();
97-
98-
glob_match_inner(&mut p.collect::<Vec<_>>(), &t.collect::<Vec<_>>(), 0, 0)
95+
let p: Vec<char> = pattern.chars().collect();
96+
let t: Vec<char> = text.chars().collect();
97+
glob_match_inner(&p, &t, 0, 0)
9998
}
10099

101100
fn glob_match_inner(pattern: &[char], text: &[char], pi: usize, ti: usize) -> bool {

src/main.rs

Lines changed: 95 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -33,32 +33,33 @@ fn main() -> Result<()> {
3333

3434
// Load config
3535
let config = Config::load()?;
36+
let refresh = cli.refresh;
3637

3738
match cli.command {
3839
Commands::Env { packages, export, json } => {
39-
cmd_env(&config, &packages, export, json)?;
40+
cmd_env(&config, &packages, export, json, refresh)?;
4041
}
4142
Commands::Run { packages, env_vars, command } => {
42-
cmd_run(&config, &packages, &env_vars, &command)?;
43+
cmd_run(&config, &packages, &env_vars, &command, refresh)?;
4344
}
4445
Commands::Shell { packages, shell } => {
45-
cmd_shell(&config, &packages, shell)?;
46+
cmd_shell(&config, &packages, shell, refresh)?;
4647
}
4748
Commands::List { package } => {
48-
cmd_list(&config, package)?;
49+
cmd_list(&config, package, refresh)?;
4950
}
5051
Commands::Info { package } => {
51-
cmd_info(&config, &package)?;
52+
cmd_info(&config, &package, refresh)?;
5253
}
53-
Commands::Validate { package } => {
54-
cmd_validate(&config, package)?;
54+
Commands::Validate { package, strict } => {
55+
cmd_validate(&config, package, strict, refresh)?;
5556
}
5657
Commands::Lock { packages, update: _ } => {
57-
cmd_lock(&config, &packages)?;
58+
cmd_lock(&config, &packages, refresh)?;
5859
}
5960
Commands::Context { action } => match action {
6061
ContextAction::Save { packages, output } => {
61-
cmd_context_save(&config, &packages, &output)?;
62+
cmd_context_save(&config, &packages, &output, refresh)?;
6263
}
6364
ContextAction::Show { file, json, export } => {
6465
cmd_context_show(&file, json, export)?;
@@ -77,7 +78,7 @@ fn main() -> Result<()> {
7778
Cli::print_completions(shell);
7879
}
7980
Commands::Wrap { packages, dir, shell } => {
80-
cmd_wrap(&config, &packages, &dir, &shell)?;
81+
cmd_wrap(&config, &packages, &dir, &shell, refresh)?;
8182
}
8283
Commands::Publish { target, path, flat } => {
8384
cmd_publish(&target, path.as_deref(), flat)?;
@@ -88,8 +89,14 @@ fn main() -> Result<()> {
8889
}
8990

9091
/// Resolve packages and print environment
91-
fn cmd_env(config: &Config, packages: &[String], export: bool, json: bool) -> Result<()> {
92-
let resolver = Resolver::new(config)?;
92+
fn cmd_env(
93+
config: &Config,
94+
packages: &[String],
95+
export: bool,
96+
json: bool,
97+
refresh: bool,
98+
) -> Result<()> {
99+
let resolver = Resolver::new(config, refresh)?;
93100
let resolved = resolver.resolve(packages)?;
94101
let env = resolved.environment();
95102

@@ -114,13 +121,14 @@ fn cmd_run(
114121
packages: &[String],
115122
env_vars: &[String],
116123
command: &[String],
124+
refresh: bool,
117125
) -> Result<()> {
118126
use std::process::Command;
119127

120128
// Pre-resolve hooks
121129
Config::run_hooks(&config.hooks.pre_resolve, &std::env::vars().collect())?;
122130

123-
let resolver = Resolver::new(config)?;
131+
let resolver = Resolver::new(config, refresh)?;
124132
let resolved = resolver.resolve(packages)?;
125133
let mut env = resolved.environment();
126134

@@ -138,23 +146,17 @@ fn cmd_run(
138146
anyhow::bail!("No command specified");
139147
}
140148

141-
// Resolve command alias. A command value may include baked-in
142-
// arguments (e.g. `nukex: ${NUKE}/Nuke --nukex`) or whitespace from
143-
// a script launcher (e.g. `usdview: python3.14 ~/USD/bin/usdview`).
144-
// Tokenize with POSIX shell rules so the first token is the program
145-
// and the rest are prepended to user args. Tilde-expand each token
146-
// individually — Package::expand_env_value only expands a leading
147-
// `~/` so tokens after whitespace would otherwise stay literal.
149+
// Resolve command alias. A command value may be a bare path (possibly
150+
// containing spaces, e.g. `/Applications/Houdini 20/bin/hython`), or
151+
// include baked-in arguments (e.g. `nukex: ${NUKE}/Nuke --nukex`), or
152+
// whitespace from a script launcher (e.g. `python3.14 ~/USD/bin/usdview`).
148153
let commands_map = resolved.commands();
149154
let resolved_cmd = commands_map
150155
.get(&command[0])
151156
.cloned()
152157
.unwrap_or_else(|| command[0].clone());
153-
let mut tokens: Vec<String> = shell_words::split(&resolved_cmd)
154-
.with_context(|| format!("Failed to parse command alias: {:?}", resolved_cmd))?
155-
.into_iter()
156-
.map(|t| shellexpand::tilde(&t).into_owned())
157-
.collect();
158+
let mut tokens = package::tokenize_command(&resolved_cmd)
159+
.with_context(|| format!("Failed to parse command alias: {:?}", resolved_cmd))?;
158160
if tokens.is_empty() {
159161
anyhow::bail!(
160162
"Command alias for {:?} resolved to an empty string",
@@ -180,8 +182,13 @@ fn cmd_run(
180182
}
181183

182184
/// Start interactive shell with resolved environment
183-
fn cmd_shell(config: &Config, packages: &[String], shell: Option<String>) -> Result<()> {
184-
let resolver = Resolver::new(config)?;
185+
fn cmd_shell(
186+
config: &Config,
187+
packages: &[String],
188+
shell: Option<String>,
189+
refresh: bool,
190+
) -> Result<()> {
191+
let resolver = Resolver::new(config, refresh)?;
185192
let resolved = resolver.resolve(packages)?;
186193
let env = resolved.environment();
187194

@@ -195,8 +202,8 @@ fn cmd_shell(config: &Config, packages: &[String], shell: Option<String>) -> Res
195202
}
196203

197204
/// List available packages
198-
fn cmd_list(config: &Config, package: Option<String>) -> Result<()> {
199-
let resolver = Resolver::new(config)?;
205+
fn cmd_list(config: &Config, package: Option<String>, refresh: bool) -> Result<()> {
206+
let resolver = Resolver::new(config, refresh)?;
200207

201208
if let Some(name) = package {
202209
// List versions of specific package
@@ -217,8 +224,8 @@ fn cmd_list(config: &Config, package: Option<String>) -> Result<()> {
217224
}
218225

219226
/// Show package info
220-
fn cmd_info(config: &Config, package: &str) -> Result<()> {
221-
let resolver = Resolver::new(config)?;
227+
fn cmd_info(config: &Config, package: &str, refresh: bool) -> Result<()> {
228+
let resolver = Resolver::new(config, refresh)?;
222229
let pkg = resolver.get_package(package)?;
223230

224231
println!("Name: {}", pkg.name);
@@ -248,9 +255,18 @@ fn cmd_info(config: &Config, package: &str) -> Result<()> {
248255
Ok(())
249256
}
250257

251-
/// Validate package definitions
252-
fn cmd_validate(config: &Config, package: Option<String>) -> Result<()> {
253-
let resolver = Resolver::new(config)?;
258+
/// Validate package definitions.
259+
///
260+
/// Dependency problems are always fatal. Command-target problems
261+
/// (missing / non-executable files) are reported as warnings unless
262+
/// `strict` is set, in which case they fail validation too.
263+
fn cmd_validate(
264+
config: &Config,
265+
package: Option<String>,
266+
strict: bool,
267+
refresh: bool,
268+
) -> Result<()> {
269+
let resolver = Resolver::new(config, refresh)?;
254270

255271
let packages = if let Some(name) = package {
256272
vec![name]
@@ -259,10 +275,27 @@ fn cmd_validate(config: &Config, package: Option<String>) -> Result<()> {
259275
};
260276

261277
let mut errors = 0;
278+
let mut warnings = 0;
262279

263280
for pkg_name in packages {
264-
match resolver.validate_package(&pkg_name) {
265-
Ok(()) => println!("✓ {}", pkg_name),
281+
let report = resolver.validate_package_report(&pkg_name);
282+
match report {
283+
Ok(cmd_problems) => {
284+
if cmd_problems.is_empty() {
285+
println!("✓ {}", pkg_name);
286+
} else {
287+
let label = if strict { "✗" } else { "!" };
288+
println!("{} {}: command problems:", label, pkg_name);
289+
for p in &cmd_problems {
290+
println!(" - {}", p);
291+
}
292+
if strict {
293+
errors += 1;
294+
} else {
295+
warnings += 1;
296+
}
297+
}
298+
}
266299
Err(e) => {
267300
println!("✗ {}: {}", pkg_name, e);
268301
errors += 1;
@@ -274,7 +307,14 @@ fn cmd_validate(config: &Config, package: Option<String>) -> Result<()> {
274307
anyhow::bail!("{} package(s) failed validation", errors);
275308
}
276309

277-
println!("\nAll packages valid!");
310+
if warnings > 0 {
311+
println!(
312+
"\nAll dependencies resolve ({} package(s) with command warnings — use --strict to fail on these).",
313+
warnings
314+
);
315+
} else {
316+
println!("\nAll packages valid!");
317+
}
278318
Ok(())
279319
}
280320

@@ -283,9 +323,9 @@ fn cmd_validate(config: &Config, package: Option<String>) -> Result<()> {
283323
// ---------------------------------------------------------------------------
284324

285325
/// Resolve packages and write pinned versions to `anvil.lock`.
286-
fn cmd_lock(config: &Config, packages: &[String]) -> Result<()> {
326+
fn cmd_lock(config: &Config, packages: &[String], refresh: bool) -> Result<()> {
287327
// Always resolve fresh (ignore existing lockfile).
288-
let resolver = Resolver::new_unlocked(config)?;
328+
let resolver = Resolver::new_unlocked(config, refresh)?;
289329
let resolved = resolver.resolve(packages)?;
290330

291331
let mut pins = std::collections::HashMap::new();
@@ -314,8 +354,13 @@ fn cmd_lock(config: &Config, packages: &[String]) -> Result<()> {
314354
// ---------------------------------------------------------------------------
315355

316356
/// Resolve packages and save the full environment to a context file.
317-
fn cmd_context_save(config: &Config, packages: &[String], output: &str) -> Result<()> {
318-
let resolver = Resolver::new(config)?;
357+
fn cmd_context_save(
358+
config: &Config,
359+
packages: &[String],
360+
output: &str,
361+
refresh: bool,
362+
) -> Result<()> {
363+
let resolver = Resolver::new(config, refresh)?;
319364
let resolved = resolver.resolve(packages)?;
320365
let env = resolved.environment();
321366

@@ -416,7 +461,7 @@ fn cmd_init(name: &str, version: &str, flat: bool) -> Result<()> {
416461
let template = format!(
417462
r#"name: {name}
418463
version: "{version}"
419-
description: TODO
464+
# description: one-line summary
420465
421466
# requires:
422467
# - python-3.11
@@ -459,8 +504,14 @@ environment:
459504
// ---------------------------------------------------------------------------
460505

461506
/// Generate wrapper scripts for all commands defined by the resolved packages.
462-
fn cmd_wrap(config: &Config, packages: &[String], dir: &str, wrapper_shell: &str) -> Result<()> {
463-
let resolver = Resolver::new(config)?;
507+
fn cmd_wrap(
508+
config: &Config,
509+
packages: &[String],
510+
dir: &str,
511+
wrapper_shell: &str,
512+
refresh: bool,
513+
) -> Result<()> {
514+
let resolver = Resolver::new(config, refresh)?;
464515
let resolved = resolver.resolve(packages)?;
465516
let commands = resolved.commands();
466517

0 commit comments

Comments
 (0)