From 6224f2f2d60b1d61da2f3b35bb0ecd310954075f Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 15 Nov 2025 22:59:07 +0300 Subject: [PATCH 01/10] eh: attempt to prevent resource leaks Signed-off-by: NotAShelf Change-Id: I28d716bd37d17dd96731c7863b3383416a6a6964 --- eh/src/command.rs | 18 ++++++++++++++++++ eh/src/error.rs | 12 +++++++++--- eh/src/main.rs | 4 ++-- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/eh/src/command.rs b/eh/src/command.rs index 74ca3f1..7eace98 100644 --- a/eh/src/command.rs +++ b/eh/src/command.rs @@ -2,6 +2,7 @@ use std::{ collections::VecDeque, io::{self, Read, Write}, process::{Command, ExitStatus, Output, Stdio}, + time::{Duration, Instant}, }; use crate::error::{EhError, Result}; @@ -27,6 +28,9 @@ impl LogInterceptor for StdIoInterceptor { /// Default buffer size for reading command output const DEFAULT_BUFFER_SIZE: usize = 4096; +/// Default timeout for command execution +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300); // 5 minutes + /// Builder and executor for Nix commands. pub struct NixCommand { subcommand: String, @@ -147,10 +151,19 @@ impl NixCommand { let mut out_queue = VecDeque::new(); let mut err_queue = VecDeque::new(); + let start_time = Instant::now(); loop { let mut did_something = false; + // Check for timeout + if start_time.elapsed() > DEFAULT_TIMEOUT { + let _ = child.kill(); + return Err(EhError::CommandFailed { + command: format!("nix {} timed out after 5 minutes", self.subcommand), + }); + } + match stdout.read(&mut out_buf) { Ok(0) => {}, Ok(n) => { @@ -176,6 +189,11 @@ impl NixCommand { if !did_something && child.try_wait()?.is_some() { break; } + + // Prevent busy waiting when no data is available + if !did_something { + std::thread::sleep(Duration::from_millis(10)); + } } let status = child.wait()?; diff --git a/eh/src/error.rs b/eh/src/error.rs index b9c2bfb..173f047 100644 --- a/eh/src/error.rs +++ b/eh/src/error.rs @@ -40,9 +40,15 @@ impl EhError { pub const fn exit_code(&self) -> i32 { match self { Self::ProcessExit { code } => *code, - Self::NixCommandFailed(_) => 1, - Self::CommandFailed { .. } => 1, - _ => 1, + Self::NixCommandFailed(_) => 2, + Self::CommandFailed { .. } => 3, + Self::HashExtractionFailed => 4, + Self::NoNixFilesFound => 5, + Self::HashFixFailed { .. } => 6, + Self::InvalidInput { .. } => 7, + Self::Io(_) => 8, + Self::Regex(_) => 9, + Self::Utf8(_) => 10, } } } diff --git a/eh/src/main.rs b/eh/src/main.rs index 13c29f4..370049f 100644 --- a/eh/src/main.rs +++ b/eh/src/main.rs @@ -36,12 +36,12 @@ fn dispatch_multicall( args: std::env::Args, ) -> Option> { let rest: Vec = args.collect(); - + // Validate arguments before processing if let Err(e) = util::validate_nix_args(&rest) { return Some(Err(e)); } - + let hash_extractor = util::RegexHashExtractor; let fixer = util::DefaultNixFileFixer; let classifier = util::DefaultNixErrorClassifier; From 237bfec0d4b5430448bdd97b2ff174eafc68cada Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 15 Nov 2025 23:00:34 +0300 Subject: [PATCH 02/10] chore: bump crate version Signed-off-by: NotAShelf Change-Id: I3787eb5f42c471dda268e1f3ccffd0296a6a6964 --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d195e8b..bd32f1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,7 +78,7 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" [[package]] name = "eh" -version = "0.1.2" +version = "0.1.3" dependencies = [ "clap", "regex", @@ -438,7 +438,7 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "xtask" -version = "0.1.2" +version = "0.1.3" dependencies = [ "clap", "clap_complete", diff --git a/Cargo.toml b/Cargo.toml index dda7e17..9cc10d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ edition = "2024" license = "MPL-2.0" readme = true rust-version = "1.89" -version = "0.1.2" +version = "0.1.3" [workspace.dependencies] clap = { default-features = false, features = [ "std", "help", "derive" ], version = "4.5.51" } From 4355f1d2c77ccfffad1f41a34c83854fb38103cb Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 30 Jan 2026 16:13:23 +0300 Subject: [PATCH 03/10] meta: move xtask to `crates/` Signed-off-by: NotAShelf Change-Id: I8080e8d293726fad569f3f8dd79b22ea6a6a6964 --- {xtask => crates/xtask}/Cargo.toml | 2 +- {xtask => crates/xtask}/src/main.rs | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename {xtask => crates/xtask}/Cargo.toml (87%) rename {xtask => crates/xtask}/src/main.rs (100%) diff --git a/xtask/Cargo.toml b/crates/xtask/Cargo.toml similarity index 87% rename from xtask/Cargo.toml rename to crates/xtask/Cargo.toml index ece4292..2e7cd1a 100644 --- a/xtask/Cargo.toml +++ b/crates/xtask/Cargo.toml @@ -11,4 +11,4 @@ publish = false [dependencies] clap.workspace = true clap_complete.workspace = true -eh = { path = "../eh" } +eh.workspace = true diff --git a/xtask/src/main.rs b/crates/xtask/src/main.rs similarity index 100% rename from xtask/src/main.rs rename to crates/xtask/src/main.rs From 304a7e1a1adfa4909b5acc6e26bf49d55b8c16d9 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 30 Jan 2026 18:02:09 +0300 Subject: [PATCH 04/10] eh: remove unused tracing dep Signed-off-by: NotAShelf Change-Id: Idd2818fa3d590b192c1bdecefb25da066a6a6964 --- Cargo.lock | 116 --------- Cargo.toml | 7 +- eh/Cargo.toml | 2 - eh/src/main.rs | 69 +++--- eh/src/util.rs | 634 ++++++++++++++++++++++++++++++++++++++----------- 5 files changed, 535 insertions(+), 293 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bd32f1d..d68884d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,8 +84,6 @@ dependencies = [ "regex", "tempfile", "thiserror", - "tracing", - "tracing-subscriber", "walkdir", "yansi", ] @@ -124,12 +122,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "libc" version = "0.2.177" @@ -142,39 +134,18 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" -[[package]] -name = "log" -version = "0.4.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" - [[package]] name = "memchr" version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys", -] - [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - [[package]] name = "proc-macro2" version = "1.0.95" @@ -250,21 +221,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - [[package]] name = "syn" version = "2.0.104" @@ -309,84 +265,12 @@ dependencies = [ "syn", ] -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "tracing" -version = "0.1.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" -dependencies = [ - "nu-ansi-term", - "sharded-slab", - "smallvec", - "thread_local", - "tracing-core", - "tracing-log", -] - [[package]] name = "unicode-ident" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 9cc10d5..59825fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] -members = [ "eh", "xtask" ] +members = [ "eh", "crates/*" ] +default-members = ["eh"] resolver = "3" [workspace.package] @@ -17,11 +18,11 @@ clap_complete = "4.5.60" regex = "1.12.2" tempfile = "3.23.0" thiserror = "2.0.17" -tracing = "0.1.41" -tracing-subscriber = "0.3.20" walkdir = "2.5.0" yansi = "1.0.1" +eh = { path = "./eh" } + [profile.release] codegen-units = 1 lto = true diff --git a/eh/Cargo.toml b/eh/Cargo.toml index 1e62b9b..f194a62 100644 --- a/eh/Cargo.toml +++ b/eh/Cargo.toml @@ -15,7 +15,5 @@ clap.workspace = true regex.workspace = true tempfile.workspace = true thiserror.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true walkdir.workspace = true yansi.workspace = true diff --git a/eh/src/main.rs b/eh/src/main.rs index 370049f..e090375 100644 --- a/eh/src/main.rs +++ b/eh/src/main.rs @@ -11,20 +11,15 @@ mod shell; mod util; fn main() { - let format = tracing_subscriber::fmt::format() - .with_level(true) // don't include levels in formatted output - .with_target(true) // don't include targets - .with_thread_ids(false) // include the thread ID of the current thread - .with_thread_names(false) // include the name of the current thread - .compact(); // use the `Compact` formatting style. - tracing_subscriber::fmt().event_format(format).init(); - let result = run_app(); match result { Ok(code) => std::process::exit(code), Err(e) => { eprintln!("Error: {e}"); + if let Some(hint) = e.hint() { + eprintln!("Hint: {hint}"); + } std::process::exit(e.exit_code()); }, } @@ -37,42 +32,42 @@ fn dispatch_multicall( ) -> Option> { let rest: Vec = args.collect(); - // Validate arguments before processing - if let Err(e) = util::validate_nix_args(&rest) { - return Some(Err(e)); + let subcommand = match app_name { + "nr" => "run", + "ns" => "shell", + "nb" => "build", + _ => return None, + }; + + // Handle --help/-h/--version before forwarding to nix + if rest.iter().any(|a| a == "--help" || a == "-h") { + eprintln!("{app_name}: shorthand for 'eh {subcommand}'"); + eprintln!("Usage: {app_name} [args...]"); + eprintln!("All arguments are forwarded to 'nix {subcommand}'."); + return Some(Ok(0)); + } + + if rest.iter().any(|a| a == "--version") { + eprintln!("{app_name} (eh {})", env!("CARGO_PKG_VERSION")); + return Some(Ok(0)); } let hash_extractor = util::RegexHashExtractor; let fixer = util::DefaultNixFileFixer; let classifier = util::DefaultNixErrorClassifier; - match app_name { - "nr" => { - Some(run::handle_nix_run( - &rest, - &hash_extractor, - &fixer, - &classifier, - )) + Some(match subcommand { + "run" => run::handle_nix_run(&rest, &hash_extractor, &fixer, &classifier), + "shell" => { + shell::handle_nix_shell(&rest, &hash_extractor, &fixer, &classifier) }, - "ns" => { - Some(shell::handle_nix_shell( - &rest, - &hash_extractor, - &fixer, - &classifier, - )) + "build" => { + build::handle_nix_build(&rest, &hash_extractor, &fixer, &classifier) }, - "nb" => { - Some(build::handle_nix_build( - &rest, - &hash_extractor, - &fixer, - &classifier, - )) - }, - _ => None, - } + // subcommand is assigned from the match on app_name above; + // only "run"/"shell"/"build" are possible values. + _ => unreachable!(), + }) } fn run_app() -> Result { @@ -107,7 +102,7 @@ fn run_app() -> Result { build::handle_nix_build(&args, &hash_extractor, &fixer, &classifier) }, - _ => { + None => { Cli::command().print_help()?; println!(); Ok(0) diff --git a/eh/src/util.rs b/eh/src/util.rs index f452296..78671a9 100644 --- a/eh/src/util.rs +++ b/eh/src/util.rs @@ -1,11 +1,11 @@ use std::{ io::{BufWriter, Write}, path::{Path, PathBuf}, + sync::LazyLock, }; use regex::Regex; use tempfile::NamedTempFile; -use tracing::{info, warn}; use walkdir::WalkDir; use yansi::Paint; @@ -14,22 +14,41 @@ use crate::{ error::{EhError, Result}, }; +/// Compiled regex patterns for extracting the actual hash from nix stderr. +static HASH_EXTRACT_PATTERNS: LazyLock<[Regex; 3]> = LazyLock::new(|| { + [ + Regex::new(r"got:\s+(sha256-[a-zA-Z0-9+/=]+)").unwrap(), + Regex::new(r"actual:\s+(sha256-[a-zA-Z0-9+/=]+)").unwrap(), + Regex::new(r"have:\s+(sha256-[a-zA-Z0-9+/=]+)").unwrap(), + ] +}); + +/// Compiled regex pattern for extracting the old (specified) hash from nix +/// stderr. +static HASH_OLD_EXTRACT_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r"specified:\s+(sha256-[a-zA-Z0-9+/=]+)").unwrap() +}); + +/// Compiled regex patterns for matching hash attributes in .nix files. +static HASH_FIX_PATTERNS: LazyLock<[Regex; 3]> = LazyLock::new(|| { + [ + Regex::new(r#"hash\s*=\s*"[^"]*""#).unwrap(), + Regex::new(r#"sha256\s*=\s*"[^"]*""#).unwrap(), + Regex::new(r#"outputHash\s*=\s*"[^"]*""#).unwrap(), + ] +}); + pub trait HashExtractor { fn extract_hash(&self, stderr: &str) -> Option; + fn extract_old_hash(&self, stderr: &str) -> Option; } pub struct RegexHashExtractor; impl HashExtractor for RegexHashExtractor { fn extract_hash(&self, stderr: &str) -> Option { - let patterns = [ - r"got:\s+(sha256-[a-zA-Z0-9+/=]+)", - r"actual:\s+(sha256-[a-zA-Z0-9+/=]+)", - r"have:\s+(sha256-[a-zA-Z0-9+/=]+)", - ]; - for pattern in &patterns { - if let Ok(re) = Regex::new(pattern) - && let Some(captures) = re.captures(stderr) + for re in HASH_EXTRACT_PATTERNS.iter() { + if let Some(captures) = re.captures(stderr) && let Some(hash) = captures.get(1) { return Some(hash.as_str().to_string()); @@ -37,22 +56,42 @@ impl HashExtractor for RegexHashExtractor { } None } + + fn extract_old_hash(&self, stderr: &str) -> Option { + HASH_OLD_EXTRACT_PATTERN + .captures(stderr) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()) + } } pub trait NixFileFixer { - fn fix_hash_in_files(&self, new_hash: &str) -> Result; + fn fix_hash_in_files( + &self, + old_hash: Option<&str>, + new_hash: &str, + ) -> Result; fn find_nix_files(&self) -> Result>; - fn fix_hash_in_file(&self, file_path: &Path, new_hash: &str) -> Result; + fn fix_hash_in_file( + &self, + file_path: &Path, + old_hash: Option<&str>, + new_hash: &str, + ) -> Result; } pub struct DefaultNixFileFixer; impl NixFileFixer for DefaultNixFileFixer { - fn fix_hash_in_files(&self, new_hash: &str) -> Result { + fn fix_hash_in_files( + &self, + old_hash: Option<&str>, + new_hash: &str, + ) -> Result { let nix_files = self.find_nix_files()?; let mut fixed = false; for file_path in nix_files { - if self.fix_hash_in_file(&file_path, new_hash)? { + if self.fix_hash_in_file(&file_path, old_hash, new_hash)? { println!("Updated hash in {}", file_path.display()); fixed = true; } @@ -61,8 +100,20 @@ impl NixFileFixer for DefaultNixFileFixer { } fn find_nix_files(&self) -> Result> { + let should_skip = |entry: &walkdir::DirEntry| -> bool { + // Never skip the root entry, otherwise the entire walk is empty + if entry.depth() == 0 || !entry.file_type().is_dir() { + return false; + } + let name = entry.file_name().to_string_lossy(); + name.starts_with('.') + || matches!(name.as_ref(), "node_modules" | "target" | "result") + }; + let files: Vec = WalkDir::new(".") + .max_depth(3) .into_iter() + .filter_entry(|e| !should_skip(e)) .filter_map(std::result::Result::ok) .filter(|entry| { entry.file_type().is_file() @@ -80,39 +131,59 @@ impl NixFileFixer for DefaultNixFileFixer { Ok(files) } - fn fix_hash_in_file(&self, file_path: &Path, new_hash: &str) -> Result { - // Pre-compile regex patterns once to avoid repeated compilation - let patterns: Vec<(Regex, String)> = [ - (r#"hash\s*=\s*"[^"]*""#, format!(r#"hash = "{new_hash}""#)), - ( - r#"sha256\s*=\s*"[^"]*""#, - format!(r#"sha256 = "{new_hash}""#), - ), - ( - r#"outputHash\s*=\s*"[^"]*""#, - format!(r#"outputHash = "{new_hash}""#), - ), - ] - .into_iter() - .map(|(pattern, replacement)| { - Regex::new(pattern) - .map(|re| (re, replacement)) - .map_err(EhError::Regex) - }) - .collect::>>()?; - + fn fix_hash_in_file( + &self, + file_path: &Path, + old_hash: Option<&str>, + new_hash: &str, + ) -> Result { // Read the entire file content let content = std::fs::read_to_string(file_path)?; let mut replaced = false; let mut result_content = content; - // Apply replacements - for (re, replacement) in &patterns { - if re.is_match(&result_content) { - result_content = re - .replace_all(&result_content, replacement.as_str()) - .into_owned(); - replaced = true; + if let Some(old) = old_hash { + // Targeted replacement: only replace attributes whose value matches the + // old hash. Uses regexes to handle variable whitespace around `=`. + let old_escaped = regex::escape(old); + let targeted_patterns = [ + ( + Regex::new(&format!(r#"hash\s*=\s*"{old_escaped}""#)).unwrap(), + format!(r#"hash = "{new_hash}""#), + ), + ( + Regex::new(&format!(r#"sha256\s*=\s*"{old_escaped}""#)).unwrap(), + format!(r#"sha256 = "{new_hash}""#), + ), + ( + Regex::new(&format!(r#"outputHash\s*=\s*"{old_escaped}""#)).unwrap(), + format!(r#"outputHash = "{new_hash}""#), + ), + ]; + + for (re, replacement) in &targeted_patterns { + if re.is_match(&result_content) { + result_content = re + .replace_all(&result_content, replacement.as_str()) + .into_owned(); + replaced = true; + } + } + } else { + // Fallback: replace all hash attributes (original behavior) + let replacements = [ + format!(r#"hash = "{new_hash}""#), + format!(r#"sha256 = "{new_hash}""#), + format!(r#"outputHash = "{new_hash}""#), + ]; + + for (re, replacement) in HASH_FIX_PATTERNS.iter().zip(&replacements) { + if re.is_match(&result_content) { + result_content = re + .replace_all(&result_content, replacement.as_str()) + .into_owned(); + replaced = true; + } } } @@ -140,38 +211,119 @@ pub trait NixErrorClassifier { fn should_retry(&self, stderr: &str) -> bool; } -/// Pre-evaluate expression to catch errors early -fn pre_evaluate(_subcommand: &str, args: &[String]) -> Result { +/// Classifies what retry action should be taken based on nix stderr output. +#[derive(Debug, PartialEq, Eq)] +pub enum RetryAction { + AllowUnfree, + AllowInsecure, + AllowBroken, + None, +} + +impl RetryAction { + /// Returns `(env_var, reason)` for this retry action, + /// or `None` if no retry is needed. + fn env_override(&self) -> Option<(&str, &str)> { + match self { + Self::AllowUnfree => { + Some(("NIXPKGS_ALLOW_UNFREE", "has an unfree license")) + }, + Self::AllowInsecure => { + Some(("NIXPKGS_ALLOW_INSECURE", "has been marked as insecure")) + }, + Self::AllowBroken => { + Some(("NIXPKGS_ALLOW_BROKEN", "has been marked as broken")) + }, + Self::None => None, + } + } +} + +/// Extract the package/expression name from args (first non-flag argument). +fn package_name(args: &[String]) -> &str { + args + .iter() + .find(|a| !a.starts_with('-')) + .map(String::as_str) + .unwrap_or("") +} + +/// Print a retry message with consistent formatting. +/// Format: ` -> : , setting =1` +fn print_retry_msg(pkg: &str, reason: &str, env_var: &str) { + eprintln!( + " {} {}: {}, setting {}", + "->".yellow().bold(), + pkg.bold(), + reason, + format!("{env_var}=1").bold(), + ); +} + +/// Classify stderr into a retry action. +pub fn classify_retry_action(stderr: &str) -> RetryAction { + if stderr.contains("has an unfree license") && stderr.contains("refusing") { + RetryAction::AllowUnfree + } else if stderr.contains("has been marked as insecure") + && stderr.contains("refusing") + { + RetryAction::AllowInsecure + } else if stderr.contains("has been marked as broken") + && stderr.contains("refusing") + { + RetryAction::AllowBroken + } else { + RetryAction::None + } +} + +/// Returns true if stderr looks like a genuine hash mismatch error +/// (not just any mention of "hash" or "sha256"). +fn is_hash_mismatch_error(stderr: &str) -> bool { + stderr.contains("hash mismatch") + || (stderr.contains("specified:") && stderr.contains("got:")) +} + +/// Pre-evaluate expression to catch errors early. +/// +/// Returns a `RetryAction` if the evaluation fails with a retryable error +/// (unfree/insecure/broken), allowing the caller to retry with the right +/// environment variables without ever streaming the verbose nix error output. +fn pre_evaluate(args: &[String]) -> Result { // Find flake references or expressions to evaluate // Only take the first non-flag argument (the package/expression) let eval_arg = args.iter().find(|arg| !arg.starts_with('-')); let Some(eval_arg) = eval_arg else { - return Ok(true); // No expression to evaluate + return Ok(RetryAction::None); // No expression to evaluate }; - let eval_cmd = NixCommand::new("eval").arg(eval_arg).arg("--raw"); + let eval_cmd = NixCommand::new("eval") + .arg(eval_arg) + .print_build_logs(false); let output = eval_cmd.output()?; if output.status.success() { - return Ok(true); + return Ok(RetryAction::None); } let stderr = String::from_utf8_lossy(&output.stderr); - // If eval fails due to unfree/insecure/broken, don't fail pre-evaluation - // Let the main command handle it with retry logic - if stderr.contains("has an unfree license") - || stderr.contains("refusing to evaluate") - || stderr.contains("has been marked as insecure") - || stderr.contains("has been marked as broken") - { - return Ok(true); + // Classify whether this is a retryable error (unfree/insecure/broken) + let action = classify_retry_action(&stderr); + if action != RetryAction::None { + return Ok(action); } - // For other eval failures, fail early - Ok(false) + // For other eval failures, warn but let the actual command handle the + // error with full streaming output rather than halting here. + let err = EhError::PreEvalFailed { + expression: eval_arg.clone(), + stderr: stderr.trim().to_string(), + }; + eprintln!(" {} {}", "->".yellow().bold(), err,); + Ok(RetryAction::None) } pub fn validate_nix_args(args: &[String]) -> Result<()> { @@ -202,15 +354,28 @@ pub fn handle_nix_with_retry( interactive: bool, ) -> Result { validate_nix_args(args)?; - // Pre-evaluate for build commands to catch errors early - if !pre_evaluate(subcommand, args)? { - return Err(EhError::NixCommandFailed( - "Expression evaluation failed".to_string(), - )); + + // Pre-evaluate to detect retryable errors (unfree/insecure/broken) before + // running the actual command. This avoids streaming verbose nix error output + // only to retry immediately after. + let pkg = package_name(args); + let pre_eval_action = pre_evaluate(args)?; + if let Some((env_var, reason)) = pre_eval_action.env_override() { + print_retry_msg(pkg, reason, env_var); + let mut retry_cmd = NixCommand::new(subcommand) + .print_build_logs(true) + .args_ref(args) + .env(env_var, "1") + .impure(true); + if interactive { + retry_cmd = retry_cmd.interactive(true); + } + let retry_status = retry_cmd.run_with_logs(StdIoInterceptor)?; + return Ok(retry_status.code().unwrap_or(1)); } - // For run commands, try interactive first to avoid breaking terminal - if subcommand == "run" && interactive { + // For run/shell commands, try interactive mode now that pre-eval passed + if interactive { let status = NixCommand::new(subcommand) .print_build_logs(true) .interactive(true) @@ -221,18 +386,23 @@ pub fn handle_nix_with_retry( } } - // First, always capture output to check for errors that need retry + // Capture output to check for errors that need retry (hash mismatches etc.) let output_cmd = NixCommand::new(subcommand) .print_build_logs(true) .args_ref(args); let output = output_cmd.output()?; let stderr = String::from_utf8_lossy(&output.stderr); - // Check if we need to retry with special flags + // Check for hash mismatch errors if let Some(new_hash) = hash_extractor.extract_hash(&stderr) { - match fixer.fix_hash_in_files(&new_hash) { + let old_hash = hash_extractor.extract_old_hash(&stderr); + match fixer.fix_hash_in_files(old_hash.as_deref(), &new_hash) { Ok(true) => { - info!("{}", Paint::green("✔ Fixed hash mismatch, retrying...")); + eprintln!( + " {} {}: hash mismatch corrected in local files, rebuilding", + "->".green().bold(), + pkg.bold(), + ); let mut retry_cmd = NixCommand::new(subcommand) .print_build_logs(true) .args_ref(args); @@ -246,72 +416,34 @@ pub fn handle_nix_with_retry( // No files were fixed, continue with normal error handling }, Err(EhError::NoNixFilesFound) => { - warn!("No .nix files found to fix hash in"); + eprintln!( + " {} {}: hash mismatch detected but no .nix files found to update", + "->".yellow().bold(), + pkg.bold(), + ); // Continue with normal error handling }, Err(e) => { return Err(e); }, } - } else if stderr.contains("hash") || stderr.contains("sha256") { - // If there's a hash-related error but we couldn't extract it, that's a - // failure - return Err(EhError::HashExtractionFailed); + } else if is_hash_mismatch_error(&stderr) { + // There's a genuine hash mismatch but we couldn't extract the new hash + return Err(EhError::HashExtractionFailed { + stderr: stderr.to_string(), + }); } + // Fallback: check for unfree/insecure/broken in captured output + // (in case pre_evaluate didn't catch it, e.g. from a dependency) if classifier.should_retry(&stderr) { - if stderr.contains("has an unfree license") && stderr.contains("refusing") { - warn!( - "{}", - Paint::yellow( - "⚠ Unfree package detected, retrying with NIXPKGS_ALLOW_UNFREE=1..." - ) - ); + let action = classify_retry_action(&stderr); + if let Some((env_var, reason)) = action.env_override() { + print_retry_msg(pkg, reason, env_var); let mut retry_cmd = NixCommand::new(subcommand) .print_build_logs(true) .args_ref(args) - .env("NIXPKGS_ALLOW_UNFREE", "1") - .impure(true); - if interactive { - retry_cmd = retry_cmd.interactive(true); - } - let retry_status = retry_cmd.run_with_logs(StdIoInterceptor)?; - return Ok(retry_status.code().unwrap_or(1)); - } - if stderr.contains("has been marked as insecure") - && stderr.contains("refusing") - { - warn!( - "{}", - Paint::yellow( - "⚠ Insecure package detected, retrying with \ - NIXPKGS_ALLOW_INSECURE=1..." - ) - ); - let mut retry_cmd = NixCommand::new(subcommand) - .print_build_logs(true) - .args_ref(args) - .env("NIXPKGS_ALLOW_INSECURE", "1") - .impure(true); - if interactive { - retry_cmd = retry_cmd.interactive(true); - } - let retry_status = retry_cmd.run_with_logs(StdIoInterceptor)?; - return Ok(retry_status.code().unwrap_or(1)); - } - if stderr.contains("has been marked as broken") - && stderr.contains("refusing") - { - warn!( - "{}", - Paint::yellow( - "⚠ Broken package detected, retrying with NIXPKGS_ALLOW_BROKEN=1..." - ) - ); - let mut retry_cmd = NixCommand::new(subcommand) - .print_build_logs(true) - .args_ref(args) - .env("NIXPKGS_ALLOW_BROKEN", "1") + .env(env_var, "1") .impure(true); if interactive { retry_cmd = retry_cmd.interactive(true); @@ -330,22 +462,23 @@ pub fn handle_nix_with_retry( std::io::stderr() .write_all(&output.stderr) .map_err(EhError::Io)?; - Err(EhError::ProcessExit { - code: output.status.code().unwrap_or(1), - }) + + match output.status.code() { + Some(code) => Err(EhError::ProcessExit { code }), + // No exit code means the process was killed by a signal + None => { + Err(EhError::NixCommandFailed { + command: subcommand.to_string(), + }) + }, + } } pub struct DefaultNixErrorClassifier; impl NixErrorClassifier for DefaultNixErrorClassifier { fn should_retry(&self, stderr: &str) -> bool { - RegexHashExtractor.extract_hash(stderr).is_some() - || (stderr.contains("has an unfree license") - && stderr.contains("refusing")) - || (stderr.contains("has been marked as insecure") - && stderr.contains("refusing")) - || (stderr.contains("has been marked as broken") - && stderr.contains("refusing")) + classify_retry_action(stderr) != RetryAction::None } } @@ -379,7 +512,7 @@ mod tests { let fixer = DefaultNixFileFixer; let result = fixer - .fix_hash_in_file(file_path, "sha256-newhash999") + .fix_hash_in_file(file_path, None, "sha256-newhash999") .unwrap(); assert!(result, "Hash replacement should return true"); @@ -413,7 +546,7 @@ mod tests { // Test hash replacement let fixer = DefaultNixFileFixer; let result = fixer - .fix_hash_in_file(&file_path, "sha256-newhash999") + .fix_hash_in_file(&file_path, None, "sha256-newhash999") .unwrap(); assert!( @@ -448,7 +581,7 @@ mod tests { // Test that streaming can handle large files without memory issues let fixer = DefaultNixFileFixer; let result = fixer - .fix_hash_in_file(file_path, "sha256-newhash999") + .fix_hash_in_file(file_path, None, "sha256-newhash999") .unwrap(); assert!(result, "Hash replacement should work for large files"); @@ -483,7 +616,9 @@ mod tests { // Test hash replacement let fixer = DefaultNixFileFixer; - let result = fixer.fix_hash_in_file(file_path, "sha256-newhash").unwrap(); + let result = fixer + .fix_hash_in_file(file_path, None, "sha256-newhash") + .unwrap(); assert!(result, "Hash replacement should succeed"); @@ -538,4 +673,233 @@ mod tests { safe_args ); } + + #[test] + fn test_input_validation_empty_args() { + let result = validate_nix_args(&[]); + assert!(result.is_ok(), "Empty args should be accepted"); + } + + #[test] + fn test_hash_extraction_got_pattern() { + let stderr = "hash mismatch in fixed-output derivation\n specified: \ + sha256-AAAA\n got: \ + sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="; + let extractor = RegexHashExtractor; + let hash = extractor.extract_hash(stderr); + assert!(hash.is_some()); + assert!(hash.unwrap().starts_with("sha256-")); + } + + #[test] + fn test_hash_extraction_actual_pattern() { + let stderr = "hash mismatch\n actual: \ + sha256-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC="; + let extractor = RegexHashExtractor; + let hash = extractor.extract_hash(stderr); + assert!(hash.is_some()); + assert!(hash.unwrap().starts_with("sha256-")); + } + + #[test] + fn test_hash_extraction_have_pattern() { + let stderr = "hash mismatch\n have: \ + sha256-DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD="; + let extractor = RegexHashExtractor; + let hash = extractor.extract_hash(stderr); + assert!(hash.is_some()); + assert!(hash.unwrap().starts_with("sha256-")); + } + + #[test] + fn test_hash_extraction_no_match() { + let stderr = "error: some other nix error without hashes"; + let extractor = RegexHashExtractor; + assert!(extractor.extract_hash(stderr).is_none()); + } + + #[test] + fn test_hash_extraction_partial_match() { + // Contains "got:" but no sha256 hash + let stderr = "got: some-other-value"; + let extractor = RegexHashExtractor; + assert!(extractor.extract_hash(stderr).is_none()); + } + + #[test] + fn test_false_positive_hash_detection() { + // Normal nix output mentioning "hash" or "sha256" without being a mismatch + let cases = [ + "evaluating attribute 'sha256' of derivation 'hello'", + "building '/nix/store/hash-something.drv'", + "copying path '/nix/store/sha256-abcdef-hello'", + "this derivation has a hash attribute set", + ]; + for stderr in &cases { + assert!( + !is_hash_mismatch_error(stderr), + "Should not detect hash mismatch in: {stderr}" + ); + } + } + + #[test] + fn test_genuine_hash_mismatch_detection() { + assert!(is_hash_mismatch_error( + "hash mismatch in fixed-output derivation" + )); + assert!(is_hash_mismatch_error( + "specified: sha256-AAAA\n got: sha256-BBBB" + )); + } + + #[test] + fn test_classify_retry_action_unfree() { + let stderr = + "error: Package 'foo' has an unfree license, refusing to evaluate."; + assert_eq!(classify_retry_action(stderr), RetryAction::AllowUnfree); + } + + #[test] + fn test_classify_retry_action_insecure() { + let stderr = + "error: Package 'bar' has been marked as insecure, refusing to evaluate."; + assert_eq!(classify_retry_action(stderr), RetryAction::AllowInsecure); + } + + #[test] + fn test_classify_retry_action_broken() { + let stderr = + "error: Package 'baz' has been marked as broken, refusing to evaluate."; + assert_eq!(classify_retry_action(stderr), RetryAction::AllowBroken); + } + + #[test] + fn test_classify_retry_action_none() { + let stderr = "error: attribute 'nonexistent' not found"; + assert_eq!(classify_retry_action(stderr), RetryAction::None); + } + + #[test] + fn test_retry_action_env_overrides() { + let (var, reason) = RetryAction::AllowUnfree.env_override().unwrap(); + assert_eq!(var, "NIXPKGS_ALLOW_UNFREE"); + assert!(reason.contains("unfree")); + + let (var, reason) = RetryAction::AllowInsecure.env_override().unwrap(); + assert_eq!(var, "NIXPKGS_ALLOW_INSECURE"); + assert!(reason.contains("insecure")); + + let (var, reason) = RetryAction::AllowBroken.env_override().unwrap(); + assert_eq!(var, "NIXPKGS_ALLOW_BROKEN"); + assert!(reason.contains("broken")); + + assert_eq!(RetryAction::None.env_override(), None); + } + + #[test] + fn test_classifier_should_retry() { + let classifier = DefaultNixErrorClassifier; + assert!( + classifier.should_retry( + "Package 'x' has an unfree license, refusing to evaluate" + ) + ); + assert!(classifier.should_retry( + "Package 'x' has been marked as insecure, refusing to evaluate" + )); + assert!(classifier.should_retry( + "Package 'x' has been marked as broken, refusing to evaluate" + )); + assert!(!classifier.should_retry("error: attribute not found")); + } + + #[test] + fn test_old_hash_extraction() { + let stderr = + "hash mismatch in fixed-output derivation\n specified: \ + sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n got: \ + sha256-BBBB="; + let extractor = RegexHashExtractor; + let old = extractor.extract_old_hash(stderr); + assert!(old.is_some()); + assert_eq!( + old.unwrap(), + "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + ); + } + + #[test] + fn test_old_hash_extraction_missing() { + let stderr = "hash mismatch\n got: sha256-BBBB="; + let extractor = RegexHashExtractor; + assert!(extractor.extract_old_hash(stderr).is_none()); + } + + #[test] + fn test_targeted_hash_replacement_only_matching() { + let temp_file = NamedTempFile::new().unwrap(); + let file_path = temp_file.path(); + + // File with two derivations, each with a different hash + let test_content = r#"{ pkgs }: +{ + a = pkgs.fetchurl { + url = "https://example.com/a.tar.gz"; + hash = "sha256-AAAA"; + }; + b = pkgs.fetchurl { + url = "https://example.com/b.tar.gz"; + hash = "sha256-BBBB"; + }; +}"#; + + let mut file = std::fs::File::create(file_path).unwrap(); + file.write_all(test_content.as_bytes()).unwrap(); + file.flush().unwrap(); + + let fixer = DefaultNixFileFixer; + // Only replace the hash matching "sha256-AAAA" + let result = fixer + .fix_hash_in_file(file_path, Some("sha256-AAAA"), "sha256-NEWW") + .unwrap(); + + assert!(result, "Targeted replacement should return true"); + + let updated = std::fs::read_to_string(file_path).unwrap(); + assert!( + updated.contains(r#"hash = "sha256-NEWW""#), + "Matching hash should be replaced" + ); + assert!( + updated.contains(r#"hash = "sha256-BBBB""#), + "Non-matching hash should be untouched" + ); + } + + #[test] + fn test_targeted_hash_replacement_no_match() { + let temp_file = NamedTempFile::new().unwrap(); + let file_path = temp_file.path(); + + let test_content = r#"{ hash = "sha256-XXXX"; }"#; + + let mut file = std::fs::File::create(file_path).unwrap(); + file.write_all(test_content.as_bytes()).unwrap(); + file.flush().unwrap(); + + let fixer = DefaultNixFileFixer; + // old_hash doesn't match anything in the file + let result = fixer + .fix_hash_in_file(file_path, Some("sha256-NOMATCH"), "sha256-NEWW") + .unwrap(); + + assert!(!result, "Should return false when old hash doesn't match"); + + let updated = std::fs::read_to_string(file_path).unwrap(); + assert!( + updated.contains("sha256-XXXX"), + "Original hash should be untouched" + ); + } } From 9b632788c2524b5189b8ac7260ec4e45dbe19b36 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 30 Jan 2026 18:30:26 +0300 Subject: [PATCH 05/10] eh: rewrite command exec with thread-based pipe reading Signed-off-by: NotAShelf Change-Id: Id0e34e109a6423820e24676968e08dc66a6a6964 --- eh/src/command.rs | 241 +++++++++++++++++++++++++++++++--------------- 1 file changed, 164 insertions(+), 77 deletions(-) diff --git a/eh/src/command.rs b/eh/src/command.rs index 7eace98..a6ff260 100644 --- a/eh/src/command.rs +++ b/eh/src/command.rs @@ -1,7 +1,8 @@ use std::{ - collections::VecDeque, io::{self, Read, Write}, process::{Command, ExitStatus, Output, Stdio}, + sync::mpsc, + thread, time::{Duration, Instant}, }; @@ -31,6 +32,40 @@ const DEFAULT_BUFFER_SIZE: usize = 4096; /// Default timeout for command execution const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300); // 5 minutes +enum PipeEvent { + Stdout(Vec), + Stderr(Vec), + Error(io::Error), +} + +/// Drain a pipe reader, sending chunks through the channel. +fn read_pipe( + mut reader: R, + tx: mpsc::Sender, + is_stderr: bool, +) { + let mut buf = [0u8; DEFAULT_BUFFER_SIZE]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + let event = if is_stderr { + PipeEvent::Stderr(buf[..n].to_vec()) + } else { + PipeEvent::Stdout(buf[..n].to_vec()) + }; + if tx.send(event).is_err() { + break; + } + }, + Err(e) => { + let _ = tx.send(PipeEvent::Error(e)); + break; + }, + } + } +} + /// Builder and executor for Nix commands. pub struct NixCommand { subcommand: String, @@ -58,16 +93,6 @@ impl NixCommand { self } - #[allow(dead_code, reason = "FIXME")] - pub fn args(mut self, args: I) -> Self - where - I: IntoIterator, - S: Into, - { - self.args.extend(args.into_iter().map(Into::into)); - self - } - #[must_use] pub fn args_ref(mut self, args: &[String]) -> Self { self.args.extend(args.iter().cloned()); @@ -101,11 +126,9 @@ impl NixCommand { self } - /// Run the command, streaming output to the provided interceptor. - pub fn run_with_logs( - &self, - mut interceptor: I, - ) -> Result { + /// Build the underlying `std::process::Command` with all configured + /// arguments, environment variables, and flags. + fn build_command(&self) -> Command { let mut cmd = Command::new("nix"); cmd.arg(&self.subcommand); @@ -121,6 +144,18 @@ impl NixCommand { cmd.env(k, v); } cmd.args(&self.args); + cmd + } + + /// Run the command, streaming output to the provided interceptor. + /// + /// Stdout and stderr are read concurrently using background threads + /// so that neither pipe blocks the other. + pub fn run_with_logs( + &self, + mut interceptor: I, + ) -> Result { + let mut cmd = self.build_command(); if self.interactive { cmd.stdout(Stdio::inherit()); @@ -133,100 +168,152 @@ impl NixCommand { cmd.stderr(Stdio::piped()); let mut child = cmd.spawn()?; - let child_stdout = child.stdout.take().ok_or_else(|| { + let stdout = child.stdout.take().ok_or_else(|| { EhError::CommandFailed { command: format!("nix {}", self.subcommand), } })?; - let child_stderr = child.stderr.take().ok_or_else(|| { + let stderr = child.stderr.take().ok_or_else(|| { EhError::CommandFailed { command: format!("nix {}", self.subcommand), } })?; - let mut stdout = child_stdout; - let mut stderr = child_stderr; - let mut out_buf = [0u8; DEFAULT_BUFFER_SIZE]; - let mut err_buf = [0u8; DEFAULT_BUFFER_SIZE]; + let (tx, rx) = mpsc::channel(); + + let tx_out = tx.clone(); + let stdout_thread = thread::spawn(move || read_pipe(stdout, tx_out, false)); + + let tx_err = tx; + let stderr_thread = thread::spawn(move || read_pipe(stderr, tx_err, true)); - let mut out_queue = VecDeque::new(); - let mut err_queue = VecDeque::new(); let start_time = Instant::now(); loop { - let mut did_something = false; - - // Check for timeout if start_time.elapsed() > DEFAULT_TIMEOUT { let _ = child.kill(); - return Err(EhError::CommandFailed { - command: format!("nix {} timed out after 5 minutes", self.subcommand), + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + let _ = child.wait(); + return Err(EhError::Timeout { + command: format!("nix {}", self.subcommand), + duration: DEFAULT_TIMEOUT, }); } - match stdout.read(&mut out_buf) { - Ok(0) => {}, - Ok(n) => { - interceptor.on_stdout(&out_buf[..n]); - out_queue.push_back(Vec::from(&out_buf[..n])); - did_something = true; + match rx.recv_timeout(Duration::from_millis(100)) { + Ok(PipeEvent::Stdout(data)) => interceptor.on_stdout(&data), + Ok(PipeEvent::Stderr(data)) => interceptor.on_stderr(&data), + Ok(PipeEvent::Error(e)) => { + let _ = child.kill(); + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + let _ = child.wait(); + return Err(EhError::Io(e)); }, - Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {}, - Err(e) => return Err(EhError::Io(e)), - } - - match stderr.read(&mut err_buf) { - Ok(0) => {}, - Ok(n) => { - interceptor.on_stderr(&err_buf[..n]); - err_queue.push_back(Vec::from(&err_buf[..n])); - did_something = true; - }, - Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {}, - Err(e) => return Err(EhError::Io(e)), - } - - if !did_something && child.try_wait()?.is_some() { - break; - } - - // Prevent busy waiting when no data is available - if !did_something { - std::thread::sleep(Duration::from_millis(10)); + Err(mpsc::RecvTimeoutError::Timeout) => {}, + // All senders dropped — both reader threads finished + Err(mpsc::RecvTimeoutError::Disconnected) => break, } } + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + let status = child.wait()?; Ok(status) } - /// Run the command and capture all output. + /// Run the command and capture all output (with timeout). pub fn output(&self) -> Result { - let mut cmd = Command::new("nix"); - cmd.arg(&self.subcommand); - - if self.print_build_logs - && !self.args.iter().any(|a| a == "--no-build-output") - { - cmd.arg("--print-build-logs"); - } - if self.impure { - cmd.arg("--impure"); - } - for (k, v) in &self.env { - cmd.env(k, v); - } - cmd.args(&self.args); + let mut cmd = self.build_command(); if self.interactive { cmd.stdout(Stdio::inherit()); cmd.stderr(Stdio::inherit()); cmd.stdin(Stdio::inherit()); - } else { - cmd.stdout(Stdio::piped()); - cmd.stderr(Stdio::piped()); + return Ok(cmd.output()?); } - Ok(cmd.output()?) + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let mut child = cmd.spawn()?; + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + + let (tx, rx) = mpsc::channel(); + + let tx_out = tx.clone(); + let stdout_thread = thread::spawn(move || { + let mut buf = Vec::new(); + if let Some(mut r) = stdout { + let _ = r.read_to_end(&mut buf); + } + let _ = tx_out.send((false, buf)); + }); + + let tx_err = tx; + let stderr_thread = thread::spawn(move || { + let mut buf = Vec::new(); + if let Some(mut r) = stderr { + let _ = r.read_to_end(&mut buf); + } + let _ = tx_err.send((true, buf)); + }); + + let start_time = Instant::now(); + let mut stdout_buf = Vec::new(); + let mut stderr_buf = Vec::new(); + let mut received = 0; + + while received < 2 { + let remaining = DEFAULT_TIMEOUT + .checked_sub(start_time.elapsed()) + .unwrap_or(Duration::ZERO); + + if remaining.is_zero() { + let _ = child.kill(); + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + let _ = child.wait(); + return Err(EhError::Timeout { + command: format!("nix {}", self.subcommand), + duration: DEFAULT_TIMEOUT, + }); + } + + match rx.recv_timeout(remaining) { + Ok((true, buf)) => { + stderr_buf = buf; + received += 1; + }, + Ok((false, buf)) => { + stdout_buf = buf; + received += 1; + }, + Err(mpsc::RecvTimeoutError::Timeout) => { + let _ = child.kill(); + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + let _ = child.wait(); + return Err(EhError::Timeout { + command: format!("nix {}", self.subcommand), + duration: DEFAULT_TIMEOUT, + }); + }, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + + let status = child.wait()?; + Ok(Output { + status, + stdout: stdout_buf, + stderr: stderr_buf, + }) } } From be3226bc3aa38a12f9f1d2494898433a2b429600 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 30 Jan 2026 18:31:09 +0300 Subject: [PATCH 06/10] eh: improve error handling Signed-off-by: NotAShelf Change-Id: I13d7d14ed4de1ee472aae9fb4ec7ffe46a6a6964 --- eh/src/error.rs | 203 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 196 insertions(+), 7 deletions(-) diff --git a/eh/src/error.rs b/eh/src/error.rs index 173f047..403e2d1 100644 --- a/eh/src/error.rs +++ b/eh/src/error.rs @@ -1,9 +1,11 @@ +use std::time::Duration; + use thiserror::Error; #[derive(Error, Debug)] pub enum EhError { - #[error("Nix command failed: {0}")] - NixCommandFailed(String), + #[error("Nix command 'nix {command}' failed")] + NixCommandFailed { command: String }, #[error("IO error: {0}")] Io(#[from] std::io::Error), @@ -14,10 +16,10 @@ pub enum EhError { #[error("UTF-8 conversion error: {0}")] Utf8(#[from] std::string::FromUtf8Error), - #[error("Hash extraction failed")] - HashExtractionFailed, + #[error("Hash extraction failed: could not parse hash from nix output")] + HashExtractionFailed { stderr: String }, - #[error("No Nix files found")] + #[error("No Nix files found in the current directory")] NoNixFilesFound, #[error("Failed to fix hash in file: {path}")] @@ -29,6 +31,18 @@ pub enum EhError { #[error("Command execution failed: {command}")] CommandFailed { command: String }, + #[error("Command '{command}' timed out after {} seconds", duration.as_secs())] + Timeout { + command: String, + duration: Duration, + }, + + #[error("Pre-evaluation of '{expression}' failed: {stderr}")] + PreEvalFailed { + expression: String, + stderr: String, + }, + #[error("Invalid input: {input} - {reason}")] InvalidInput { input: String, reason: String }, } @@ -40,15 +54,190 @@ impl EhError { pub const fn exit_code(&self) -> i32 { match self { Self::ProcessExit { code } => *code, - Self::NixCommandFailed(_) => 2, + Self::NixCommandFailed { .. } => 2, Self::CommandFailed { .. } => 3, - Self::HashExtractionFailed => 4, + Self::HashExtractionFailed { .. } => 4, Self::NoNixFilesFound => 5, Self::HashFixFailed { .. } => 6, Self::InvalidInput { .. } => 7, Self::Io(_) => 8, Self::Regex(_) => 9, Self::Utf8(_) => 10, + Self::Timeout { .. } => 11, + Self::PreEvalFailed { .. } => 12, + } + } + + #[must_use] + pub fn hint(&self) -> Option<&str> { + match self { + Self::NixCommandFailed { .. } => { + Some("Run with --show-trace for more details from nix") + }, + Self::PreEvalFailed { .. } => { + Some( + "Check that the expression exists in the flake and is spelled \ + correctly", + ) + }, + Self::HashExtractionFailed { .. } => { + Some( + "The nix output contained a hash mismatch but the hash could not be \ + parsed", + ) + }, + Self::NoNixFilesFound => { + Some("Run this command from a directory containing .nix files") + }, + Self::Timeout { .. } => { + Some( + "The command took too long; try a faster network or a smaller \ + derivation", + ) + }, + Self::InvalidInput { .. } => { + Some("Avoid shell metacharacters in nix arguments") + }, + Self::Io(_) + | Self::Regex(_) + | Self::Utf8(_) + | Self::HashFixFailed { .. } + | Self::ProcessExit { .. } + | Self::CommandFailed { .. } => None, } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_exit_codes() { + assert_eq!( + EhError::NixCommandFailed { + command: "build".into(), + } + .exit_code(), + 2 + ); + assert_eq!( + EhError::CommandFailed { + command: "x".into(), + } + .exit_code(), + 3 + ); + assert_eq!( + EhError::HashExtractionFailed { + stderr: String::new(), + } + .exit_code(), + 4 + ); + assert_eq!(EhError::NoNixFilesFound.exit_code(), 5); + assert_eq!(EhError::HashFixFailed { path: "x".into() }.exit_code(), 6); + assert_eq!( + EhError::InvalidInput { + input: "x".into(), + reason: "y".into(), + } + .exit_code(), + 7 + ); + assert_eq!( + EhError::Timeout { + command: "build".into(), + duration: Duration::from_secs(300), + } + .exit_code(), + 11 + ); + assert_eq!( + EhError::PreEvalFailed { + expression: "x".into(), + stderr: "y".into(), + } + .exit_code(), + 12 + ); + assert_eq!(EhError::ProcessExit { code: 42 }.exit_code(), 42); + } + + #[test] + fn test_display_messages() { + let err = EhError::Timeout { + command: "build".into(), + duration: Duration::from_secs(300), + }; + assert_eq!( + err.to_string(), + "Command 'build' timed out after 300 seconds" + ); + + let err = EhError::PreEvalFailed { + expression: "nixpkgs#hello".into(), + stderr: "attribute not found".into(), + }; + assert!(err.to_string().contains("nixpkgs#hello")); + assert!(err.to_string().contains("attribute not found")); + + let err = EhError::HashExtractionFailed { + stderr: "some output".into(), + }; + assert!(err.to_string().contains("could not parse hash")); + } + + #[test] + fn test_hints() { + assert!( + EhError::PreEvalFailed { + expression: "x".into(), + stderr: "y".into(), + } + .hint() + .is_some() + ); + assert!( + EhError::HashExtractionFailed { + stderr: String::new(), + } + .hint() + .is_some() + ); + assert!(EhError::NoNixFilesFound.hint().is_some()); + assert!( + EhError::Timeout { + command: "x".into(), + duration: Duration::from_secs(1), + } + .hint() + .is_some() + ); + assert!( + EhError::InvalidInput { + input: "x".into(), + reason: "y".into(), + } + .hint() + .is_some() + ); + // Variants with hints + assert!( + EhError::NixCommandFailed { + command: "build".into(), + } + .hint() + .is_some() + ); + // Variants without hints + assert!( + EhError::CommandFailed { + command: "x".into(), + } + .hint() + .is_none() + ); + assert!(EhError::ProcessExit { code: 1 }.hint().is_none()); + } +} From e6d1b90b97ab9a333febde7470b09c1acddb37a6 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 30 Jan 2026 18:31:19 +0300 Subject: [PATCH 07/10] eh: improve error and warning glyphs; move logger to new crate Signed-off-by: NotAShelf Change-Id: I563b37a9f38f8dcec6dda7693ae45e826a6a6964 --- Cargo.lock | 8 +++++++ Cargo.toml | 3 ++- crates/eh-log/Cargo.toml | 10 ++++++++ crates/eh-log/src/lib.rs | 31 +++++++++++++++++++++++++ eh/Cargo.toml | 1 + eh/src/error.rs | 49 ++++++++++++++++------------------------ eh/src/main.rs | 20 +++++++++++----- eh/src/util.rs | 41 +++++++++++++++++---------------- 8 files changed, 107 insertions(+), 56 deletions(-) create mode 100644 crates/eh-log/Cargo.toml create mode 100644 crates/eh-log/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index d68884d..2beabba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -81,6 +81,7 @@ name = "eh" version = "0.1.3" dependencies = [ "clap", + "eh-log", "regex", "tempfile", "thiserror", @@ -88,6 +89,13 @@ dependencies = [ "yansi", ] +[[package]] +name = "eh-log" +version = "0.1.3" +dependencies = [ + "yansi", +] + [[package]] name = "errno" version = "0.3.14" diff --git a/Cargo.toml b/Cargo.toml index 59825fb..7b3f31b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,8 @@ thiserror = "2.0.17" walkdir = "2.5.0" yansi = "1.0.1" -eh = { path = "./eh" } +eh = { path = "./eh" } +eh-log = { path = "./crates/eh-log" } [profile.release] codegen-units = 1 diff --git a/crates/eh-log/Cargo.toml b/crates/eh-log/Cargo.toml new file mode 100644 index 0000000..e3ef1f4 --- /dev/null +++ b/crates/eh-log/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "eh-log" +description = "Styled logging for eh" +version.workspace = true +edition.workspace = true +authors.workspace = true +rust-version.workspace = true + +[dependencies] +yansi.workspace = true diff --git a/crates/eh-log/src/lib.rs b/crates/eh-log/src/lib.rs new file mode 100644 index 0000000..365deff --- /dev/null +++ b/crates/eh-log/src/lib.rs @@ -0,0 +1,31 @@ +use std::fmt; + +use yansi::Paint; + +pub fn info(args: fmt::Arguments) { + eprintln!(" {} {args}", "->".green().bold()); +} + +pub fn warn(args: fmt::Arguments) { + eprintln!(" {} {args}", "->".yellow().bold()); +} + +pub fn error(args: fmt::Arguments) { + eprintln!(" {} {args}", "!".red().bold()); +} + +pub fn hint(args: fmt::Arguments) { + eprintln!(" {} {args}", "~".yellow().dim()); +} + +#[macro_export] +macro_rules! log_info { ($($t:tt)*) => { $crate::info(format_args!($($t)*)) } } + +#[macro_export] +macro_rules! log_warn { ($($t:tt)*) => { $crate::warn(format_args!($($t)*)) } } + +#[macro_export] +macro_rules! log_error { ($($t:tt)*) => { $crate::error(format_args!($($t)*)) } } + +#[macro_export] +macro_rules! log_hint { ($($t:tt)*) => { $crate::hint(format_args!($($t)*)) } } diff --git a/eh/Cargo.toml b/eh/Cargo.toml index f194a62..d221297 100644 --- a/eh/Cargo.toml +++ b/eh/Cargo.toml @@ -12,6 +12,7 @@ name = "eh" [dependencies] clap.workspace = true +eh-log.workspace = true regex.workspace = true tempfile.workspace = true thiserror.workspace = true diff --git a/eh/src/error.rs b/eh/src/error.rs index 403e2d1..909453a 100644 --- a/eh/src/error.rs +++ b/eh/src/error.rs @@ -4,46 +4,46 @@ use thiserror::Error; #[derive(Error, Debug)] pub enum EhError { - #[error("Nix command 'nix {command}' failed")] + #[error("nix {command} failed")] NixCommandFailed { command: String }, - #[error("IO error: {0}")] + #[error("io: {0}")] Io(#[from] std::io::Error), - #[error("Regex error: {0}")] + #[error("regex: {0}")] Regex(#[from] regex::Error), - #[error("UTF-8 conversion error: {0}")] + #[error("utf-8 conversion: {0}")] Utf8(#[from] std::string::FromUtf8Error), - #[error("Hash extraction failed: could not parse hash from nix output")] + #[error("could not extract hash from nix output")] HashExtractionFailed { stderr: String }, - #[error("No Nix files found in the current directory")] + #[error("no .nix files found in the current directory")] NoNixFilesFound, - #[error("Failed to fix hash in file: {path}")] + #[error("could not update hash in {path}")] HashFixFailed { path: String }, - #[error("Process exited with code: {code}")] + #[error("process exited with code {code}")] ProcessExit { code: i32 }, - #[error("Command execution failed: {command}")] + #[error("command '{command}' failed")] CommandFailed { command: String }, - #[error("Command '{command}' timed out after {} seconds", duration.as_secs())] + #[error("nix {command} timed out after {} seconds", duration.as_secs())] Timeout { command: String, duration: Duration, }, - #[error("Pre-evaluation of '{expression}' failed: {stderr}")] + #[error("'{expression}' failed to evaluate: {stderr}")] PreEvalFailed { expression: String, stderr: String, }, - #[error("Invalid input: {input} - {reason}")] + #[error("invalid input '{input}': {reason}")] InvalidInput { input: String, reason: String }, } @@ -72,31 +72,25 @@ impl EhError { pub fn hint(&self) -> Option<&str> { match self { Self::NixCommandFailed { .. } => { - Some("Run with --show-trace for more details from nix") + Some("run with --show-trace for more details") }, Self::PreEvalFailed { .. } => { - Some( - "Check that the expression exists in the flake and is spelled \ - correctly", - ) + Some("check that the expression exists and is spelled correctly") }, Self::HashExtractionFailed { .. } => { - Some( - "The nix output contained a hash mismatch but the hash could not be \ - parsed", - ) + Some("nix reported a hash mismatch but the hash could not be parsed") }, Self::NoNixFilesFound => { - Some("Run this command from a directory containing .nix files") + Some("run this command from a directory containing .nix files") }, Self::Timeout { .. } => { Some( - "The command took too long; try a faster network or a smaller \ + "the command took too long; try a faster network or a smaller \ derivation", ) }, Self::InvalidInput { .. } => { - Some("Avoid shell metacharacters in nix arguments") + Some("avoid shell metacharacters in nix arguments") }, Self::Io(_) | Self::Regex(_) @@ -170,10 +164,7 @@ mod tests { command: "build".into(), duration: Duration::from_secs(300), }; - assert_eq!( - err.to_string(), - "Command 'build' timed out after 300 seconds" - ); + assert_eq!(err.to_string(), "nix build timed out after 300 seconds"); let err = EhError::PreEvalFailed { expression: "nixpkgs#hello".into(), @@ -185,7 +176,7 @@ mod tests { let err = EhError::HashExtractionFailed { stderr: "some output".into(), }; - assert!(err.to_string().contains("could not parse hash")); + assert!(err.to_string().contains("could not extract hash")); } #[test] diff --git a/eh/src/main.rs b/eh/src/main.rs index e090375..8cc4d11 100644 --- a/eh/src/main.rs +++ b/eh/src/main.rs @@ -2,6 +2,7 @@ use std::{env, path::Path}; use eh::{Cli, Command, CommandFactory, Parser}; use error::Result; +use yansi::Paint; mod build; mod command; @@ -16,9 +17,9 @@ fn main() { match result { Ok(code) => std::process::exit(code), Err(e) => { - eprintln!("Error: {e}"); + eh_log::log_error!("{e}"); if let Some(hint) = e.hint() { - eprintln!("Hint: {hint}"); + eh_log::log_hint!("{hint}"); } std::process::exit(e.exit_code()); }, @@ -41,14 +42,21 @@ fn dispatch_multicall( // Handle --help/-h/--version before forwarding to nix if rest.iter().any(|a| a == "--help" || a == "-h") { - eprintln!("{app_name}: shorthand for 'eh {subcommand}'"); - eprintln!("Usage: {app_name} [args...]"); - eprintln!("All arguments are forwarded to 'nix {subcommand}'."); + eprintln!( + "{}: shorthand for '{}'", + app_name.bold(), + format!("eh {subcommand}").bold() + ); + eprintln!(" {} {app_name} [args...]", "usage:".green().bold()); + eprintln!( + " All arguments are forwarded to '{}'.", + format!("nix {subcommand}").dim() + ); return Some(Ok(0)); } if rest.iter().any(|a| a == "--version") { - eprintln!("{app_name} (eh {})", env!("CARGO_PKG_VERSION")); + eprintln!("{} (eh {})", app_name.bold(), env!("CARGO_PKG_VERSION")); return Some(Ok(0)); } diff --git a/eh/src/util.rs b/eh/src/util.rs index 78671a9..10227de 100644 --- a/eh/src/util.rs +++ b/eh/src/util.rs @@ -4,6 +4,7 @@ use std::{ sync::LazyLock, }; +use eh_log::{log_info, log_warn}; use regex::Regex; use tempfile::NamedTempFile; use walkdir::WalkDir; @@ -92,7 +93,7 @@ impl NixFileFixer for DefaultNixFileFixer { let mut fixed = false; for file_path in nix_files { if self.fix_hash_in_file(&file_path, old_hash, new_hash)? { - println!("Updated hash in {}", file_path.display()); + log_info!("updated hash in {}", file_path.display().bold()); fixed = true; } } @@ -251,12 +252,11 @@ fn package_name(args: &[String]) -> &str { /// Print a retry message with consistent formatting. /// Format: ` -> : , setting =1` fn print_retry_msg(pkg: &str, reason: &str, env_var: &str) { - eprintln!( - " {} {}: {}, setting {}", - "->".yellow().bold(), + log_warn!( + "{}: {}, setting {}", pkg.bold(), reason, - format!("{env_var}=1").bold(), + format!("{env_var}=1").bold() ); } @@ -316,14 +316,17 @@ fn pre_evaluate(args: &[String]) -> Result { return Ok(action); } - // For other eval failures, warn but let the actual command handle the - // error with full streaming output rather than halting here. - let err = EhError::PreEvalFailed { + // Non-retryable eval failure — fail fast with a clear message + // rather than running the full command and showing the same error again. + let stderr_clean = stderr + .trim() + .strip_prefix("error:") + .unwrap_or(stderr.trim()) + .trim(); + Err(EhError::PreEvalFailed { expression: eval_arg.clone(), - stderr: stderr.trim().to_string(), - }; - eprintln!(" {} {}", "->".yellow().bold(), err,); - Ok(RetryAction::None) + stderr: stderr_clean.to_string(), + }) } pub fn validate_nix_args(args: &[String]) -> Result<()> { @@ -398,10 +401,9 @@ pub fn handle_nix_with_retry( let old_hash = hash_extractor.extract_old_hash(&stderr); match fixer.fix_hash_in_files(old_hash.as_deref(), &new_hash) { Ok(true) => { - eprintln!( - " {} {}: hash mismatch corrected in local files, rebuilding", - "->".green().bold(), - pkg.bold(), + log_info!( + "{}: hash mismatch corrected in local files, rebuilding", + pkg.bold() ); let mut retry_cmd = NixCommand::new(subcommand) .print_build_logs(true) @@ -416,10 +418,9 @@ pub fn handle_nix_with_retry( // No files were fixed, continue with normal error handling }, Err(EhError::NoNixFilesFound) => { - eprintln!( - " {} {}: hash mismatch detected but no .nix files found to update", - "->".yellow().bold(), - pkg.bold(), + log_warn!( + "{}: hash mismatch detected but no .nix files found to update", + pkg.bold() ); // Continue with normal error handling }, From 045d1632cb48278cb7b3151f50e5807b6cf4c46d Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 30 Jan 2026 18:59:38 +0300 Subject: [PATCH 08/10] nix: bump flake inputs Signed-off-by: NotAShelf Change-Id: Iec3db09400f92453b5ffb52e364852936a6a6964 --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 6012087..6f6bfa6 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1762844143, - "narHash": "sha256-SlybxLZ1/e4T2lb1czEtWVzDCVSTvk9WLwGhmxFmBxI=", + "lastModified": 1769461804, + "narHash": "sha256-msG8SU5WsBUfVVa/9RPLaymvi5bI8edTavbIq3vRlhI=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "9da7f1cf7f8a6e2a7cb3001b048546c92a8258b4", + "rev": "bfc1b8a4574108ceef22f02bafcf6611380c100d", "type": "github" }, "original": { From 5dc7b1dcd4afcef88b0558757ec41604ee093187 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 30 Jan 2026 20:54:29 +0300 Subject: [PATCH 09/10] eh: add `eh update` or `nu` in symlink Signed-off-by: NotAShelf Change-Id: Iee1d7c2ed2c4b2cd5520c68ceb2b5e6d6a6a6964 --- crates/xtask/Cargo.toml | 2 +- crates/xtask/src/main.rs | 6 +- eh/Cargo.toml | 16 +++--- eh/src/error.rs | 39 ++++++++++++- eh/src/lib.rs | 6 ++ eh/src/main.rs | 19 +++++-- eh/src/update.rs | 115 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 188 insertions(+), 15 deletions(-) create mode 100644 eh/src/update.rs diff --git a/crates/xtask/Cargo.toml b/crates/xtask/Cargo.toml index 2e7cd1a..4343ed0 100644 --- a/crates/xtask/Cargo.toml +++ b/crates/xtask/Cargo.toml @@ -11,4 +11,4 @@ publish = false [dependencies] clap.workspace = true clap_complete.workspace = true -eh.workspace = true +eh.workspace = true diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index ad449c4..a580cda 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -42,6 +42,7 @@ enum Binary { Nr, Ns, Nb, + Nu, } impl Binary { @@ -50,6 +51,7 @@ impl Binary { Self::Nr => "nr", Self::Ns => "ns", Self::Nb => "nb", + Self::Nu => "nu", } } } @@ -90,7 +92,7 @@ fn create_multicall_binaries( ); } - let multicall_binaries = [Binary::Nr, Binary::Ns, Binary::Nb]; + let multicall_binaries = [Binary::Nr, Binary::Ns, Binary::Nb, Binary::Nu]; let bin_path = Path::new(bin_dir); for binary in multicall_binaries { @@ -153,7 +155,7 @@ fn generate_completions( println!("completion file generated: {}", completion_file.display()); // Create symlinks for multicall binaries - let multicall_names = ["nb", "nr", "ns"]; + let multicall_names = ["nb", "nr", "ns", "nu"]; for name in &multicall_names { let symlink_path = output_dir.join(format!("{name}.{shell}")); if symlink_path.exists() { diff --git a/eh/Cargo.toml b/eh/Cargo.toml index d221297..92dd751 100644 --- a/eh/Cargo.toml +++ b/eh/Cargo.toml @@ -11,10 +11,12 @@ crate-type = [ "lib" ] name = "eh" [dependencies] -clap.workspace = true -eh-log.workspace = true -regex.workspace = true -tempfile.workspace = true -thiserror.workspace = true -walkdir.workspace = true -yansi.workspace = true +clap.workspace = true +dialoguer.workspace = true +eh-log.workspace = true +regex.workspace = true +serde_json.workspace = true +tempfile.workspace = true +thiserror.workspace = true +walkdir.workspace = true +yansi.workspace = true diff --git a/eh/src/error.rs b/eh/src/error.rs index 909453a..9ebc9b6 100644 --- a/eh/src/error.rs +++ b/eh/src/error.rs @@ -45,6 +45,15 @@ pub enum EhError { #[error("invalid input '{input}': {reason}")] InvalidInput { input: String, reason: String }, + + #[error("failed to parse JSON from nix output: {detail}")] + JsonParse { detail: String }, + + #[error("no flake inputs found in lock file")] + NoFlakeInputs, + + #[error("no inputs selected")] + UpdateCancelled, } pub type Result = std::result::Result; @@ -65,6 +74,9 @@ impl EhError { Self::Utf8(_) => 10, Self::Timeout { .. } => 11, Self::PreEvalFailed { .. } => 12, + Self::JsonParse { .. } => 13, + Self::NoFlakeInputs => 14, + Self::UpdateCancelled => 0, } } @@ -92,12 +104,19 @@ impl EhError { Self::InvalidInput { .. } => { Some("avoid shell metacharacters in nix arguments") }, + Self::JsonParse { .. } => { + Some("ensure 'nix flake metadata --json' produces valid output") + }, + Self::NoFlakeInputs => { + Some("run this from a directory with a flake.lock that has inputs") + }, Self::Io(_) | Self::Regex(_) | Self::Utf8(_) | Self::HashFixFailed { .. } | Self::ProcessExit { .. } - | Self::CommandFailed { .. } => None, + | Self::CommandFailed { .. } + | Self::UpdateCancelled => None, } } } @@ -156,6 +175,15 @@ mod tests { 12 ); assert_eq!(EhError::ProcessExit { code: 42 }.exit_code(), 42); + assert_eq!( + EhError::JsonParse { + detail: "x".into(), + } + .exit_code(), + 13 + ); + assert_eq!(EhError::NoFlakeInputs.exit_code(), 14); + assert_eq!(EhError::UpdateCancelled.exit_code(), 0); } #[test] @@ -221,6 +249,14 @@ mod tests { .hint() .is_some() ); + assert!( + EhError::JsonParse { + detail: "x".into(), + } + .hint() + .is_some() + ); + assert!(EhError::NoFlakeInputs.hint().is_some()); // Variants without hints assert!( EhError::CommandFailed { @@ -230,5 +266,6 @@ mod tests { .is_none() ); assert!(EhError::ProcessExit { code: 1 }.hint().is_none()); + assert!(EhError::UpdateCancelled.hint().is_none()); } } diff --git a/eh/src/lib.rs b/eh/src/lib.rs index 5dd2c7b..23c5e78 100644 --- a/eh/src/lib.rs +++ b/eh/src/lib.rs @@ -3,6 +3,7 @@ pub mod command; pub mod error; pub mod run; pub mod shell; +pub mod update; pub mod util; pub use clap::{CommandFactory, Parser, Subcommand}; @@ -34,4 +35,9 @@ pub enum Command { #[arg(trailing_var_arg = true)] args: Vec, }, + /// Update flake inputs interactively + Update { + #[arg(trailing_var_arg = true)] + args: Vec, + }, } diff --git a/eh/src/main.rs b/eh/src/main.rs index 8cc4d11..8ce8b6b 100644 --- a/eh/src/main.rs +++ b/eh/src/main.rs @@ -9,6 +9,7 @@ mod command; mod error; mod run; mod shell; +mod update; mod util; fn main() { @@ -17,11 +18,14 @@ fn main() { match result { Ok(code) => std::process::exit(code), Err(e) => { - eh_log::log_error!("{e}"); - if let Some(hint) = e.hint() { - eh_log::log_hint!("{hint}"); + let code = e.exit_code(); + if code != 0 { + eh_log::log_error!("{e}"); + if let Some(hint) = e.hint() { + eh_log::log_hint!("{hint}"); + } } - std::process::exit(e.exit_code()); + std::process::exit(code); }, } } @@ -37,6 +41,7 @@ fn dispatch_multicall( "nr" => "run", "ns" => "shell", "nb" => "build", + "nu" => "update", _ => return None, }; @@ -60,6 +65,10 @@ fn dispatch_multicall( return Some(Ok(0)); } + if subcommand == "update" { + return Some(update::handle_update(&rest)); + } + let hash_extractor = util::RegexHashExtractor; let fixer = util::DefaultNixFileFixer; let classifier = util::DefaultNixErrorClassifier; @@ -110,6 +119,8 @@ fn run_app() -> Result { build::handle_nix_build(&args, &hash_extractor, &fixer, &classifier) }, + Some(Command::Update { args }) => update::handle_update(&args), + None => { Cli::command().print_help()?; println!(); diff --git a/eh/src/update.rs b/eh/src/update.rs new file mode 100644 index 0000000..df4184e --- /dev/null +++ b/eh/src/update.rs @@ -0,0 +1,115 @@ +use crate::{ + command::{NixCommand, StdIoInterceptor}, + error::{EhError, Result}, +}; + +/// Parse flake input names from `nix flake metadata --json` output. +pub fn parse_flake_inputs(stdout: &str) -> Result> { + let value: serde_json::Value = + serde_json::from_str(stdout).map_err(|e| EhError::JsonParse { + detail: e.to_string(), + })?; + + let inputs = value + .get("locks") + .and_then(|l| l.get("nodes")) + .and_then(|n| n.get("root")) + .and_then(|r| r.get("inputs")) + .and_then(|i| i.as_object()) + .ok_or(EhError::NoFlakeInputs)?; + + let mut names: Vec = inputs.keys().cloned().collect(); + names.sort(); + Ok(names) +} + +/// Fetch flake input names by running `nix flake metadata --json`. +fn fetch_flake_inputs() -> Result> { + let output = NixCommand::new("flake") + .arg("metadata") + .arg("--json") + .print_build_logs(false) + .output()?; + + let stdout = String::from_utf8(output.stdout)?; + parse_flake_inputs(&stdout) +} + +/// Prompt the user to select inputs via a multi-select dialog. +fn prompt_input_selection(inputs: &[String]) -> Result> { + let selections = dialoguer::MultiSelect::new() + .with_prompt("Select inputs to update") + .items(inputs) + .interact() + .map_err(|e| EhError::Io(std::io::Error::other(e)))?; + + if selections.is_empty() { + return Err(EhError::UpdateCancelled); + } + + Ok(selections.iter().map(|&i| inputs[i].clone()).collect()) +} + +/// Entry point for the `update` subcommand. +/// +/// If `args` is non-empty, use them as explicit input names. +/// Otherwise, fetch inputs interactively and prompt for selection. +pub fn handle_update(args: &[String]) -> Result { + let selected = if args.is_empty() { + let inputs = fetch_flake_inputs()?; + if inputs.is_empty() { + return Err(EhError::NoFlakeInputs); + } + prompt_input_selection(&inputs)? + } else { + args.to_vec() + }; + + let mut cmd = NixCommand::new("flake").arg("lock"); + for name in &selected { + cmd = cmd.arg("--update-input").arg(name); + } + + eh_log::log_info!("updating inputs: {}", selected.join(", ")); + + let status = cmd.run_with_logs(StdIoInterceptor)?; + Ok(status.code().unwrap_or(1)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_flake_inputs() { + let json = r#"{ + "locks": { + "nodes": { + "root": { + "inputs": { + "nixpkgs": "nixpkgs_2", + "home-manager": "home-manager_2", + "flake-utils": "flake-utils_2" + } + } + } + } + }"#; + + let inputs = parse_flake_inputs(json).unwrap(); + assert_eq!(inputs, vec!["flake-utils", "home-manager", "nixpkgs"]); + } + + #[test] + fn test_parse_flake_inputs_invalid_json() { + let result = parse_flake_inputs("not json"); + assert!(result.is_err()); + } + + #[test] + fn test_parse_flake_inputs_no_inputs() { + let json = r#"{"locks": {"nodes": {"root": {}}}}"#; + let result = parse_flake_inputs(json); + assert!(matches!(result, Err(EhError::NoFlakeInputs))); + } +} From 6f9c6893e1f419194ff00296486e64fd7f0304ec Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 30 Jan 2026 20:55:06 +0300 Subject: [PATCH 10/10] chore: bump deps; set MSRV to 1.90 Signed-off-by: NotAShelf Change-Id: I5947bb6da4c5ab6b7c02222a2b0a4ac36a6a6964 --- Cargo.lock | 191 ++++++++++++++++++++++++++++++++++++++++------------- Cargo.toml | 26 ++++---- 2 files changed, 158 insertions(+), 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2beabba..09dd3fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,18 +4,18 @@ version = 4 [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] [[package]] name = "anstyle" -version = "1.0.11" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "bitflags" @@ -25,15 +25,15 @@ checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "clap" -version = "4.5.51" +version = "4.5.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e" dependencies = [ "clap_builder", "clap_derive", @@ -41,9 +41,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.51" +version = "4.5.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0" dependencies = [ "anstyle", "clap_lex", @@ -51,18 +51,18 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.60" +version = "4.5.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e602857739c5a4291dfa33b5a298aeac9006185229a700e5810a3ef7272d971" +checksum = "430b4dc2b5e3861848de79627b2bedc9f3342c7da5173a14eaa5d0f8dc18ae5d" dependencies = [ "clap", ] [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", @@ -72,17 +72,42 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.5" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "console" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys", +] + +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", +] [[package]] name = "eh" -version = "0.1.3" +version = "0.1.4" dependencies = [ "clap", + "dialoguer", "eh-log", "regex", + "serde_json", "tempfile", "thiserror", "walkdir", @@ -91,11 +116,17 @@ dependencies = [ [[package]] name = "eh-log" -version = "0.1.3" +version = "0.1.4" dependencies = [ "yansi", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "errno" version = "0.3.14" @@ -131,10 +162,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "libc" -version = "0.2.177" +name = "itoa" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "linux-raw-sys" @@ -144,9 +181,9 @@ checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "memchr" -version = "2.7.5" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "once_cell" @@ -156,18 +193,18 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.40" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -203,15 +240,15 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ "bitflags", "errno", @@ -230,10 +267,58 @@ dependencies = [ ] [[package]] -name = "syn" -version = "2.0.104" +name = "serde" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -242,9 +327,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", "getrandom", @@ -255,18 +340,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -275,9 +360,15 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "walkdir" @@ -291,9 +382,9 @@ dependencies = [ [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ "wit-bindgen", ] @@ -324,13 +415,13 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "xtask" -version = "0.1.3" +version = "0.1.4" dependencies = [ "clap", "clap_complete", @@ -342,3 +433,9 @@ name = "yansi" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "zmij" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" diff --git a/Cargo.toml b/Cargo.toml index 7b3f31b..a0f139a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] -members = [ "eh", "crates/*" ] -default-members = ["eh"] -resolver = "3" +default-members = [ "eh" ] +members = [ "eh", "crates/*" ] +resolver = "3" [workspace.package] authors = [ "NotAShelf " ] @@ -9,17 +9,19 @@ description = "Ergonomic Nix CLI helper" edition = "2024" license = "MPL-2.0" readme = true -rust-version = "1.89" -version = "0.1.3" +rust-version = "1.90" +version = "0.1.4" [workspace.dependencies] -clap = { default-features = false, features = [ "std", "help", "derive" ], version = "4.5.51" } -clap_complete = "4.5.60" -regex = "1.12.2" -tempfile = "3.23.0" -thiserror = "2.0.17" -walkdir = "2.5.0" -yansi = "1.0.1" +clap = { default-features = false, features = [ "std", "help", "derive" ], version = "4.5.56" } +clap_complete = "4.5.65" +dialoguer = { default-features = false, version = "0.12.0" } +regex = "1.12.2" +serde_json = "1.0.149" +tempfile = "3.24.0" +thiserror = "2.0.18" +walkdir = "2.5.0" +yansi = "1.0.1" eh = { path = "./eh" } eh-log = { path = "./crates/eh-log" }