diff --git a/.rustfmt.toml b/.rustfmt.toml index ac283d5..8b13789 100644 --- a/.rustfmt.toml +++ b/.rustfmt.toml @@ -1,27 +1 @@ -condense_wildcard_suffixes = true -doc_comment_code_block_width = 80 -edition = "2024" # Keep in sync with Cargo.toml. -enum_discrim_align_threshold = 60 -force_explicit_abi = false -force_multiline_blocks = true -format_code_in_doc_comments = true -format_macro_matchers = true -format_strings = true -group_imports = "StdExternalCrate" -hex_literal_case = "Upper" -imports_granularity = "Crate" -imports_layout = "HorizontalVertical" -inline_attribute_width = 60 -match_block_trailing_comma = true -max_width = 80 -newline_style = "Unix" -normalize_comments = true -normalize_doc_attributes = true -overflow_delimited_expr = true -struct_field_align_threshold = 60 -tab_spaces = 2 -unstable_features = true -use_field_init_shorthand = true -use_try_shorthand = true -wrap_comments = true diff --git a/Cargo.lock b/Cargo.lock index d195e8b..c1fcbfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,12 +17,6 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" -[[package]] -name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" - [[package]] name = "cfg-if" version = "1.0.1" @@ -82,7 +76,6 @@ version = "0.1.2" dependencies = [ "clap", "regex", - "tempfile", "thiserror", "tracing", "tracing-subscriber", @@ -90,34 +83,6 @@ dependencies = [ "yansi", ] -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - [[package]] name = "heck" version = "0.5.0" @@ -130,18 +95,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "libc" -version = "0.2.177" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" - -[[package]] -name = "linux-raw-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - [[package]] name = "log" version = "0.4.27" @@ -193,12 +146,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "regex" version = "1.12.2" @@ -228,19 +175,6 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" -[[package]] -name = "rustix" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", -] - [[package]] name = "same-file" version = "1.0.6" @@ -276,19 +210,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "tempfile" -version = "3.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" -dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys", -] - [[package]] name = "thiserror" version = "2.0.17" @@ -397,15 +318,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "winapi-util" version = "0.1.11" @@ -430,12 +342,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - [[package]] name = "xtask" version = "0.1.2" diff --git a/eh/Cargo.toml b/eh/Cargo.toml index 0b0300b..eba03dd 100644 --- a/eh/Cargo.toml +++ b/eh/Cargo.toml @@ -18,6 +18,3 @@ tracing.workspace = true tracing-subscriber.workspace = true walkdir.workspace = true yansi.workspace = true - -[dev-dependencies] -tempfile = "3.0" diff --git a/eh/src/build.rs b/eh/src/build.rs index 4ec4540..7f5ac0d 100644 --- a/eh/src/build.rs +++ b/eh/src/build.rs @@ -1,18 +1,11 @@ -use crate::{ - error::Result, - util::{ - HashExtractor, - NixErrorClassifier, - NixFileFixer, - handle_nix_with_retry, - }, -}; +use crate::error::Result; +use crate::util::{HashExtractor, NixErrorClassifier, NixFileFixer, handle_nix_with_retry}; pub fn handle_nix_build( - args: &[String], - hash_extractor: &dyn HashExtractor, - fixer: &dyn NixFileFixer, - classifier: &dyn NixErrorClassifier, + args: &[String], + hash_extractor: &dyn HashExtractor, + fixer: &dyn NixFileFixer, + classifier: &dyn NixErrorClassifier, ) -> Result { - handle_nix_with_retry("build", args, hash_extractor, fixer, classifier, false) + handle_nix_with_retry("build", args, hash_extractor, fixer, classifier, false) } diff --git a/eh/src/command.rs b/eh/src/command.rs index cf19869..6589518 100644 --- a/eh/src/command.rs +++ b/eh/src/command.rs @@ -1,27 +1,24 @@ -use std::{ - collections::VecDeque, - io::{self, Read, Write}, - process::{Command, ExitStatus, Output, Stdio}, -}; - use crate::error::{EhError, Result}; +use std::collections::VecDeque; +use std::io::{self, Read, Write}; +use std::process::{Command, ExitStatus, Output, Stdio}; /// Trait for log interception and output handling. pub trait LogInterceptor: Send { - fn on_stderr(&mut self, chunk: &[u8]); - fn on_stdout(&mut self, chunk: &[u8]); + fn on_stderr(&mut self, chunk: &[u8]); + fn on_stdout(&mut self, chunk: &[u8]); } /// Default log interceptor that just writes to stdio. pub struct StdIoInterceptor; impl LogInterceptor for StdIoInterceptor { - fn on_stderr(&mut self, chunk: &[u8]) { - let _ = io::stderr().write_all(chunk); - } - fn on_stdout(&mut self, chunk: &[u8]) { - let _ = io::stdout().write_all(chunk); - } + fn on_stderr(&mut self, chunk: &[u8]) { + let _ = io::stderr().write_all(chunk); + } + fn on_stdout(&mut self, chunk: &[u8]) { + let _ = io::stdout().write_all(chunk); + } } /// Default buffer size for reading command output @@ -29,184 +26,167 @@ const DEFAULT_BUFFER_SIZE: usize = 4096; /// Builder and executor for Nix commands. pub struct NixCommand { - subcommand: String, - args: Vec, - env: Vec<(String, String)>, - impure: bool, - print_build_logs: bool, - interactive: bool, + subcommand: String, + args: Vec, + env: Vec<(String, String)>, + impure: bool, + print_build_logs: bool, + interactive: bool, } impl NixCommand { - pub fn new>(subcommand: S) -> Self { - Self { - subcommand: subcommand.into(), - args: Vec::new(), - env: Vec::new(), - impure: false, - print_build_logs: true, - interactive: false, + pub fn new>(subcommand: S) -> Self { + Self { + subcommand: subcommand.into(), + args: Vec::new(), + env: Vec::new(), + impure: false, + print_build_logs: true, + interactive: false, + } } - } - pub fn arg>(mut self, arg: S) -> Self { - self.args.push(arg.into()); - self - } + pub fn arg>(mut self, arg: S) -> Self { + self.args.push(arg.into()); + self + } - pub fn args(mut self, args: I) -> Self - where - I: IntoIterator, - S: Into, - { - self.args.extend(args.into_iter().map(Into::into)); - self - } - - pub fn args_ref(mut self, args: &[String]) -> Self { - self.args.extend(args.iter().cloned()); - self - } - - pub fn env, V: Into>( - mut self, - key: K, - value: V, - ) -> Self { - self.env.push((key.into(), value.into())); - self - } - - #[must_use] - pub const fn impure(mut self, yes: bool) -> Self { - self.impure = yes; - self - } - - #[must_use] - pub const fn interactive(mut self, yes: bool) -> Self { - self.interactive = yes; - self - } - - #[must_use] - pub const fn print_build_logs(mut self, yes: bool) -> Self { - self.print_build_logs = yes; - self - } - - /// Run the command, streaming output to the provided interceptor. - pub fn run_with_logs( - &self, - mut interceptor: I, - ) -> 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") + pub fn args(mut self, args: I) -> Self + where + I: IntoIterator, + S: Into, { - 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); - - if self.interactive { - cmd.stdout(Stdio::inherit()); - cmd.stderr(Stdio::inherit()); - cmd.stdin(Stdio::inherit()); - return Ok(cmd.status()?); + self.args.extend(args.into_iter().map(Into::into)); + self } - cmd.stdout(Stdio::piped()); - cmd.stderr(Stdio::piped()); - - let mut child = cmd.spawn()?; - let child_stdout = child.stdout.take().ok_or_else(|| { - EhError::CommandFailed { - command: format!("nix {}", self.subcommand), - } - })?; - let child_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 mut out_queue = VecDeque::new(); - let mut err_queue = VecDeque::new(); - - loop { - let mut did_something = false; - - 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; - }, - 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; - } + pub fn env, V: Into>(mut self, key: K, value: V) -> Self { + self.env.push((key.into(), value.into())); + self } - let status = child.wait()?; - Ok(status) - } - - /// Run the command and capture all output. - 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); - - if self.interactive { - cmd.stdout(Stdio::inherit()); - cmd.stderr(Stdio::inherit()); - cmd.stdin(Stdio::inherit()); - } else { - cmd.stdout(Stdio::piped()); - cmd.stderr(Stdio::piped()); + #[must_use] + pub const fn impure(mut self, yes: bool) -> Self { + self.impure = yes; + self } - Ok(cmd.output()?) - } + #[must_use] + pub const fn interactive(mut self, yes: bool) -> Self { + self.interactive = yes; + self + } + + #[must_use] + pub const fn print_build_logs(mut self, yes: bool) -> Self { + self.print_build_logs = yes; + self + } + + /// Run the command, streaming output to the provided interceptor. + pub fn run_with_logs( + &self, + mut interceptor: I, + ) -> 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); + + if self.interactive { + cmd.stdout(Stdio::inherit()); + cmd.stderr(Stdio::inherit()); + cmd.stdin(Stdio::inherit()); + return Ok(cmd.status()?); + } + + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let mut child = cmd.spawn()?; + let child_stdout = child.stdout.take().ok_or_else(|| EhError::CommandFailed { + command: format!("nix {}", self.subcommand), + })?; + let child_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 mut out_queue = VecDeque::new(); + let mut err_queue = VecDeque::new(); + + loop { + let mut did_something = false; + + 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; + } + 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; + } + } + + let status = child.wait()?; + Ok(status) + } + + /// Run the command and capture all output. + 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); + + if self.interactive { + cmd.stdout(Stdio::inherit()); + cmd.stderr(Stdio::inherit()); + cmd.stdin(Stdio::inherit()); + } else { + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + } + + Ok(cmd.output()?) + } } diff --git a/eh/src/error.rs b/eh/src/error.rs index 7e255ef..47296ba 100644 --- a/eh/src/error.rs +++ b/eh/src/error.rs @@ -2,44 +2,44 @@ use thiserror::Error; #[derive(Error, Debug)] pub enum EhError { - #[error("Nix command failed: {0}")] - NixCommandFailed(String), + #[error("Nix command failed: {0}")] + NixCommandFailed(String), - #[error("IO error: {0}")] - Io(#[from] std::io::Error), + #[error("IO error: {0}")] + Io(#[from] std::io::Error), - #[error("Regex error: {0}")] - Regex(#[from] regex::Error), + #[error("Regex error: {0}")] + Regex(#[from] regex::Error), - #[error("UTF-8 conversion error: {0}")] - Utf8(#[from] std::string::FromUtf8Error), + #[error("UTF-8 conversion error: {0}")] + Utf8(#[from] std::string::FromUtf8Error), - #[error("Hash extraction failed")] - HashExtractionFailed, + #[error("Hash extraction failed")] + HashExtractionFailed, - #[error("No Nix files found")] - NoNixFilesFound, + #[error("No Nix files found")] + NoNixFilesFound, - #[error("Failed to fix hash in file: {path}")] - HashFixFailed { path: String }, + #[error("Failed to fix hash in file: {path}")] + HashFixFailed { path: String }, - #[error("Process exited with code: {code}")] - ProcessExit { code: i32 }, + #[error("Process exited with code: {code}")] + ProcessExit { code: i32 }, - #[error("Command execution failed: {command}")] - CommandFailed { command: String }, + #[error("Command execution failed: {command}")] + CommandFailed { command: String }, } pub type Result = std::result::Result; impl EhError { - #[must_use] - pub const fn exit_code(&self) -> i32 { - match self { - Self::ProcessExit { code } => *code, - Self::NixCommandFailed(_) => 1, - Self::CommandFailed { .. } => 1, - _ => 1, + #[must_use] + pub const fn exit_code(&self) -> i32 { + match self { + Self::ProcessExit { code } => *code, + Self::NixCommandFailed(_) => 1, + Self::CommandFailed { .. } => 1, + _ => 1, + } } - } } diff --git a/eh/src/lib.rs b/eh/src/lib.rs index 5dd2c7b..83c467f 100644 --- a/eh/src/lib.rs +++ b/eh/src/lib.rs @@ -11,27 +11,26 @@ pub use error::{EhError, Result}; #[derive(Parser)] #[command(name = "eh")] #[command(about = "Ergonomic Nix helper", long_about = None)] -#[command(version)] pub struct Cli { - #[command(subcommand)] - pub command: Option, + #[command(subcommand)] + pub command: Option, } #[derive(Subcommand)] pub enum Command { - /// Run a Nix derivation - Run { - #[arg(trailing_var_arg = true)] - args: Vec, - }, - /// Enter a Nix shell - Shell { - #[arg(trailing_var_arg = true)] - args: Vec, - }, - /// Build a Nix derivation - Build { - #[arg(trailing_var_arg = true)] - args: Vec, - }, + /// Run a Nix derivation + Run { + #[arg(trailing_var_arg = true)] + args: Vec, + }, + /// Enter a Nix shell + Shell { + #[arg(trailing_var_arg = true)] + args: Vec, + }, + /// Build a Nix derivation + Build { + #[arg(trailing_var_arg = true)] + args: Vec, + }, } diff --git a/eh/src/main.rs b/eh/src/main.rs index 6369154..0cfba89 100644 --- a/eh/src/main.rs +++ b/eh/src/main.rs @@ -1,7 +1,7 @@ -use std::{env, path::Path}; - use eh::{Cli, Command, CommandFactory, Parser}; use error::Result; +use std::env; +use std::path::Path; mod build; mod command; @@ -11,100 +11,91 @@ mod shell; mod util; fn main() { - let format = tracing_subscriber::fmt::format() + 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(); + tracing_subscriber::fmt().event_format(format).init(); - let result = run_app(); + let result = run_app(); - match result { - Ok(code) => std::process::exit(code), - Err(e) => { - eprintln!("Error: {e}"); - std::process::exit(e.exit_code()); - }, - } + match result { + Ok(code) => std::process::exit(code), + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(e.exit_code()); + } + } } // Design partially taken from Stash -fn dispatch_multicall( - app_name: &str, - args: std::env::Args, -) -> Option> { - let rest: Vec = args.collect(); - let hash_extractor = util::RegexHashExtractor; - let fixer = util::DefaultNixFileFixer; - let classifier = util::DefaultNixErrorClassifier; +fn dispatch_multicall(app_name: &str, args: std::env::Args) -> Option> { + let rest: Vec = args.collect(); + 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, - )) - }, - "ns" => { - Some(shell::handle_nix_shell( - &rest, - &hash_extractor, - &fixer, - &classifier, - )) - }, - "nb" => { - Some(build::handle_nix_build( - &rest, - &hash_extractor, - &fixer, - &classifier, - )) - }, - _ => None, - } + match app_name { + "nr" => Some(run::handle_nix_run( + &rest, + &hash_extractor, + &fixer, + &classifier, + )), + "ns" => Some(shell::handle_nix_shell( + &rest, + &hash_extractor, + &fixer, + &classifier, + )), + "nb" => Some(build::handle_nix_build( + &rest, + &hash_extractor, + &fixer, + &classifier, + )), + _ => None, + } } fn run_app() -> Result { - let mut args = env::args(); - let bin = args.next().unwrap_or_else(|| "eh".to_string()); - let app_name = Path::new(&bin) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("eh"); + let mut args = env::args(); + let bin = args.next().unwrap_or_else(|| "eh".to_string()); + let app_name = Path::new(&bin) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("eh"); - // If invoked as nr/ns/nb, dispatch directly and exit - if let Some(result) = dispatch_multicall(app_name, args) { - return result; - } + // If invoked as nr/ns/nb, dispatch directly and exit + if let Some(result) = dispatch_multicall(app_name, args) { + return result; + } - let cli = Cli::parse(); + let cli = Cli::parse(); - let hash_extractor = util::RegexHashExtractor; - let fixer = util::DefaultNixFileFixer; - let classifier = util::DefaultNixErrorClassifier; + let hash_extractor = util::RegexHashExtractor; + let fixer = util::DefaultNixFileFixer; + let classifier = util::DefaultNixErrorClassifier; - match cli.command { - Some(Command::Run { args }) => { - run::handle_nix_run(&args, &hash_extractor, &fixer, &classifier) - }, + match cli.command { + Some(Command::Run { args }) => { + run::handle_nix_run(&args, &hash_extractor, &fixer, &classifier) + } - Some(Command::Shell { args }) => { - shell::handle_nix_shell(&args, &hash_extractor, &fixer, &classifier) - }, + Some(Command::Shell { args }) => { + shell::handle_nix_shell(&args, &hash_extractor, &fixer, &classifier) + } - Some(Command::Build { args }) => { - build::handle_nix_build(&args, &hash_extractor, &fixer, &classifier) - }, + Some(Command::Build { args }) => { + build::handle_nix_build(&args, &hash_extractor, &fixer, &classifier) + } - _ => { - Cli::command().print_help()?; - println!(); - Ok(0) - }, - } + _ => { + Cli::command().print_help()?; + println!(); + Ok(0) + } + } } diff --git a/eh/src/run.rs b/eh/src/run.rs index fff81a1..11a5a12 100644 --- a/eh/src/run.rs +++ b/eh/src/run.rs @@ -1,18 +1,11 @@ -use crate::{ - error::Result, - util::{ - HashExtractor, - NixErrorClassifier, - NixFileFixer, - handle_nix_with_retry, - }, -}; +use crate::error::Result; +use crate::util::{HashExtractor, NixErrorClassifier, NixFileFixer, handle_nix_with_retry}; pub fn handle_nix_run( - args: &[String], - hash_extractor: &dyn HashExtractor, - fixer: &dyn NixFileFixer, - classifier: &dyn NixErrorClassifier, + args: &[String], + hash_extractor: &dyn HashExtractor, + fixer: &dyn NixFileFixer, + classifier: &dyn NixErrorClassifier, ) -> Result { - handle_nix_with_retry("run", args, hash_extractor, fixer, classifier, true) + handle_nix_with_retry("run", args, hash_extractor, fixer, classifier, true) } diff --git a/eh/src/shell.rs b/eh/src/shell.rs index c0f4409..9458b08 100644 --- a/eh/src/shell.rs +++ b/eh/src/shell.rs @@ -1,18 +1,11 @@ -use crate::{ - error::Result, - util::{ - HashExtractor, - NixErrorClassifier, - NixFileFixer, - handle_nix_with_retry, - }, -}; +use crate::error::Result; +use crate::util::{HashExtractor, NixErrorClassifier, NixFileFixer, handle_nix_with_retry}; pub fn handle_nix_shell( - args: &[String], - hash_extractor: &dyn HashExtractor, - fixer: &dyn NixFileFixer, - classifier: &dyn NixErrorClassifier, + args: &[String], + hash_extractor: &dyn HashExtractor, + fixer: &dyn NixFileFixer, + classifier: &dyn NixErrorClassifier, ) -> Result { - handle_nix_with_retry("shell", args, hash_extractor, fixer, classifier, true) + handle_nix_with_retry("shell", args, hash_extractor, fixer, classifier, true) } diff --git a/eh/src/util.rs b/eh/src/util.rs index 6f1490d..3ecdeec 100644 --- a/eh/src/util.rs +++ b/eh/src/util.rs @@ -1,312 +1,292 @@ -use std::{ - fs, - io::Write, - path::{Path, PathBuf}, -}; - +use crate::command::{NixCommand, StdIoInterceptor}; +use crate::error::{EhError, Result}; use regex::Regex; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; use tracing::{info, warn}; use walkdir::WalkDir; use yansi::Paint; -use crate::{ - command::{NixCommand, StdIoInterceptor}, - error::{EhError, Result}, -}; - pub trait HashExtractor { - fn extract_hash(&self, stderr: &str) -> Option; + fn extract_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) - && let Some(hash) = captures.get(1) - { - return Some(hash.as_str().to_string()); - } + 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) + && let Some(hash) = captures.get(1) + { + return Some(hash.as_str().to_string()); + } + } + None } - None - } } pub trait NixFileFixer { - fn fix_hash_in_files(&self, 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_files(&self, new_hash: &str) -> Result; + fn find_nix_files(&self) -> Result>; + fn fix_hash_in_file(&self, file_path: &Path, new_hash: &str) -> Result; } pub struct DefaultNixFileFixer; impl NixFileFixer for DefaultNixFileFixer { - fn fix_hash_in_files(&self, 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)? { - println!("Updated hash in {}", file_path.display()); - fixed = true; - } - } - Ok(fixed) - } - - fn find_nix_files(&self) -> Result> { - let files: Vec = WalkDir::new(".") - .into_iter() - .filter_map(|entry| entry.ok()) - .filter(|entry| { - entry.file_type().is_file() - && entry - .path() - .extension() - .map(|ext| ext.eq_ignore_ascii_case("nix")) - .unwrap_or(false) - }) - .map(|entry| entry.path().to_path_buf()) - .collect(); - - if files.is_empty() { - return Err(EhError::NoNixFilesFound); - } - Ok(files) - } - - fn fix_hash_in_file(&self, file_path: &Path, new_hash: &str) -> Result { - let content = fs::read_to_string(file_path)?; - let patterns = [ - (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}""#), - ), - ]; - let mut new_content = content; - let mut replaced = false; - for (pattern, replacement) in &patterns { - let re = Regex::new(pattern)?; - if re.is_match(&new_content) { - new_content = re - .replace_all(&new_content, replacement.as_str()) - .into_owned(); - replaced = true; - } - } - if replaced { - fs::write(file_path, new_content).map_err(|_| { - EhError::HashFixFailed { - path: file_path.to_string_lossy().to_string(), + fn fix_hash_in_files(&self, 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)? { + println!("Updated hash in {}", file_path.display()); + fixed = true; + } + } + Ok(fixed) + } + + fn find_nix_files(&self) -> Result> { + let files: Vec = WalkDir::new(".") + .into_iter() + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry.file_type().is_file() + && entry + .path() + .extension() + .map(|ext| ext.eq_ignore_ascii_case("nix")) + .unwrap_or(false) + }) + .map(|entry| entry.path().to_path_buf()) + .collect(); + + if files.is_empty() { + Err(EhError::NoNixFilesFound) + } else { + Ok(files) + } + } + + fn fix_hash_in_file(&self, file_path: &Path, new_hash: &str) -> Result { + let content = fs::read_to_string(file_path)?; + let patterns = [ + (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}""#), + ), + ]; + let mut new_content = content; + let mut replaced = false; + for (pattern, replacement) in &patterns { + let re = Regex::new(pattern)?; + if re.is_match(&new_content) { + new_content = re + .replace_all(&new_content, replacement.as_str()) + .into_owned(); + replaced = true; + } + } + if replaced { + fs::write(file_path, new_content).map_err(|_| EhError::HashFixFailed { + path: file_path.to_string_lossy().to_string(), + })?; + Ok(true) + } else { + Ok(false) } - })?; - Ok(true) - } else { - Ok(false) } - } } pub trait NixErrorClassifier { - fn should_retry(&self, stderr: &str) -> bool; + fn should_retry(&self, stderr: &str) -> bool; } /// Pre-evaluate expression to catch errors early fn pre_evaluate(_subcommand: &str, 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('-')); + // 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 - }; + let Some(eval_arg) = eval_arg else { + return Ok(true); // No expression to evaluate + }; - let eval_cmd = NixCommand::new("eval").arg(eval_arg).arg("--raw"); + let eval_cmd = NixCommand::new("eval").arg(eval_arg).arg("--raw"); - let output = eval_cmd.output()?; + let output = eval_cmd.output()?; - if output.status.success() { - return Ok(true); - } + if output.status.success() { + return Ok(true); + } - let stderr = String::from_utf8_lossy(&output.stderr); + 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); - } + // 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); + } - // For other eval failures, fail early - Ok(false) + // For other eval failures, fail early + Ok(false) } /// Shared retry logic for nix commands (build/run/shell). pub fn handle_nix_with_retry( - subcommand: &str, - args: &[String], - hash_extractor: &dyn HashExtractor, - fixer: &dyn NixFileFixer, - classifier: &dyn NixErrorClassifier, - interactive: bool, + subcommand: &str, + args: &[String], + hash_extractor: &dyn HashExtractor, + fixer: &dyn NixFileFixer, + classifier: &dyn NixErrorClassifier, + interactive: bool, ) -> Result { - // Pre-evaluate for build commands to catch errors early - if !pre_evaluate(subcommand, args)? { - return Err(EhError::NixCommandFailed( - "Expression evaluation failed".to_string(), - )); - } - - // For run commands, try interactive first to avoid breaking terminal - if subcommand == "run" && interactive { - let status = NixCommand::new(subcommand) - .print_build_logs(true) - .interactive(true) - .args_ref(args) - .run_with_logs(StdIoInterceptor)?; - if status.success() { - return Ok(0); + // Pre-evaluate for build commands to catch errors early + if !pre_evaluate(subcommand, args)? { + return Err(EhError::NixCommandFailed( + "Expression evaluation failed".to_string(), + )); } - } - // First, always capture output to check for errors that need retry - 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 - if let Some(new_hash) = hash_extractor.extract_hash(&stderr) { - match fixer.fix_hash_in_files(&new_hash) { - Ok(true) => { - info!("{}", Paint::green("✔ Fixed hash mismatch, retrying...")); - let mut retry_cmd = NixCommand::new(subcommand) - .print_build_logs(true) - .args_ref(args); - if interactive { - retry_cmd = retry_cmd.interactive(true); + // For run commands, try interactive first to avoid breaking terminal + if subcommand == "run" && interactive { + let mut cmd = NixCommand::new(subcommand) + .print_build_logs(true) + .interactive(true); + for arg in args { + cmd = cmd.arg(arg); + } + let status = cmd.run_with_logs(StdIoInterceptor)?; + if status.success() { + return Ok(0); } - let retry_status = retry_cmd.run_with_logs(StdIoInterceptor)?; - return Ok(retry_status.code().unwrap_or(1)); - }, - Ok(false) => { - // No files were fixed, continue with normal error handling - }, - Err(EhError::NoNixFilesFound) => { - warn!("No .nix files found to fix hash in"); - // 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); - } - 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 mut retry_cmd = NixCommand::new(subcommand) + // First, always capture output to check for errors that need retry + let output_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") - .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)); - } - } + .args(args.iter().cloned()); + let output = output_cmd.output()?; + let stderr = String::from_utf8_lossy(&output.stderr); - // If the first attempt succeeded, we're done - if output.status.success() { - return Ok(0); - } + // Check if we need to retry with special flags + if let Some(new_hash) = hash_extractor.extract_hash(&stderr) { + match fixer.fix_hash_in_files(&new_hash) { + Ok(true) => { + info!("{}", Paint::green("✔ Fixed hash mismatch, retrying...")); + let mut retry_cmd = NixCommand::new(subcommand) + .print_build_logs(true) + .args(args.iter().cloned()); + 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)); + } + Ok(false) => { + // No files were fixed, continue with normal error handling + } + Err(EhError::NoNixFilesFound) => { + warn!("No .nix files found to fix hash in"); + // 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); + } - // Otherwise, show the error and return error - std::io::stderr() - .write_all(&output.stderr) - .map_err(EhError::Io)?; - Err(EhError::ProcessExit { - code: output.status.code().unwrap_or(1), - }) + 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 mut retry_cmd = NixCommand::new(subcommand) + .print_build_logs(true) + .args(args.iter().cloned()) + .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(args.iter().cloned()) + .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(args.iter().cloned()) + .env("NIXPKGS_ALLOW_BROKEN", "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 the first attempt succeeded, we're done + if output.status.success() { + return Ok(0); + } + + // Otherwise, show the error and return error + std::io::stderr().write_all(&output.stderr)?; + Err(EhError::ProcessExit { + code: output.status.code().unwrap_or(1), + }) } 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")) - } + 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")) + } } diff --git a/eh/tests/basic.rs b/eh/tests/basic.rs index 7380b3f..4b69805 100644 --- a/eh/tests/basic.rs +++ b/eh/tests/basic.rs @@ -1,234 +1,178 @@ -use std::{fs, process::Command}; - -use eh::util::{ - DefaultNixErrorClassifier, - DefaultNixFileFixer, - HashExtractor, - NixErrorClassifier, - NixFileFixer, - RegexHashExtractor, -}; -use tempfile::TempDir; +//! I hate writing tests, and I hate writing integration tests. This is the best +//! that you are getting, deal with it. +use std::process::{Command, Stdio}; #[test] -fn test_hash_extraction_from_real_nix_errors() { - // Test hash extraction from actual Nix error messages - let extractor = RegexHashExtractor; +fn nix_eval_validation() { + // Test that invalid expressions are caught early for all commands + let commands = ["build", "run", "shell"]; - let test_cases = [ - ( - r#"error: hash mismatch in fixed-output derivation '/nix/store/xxx-foo.drv': - specified: sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= - got: sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="#, - Some("sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=".to_string()), - ), - ( - "actual: sha256-abc123def456", - Some("sha256-abc123def456".to_string()), - ), - ("have: sha256-xyz789", Some("sha256-xyz789".to_string())), - ("no hash here", None), - ]; + for cmd in &commands { + let output = Command::new("timeout") + .args([ + "10", + "cargo", + "run", + "--bin", + "eh", + "--", + cmd, + "invalid-flake-ref", + ]) + .output() + .expect("Failed to execute command"); - for (input, expected) in test_cases { - assert_eq!(extractor.extract_hash(input), expected); - } + // Should fail fast with eval error + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Error: Expression evaluation failed") || !output.status.success()); + } } #[test] -fn test_error_classification_for_retry_logic() { - // Test that the classifier correctly identifies errors that should be retried - let classifier = DefaultNixErrorClassifier; +fn unfree_package_handling() { + // Test that unfree packages are detected and handled correctly + let output = Command::new("timeout") + .args([ + "30", + "cargo", + "run", + "--bin", + "eh", + "--", + "build", + "nixpkgs#discord", + ]) + .output() + .expect("Failed to execute command"); - // These should trigger retries - let retry_cases = [ - "Package 'discord-1.0.0' has an unfree license ('unfree'), refusing to \ - evaluate.", - "Package 'openssl-1.1.1' has been marked as insecure, refusing to \ - evaluate.", - "Package 'broken-1.0' has been marked as broken, refusing to evaluate.", - "hash mismatch in fixed-output derivation\ngot: sha256-newhash", - ]; + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let combined = format!("{}{}", stdout, stderr); - for error in retry_cases { - assert!(classifier.should_retry(error), "Should retry: {}", error); - } - - // These should NOT trigger retries - let no_retry_cases = [ - "build failed", - "random error", - "permission denied", - "network error", - ]; - - for error in no_retry_cases { + // Should detect unfree package and show appropriate message assert!( - !classifier.should_retry(error), - "Should not retry: {}", - error + combined.contains("has an unfree license") + || combined.contains("NIXPKGS_ALLOW_UNFREE") + || combined.contains("⚠ Unfree package detected") ); - } } #[test] -fn test_hash_fixing_in_nix_files() { - // Test that hash fixing actually works on real Nix files - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let fixer = DefaultNixFileFixer; +fn insecure_package_handling() { + // Test that error classification works for insecure packages + use eh::util::{DefaultNixErrorClassifier, NixErrorClassifier}; - // Create a mock Nix file with various hash formats - let nix_content = r#" -stdenv.mkDerivation { - name = "test-package"; - src = fetchurl { - url = "https://example.com.tar.gz"; - hash = "sha256-oldhash123"; - }; + let classifier = DefaultNixErrorClassifier; + let stderr_insecure = + "Package 'example-1.0' has been marked as insecure, refusing to evaluate."; - buildInputs = [ fetchurl { - url = "https://deps.com.tar.gz"; - sha256 = "sha256-oldhash456"; - }]; - - outputHash = "sha256-oldhash789"; + assert!(classifier.should_retry(stderr_insecure)); } + +#[test] +fn broken_package_handling() { + // Test that error classification works for broken packages + use eh::util::{DefaultNixErrorClassifier, NixErrorClassifier}; + + let classifier = DefaultNixErrorClassifier; + let stderr_broken = "Package 'example-1.0' has been marked as broken, refusing to evaluate."; + + assert!(classifier.should_retry(stderr_broken)); +} + +#[test] +fn multicall_binary_dispatch() { + // Test that nb/nr/ns dispatch correctly based on binary name + let commands = [("nb", "build"), ("nr", "run"), ("ns", "shell")]; + + for (binary_name, _expected_cmd) in &commands { + let output = Command::new("timeout") + .args(["10", "cargo", "run", "--bin", "eh"]) + .env("CARGO_BIN_NAME", binary_name) + .arg("nixpkgs#hello") + .arg("--help") // Use help to avoid actually building + .output() + .expect("Failed to execute command"); + + // Should execute without panicking (status code may vary) + assert!(output.status.code().is_some()); + } +} + +#[test] +fn interactive_mode_inheritance() { + // Test that run commands inherit stdio properly + let mut child = Command::new("timeout") + .args([ + "10", + "cargo", + "run", + "--bin", + "eh", + "--", + "run", + "nixpkgs#echo", + "test", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("Failed to spawn command"); + + let status = child.wait().expect("Failed to wait for child"); + + // Should complete without hanging + assert!(status.code().is_some()); +} + +#[test] +fn hash_extraction() { + use eh::util::{HashExtractor, RegexHashExtractor}; + + let extractor = RegexHashExtractor; + let stderr = "error: hash mismatch in fixed-output derivation '/nix/store/...': + specified: sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + got: sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="; + + let hash = extractor.extract_hash(stderr); + assert!(hash.is_some()); + assert_eq!( + hash.unwrap(), + "sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=" + ); +} + +#[test] +fn error_classification() { + use eh::util::{DefaultNixErrorClassifier, NixErrorClassifier}; + + let classifier = DefaultNixErrorClassifier; + + assert!(classifier.should_retry("has an unfree license ('unfree'), refusing to evaluate")); + assert!(classifier.should_retry("has been marked as insecure, refusing to evaluate")); + assert!(classifier.should_retry("has been marked as broken, refusing to evaluate")); + assert!(!classifier.should_retry("random build error")); +} + +#[test] +fn hash_mismatch_auto_fix() { + // Test that hash mismatches are automatically detected and fixed + // This is harder to test without creating actual files, so we test the regex + // for the time being. Alternatively I could do this inside a temporary directory + // but cba for now. + use eh::util::{HashExtractor, RegexHashExtractor}; + + let extractor = RegexHashExtractor; + let stderr_with_mismatch = r#" +error: hash mismatch in fixed-output derivation + specified: sha256-oldhashaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa= + got: sha256-newhashbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb= "#; - let file_path = temp_dir.path().join("test.nix"); - fs::write(&file_path, nix_content).expect("Failed to write test file"); - - // Test hash replacement - let new_hash = "sha256-newhashabc"; - let was_fixed = fixer - .fix_hash_in_file(&file_path, new_hash) - .expect("Failed to fix hash"); - - assert!(was_fixed, "File should have been modified"); - - let updated_content = - fs::read_to_string(&file_path).expect("Failed to read updated file"); - - // All hash formats should be updated - assert!(updated_content.contains(&format!(r#"hash = "{}""#, new_hash))); - assert!(updated_content.contains(&format!(r#"sha256 = "{}""#, new_hash))); - assert!(updated_content.contains(&format!(r#"outputHash = "{}""#, new_hash))); - - // Old hashes should be gone - assert!(!updated_content.contains("oldhash123")); - assert!(!updated_content.contains("oldhash456")); - assert!(!updated_content.contains("oldhash789")); -} - -#[test] -fn test_multicall_binary_dispatch() { - // Test that multicall binaries work without needing actual Nix evaluation - let commands = [("nb", "build"), ("nr", "run"), ("ns", "shell")]; - - for (binary_name, _expected_command) in &commands { - // Test that the binary starts and handles invalid arguments gracefully - let output = Command::new("timeout") - .args(["5", "cargo", "run", "--bin", "eh", "--"]) - .env("CARGO_BIN_NAME", binary_name) - .arg("invalid-package-ref") - .output() - .expect("Failed to execute command"); - - // Should fail gracefully (not panic or hang) - assert!( - output.status.code().is_some(), - "{} should exit with a code", - binary_name + let extracted = extractor.extract_hash(stderr_with_mismatch); + assert_eq!( + extracted, + Some("sha256-newhashbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb=".to_string()) ); - - // Should show an error message, not crash - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("Error:") - || stderr.contains("error:") - || stderr.contains("failed"), - "{} should show error for invalid package", - binary_name - ); - } -} - -#[test] -fn test_invalid_expression_handling() { - // Test that invalid Nix expressions fail fast with proper error messages - let invalid_refs = [ - "invalid-flake-ref", - "nonexistent-package", - "file:///nonexistent/path", - ]; - - for invalid_ref in invalid_refs { - let output = Command::new("timeout") - .args([ - "10", - "cargo", - "run", - "--bin", - "eh", - "--", - "build", - invalid_ref, - ]) - .output() - .expect("Failed to execute command"); - - // Should fail with a proper error, not hang or crash - assert!( - !output.status.success(), - "Invalid ref '{}' should fail", - invalid_ref - ); - - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("Error:") - || stderr.contains("error:") - || stderr.contains("failed"), - "Should show error message for invalid ref '{}': {}", - invalid_ref, - stderr - ); - } -} - -#[test] -fn test_nix_file_discovery() { - // Test that the fixer can find Nix files in a directory structure - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let fixer = DefaultNixFileFixer; - - // Create directory structure with Nix files - fs::create_dir_all(temp_dir.path().join("subdir")) - .expect("Failed to create subdir"); - - let files = [ - ("test.nix", "stdenv.mkDerivation { name = \"test\"; }"), - ("subdir/other.nix", "pkgs.hello"), - ("not-nix.txt", "not a nix file"), - ("default.nix", "import ./test.nix"), - ]; - - for (path, content) in files { - fs::write(temp_dir.path().join(path), content) - .expect("Failed to write file"); - } - - // Change to temp dir for file discovery - let original_dir = - std::env::current_dir().expect("Failed to get current dir"); - std::env::set_current_dir(temp_dir.path()) - .expect("Failed to change directory"); - - let found_files = fixer.find_nix_files().expect("Failed to find Nix files"); - - // Should find 3 .nix files (not the .txt file) - assert_eq!(found_files.len(), 3, "Should find exactly 3 .nix files"); - - // Restore original directory - std::env::set_current_dir(original_dir).expect("Failed to restore directory"); } diff --git a/nix/shell.nix b/nix/shell.nix index 4584b59..fcda3a5 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -1,26 +1,22 @@ { mkShell, - rustc, - cargo, + rust-analyzer, rustfmt, clippy, + cargo, taplo, - rust-analyzer-unwrapped, rustPlatform, }: mkShell { name = "rust"; - packages = [ - rustc - cargo - - (rustfmt.override {asNightly = true;}) + rust-analyzer + rustfmt clippy cargo + taplo - rust-analyzer-unwrapped ]; - env.RUST_SRC_PATH = "${rustPlatform.rustLibSrc}"; + RUST_SRC_PATH = "${rustPlatform.rustLibSrc}"; } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 327ba0d..b747f05 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,8 +1,7 @@ use std::{ - error, - fs, - path::{Path, PathBuf}, - process, + error, fs, + path::{Path, PathBuf}, + process, }; use clap::{CommandFactory, Parser}; @@ -10,169 +9,167 @@ use clap_complete::{Shell, generate}; #[derive(clap::Parser)] struct Cli { - #[clap(subcommand)] - command: Command, + #[clap(subcommand)] + command: Command, } #[derive(clap::Subcommand)] enum Command { - /// Create multicall binaries (hardlinks or copies). - Multicall { - /// Directory to install multicall binaries. - #[arg(long, default_value = "bin")] - bin_dir: PathBuf, + /// Create multicall binaries (hardlinks or copies). + Multicall { + /// Directory to install multicall binaries. + #[arg(long, default_value = "bin")] + bin_dir: PathBuf, - /// Path to the main binary. - #[arg(long, default_value = "target/release/eh")] - main_binary: PathBuf, - }, - /// Generate shell completion scripts - Completions { - /// Shell to generate completions for - #[arg(value_enum)] - shell: Shell, - /// Directory to output completion files - #[arg(long, default_value = "completions")] - output_dir: PathBuf, - }, + /// Path to the main binary. + #[arg(long, default_value = "target/release/eh")] + main_binary: PathBuf, + }, + /// Generate shell completion scripts + Completions { + /// Shell to generate completions for + #[arg(value_enum)] + shell: Shell, + /// Directory to output completion files + #[arg(long, default_value = "completions")] + output_dir: PathBuf, + }, } #[derive(Debug, Clone, Copy)] enum Binary { - Nr, - Ns, - Nb, + Nr, + Ns, + Nb, } impl Binary { - const fn name(self) -> &'static str { - match self { - Self::Nr => "nr", - Self::Ns => "ns", - Self::Nb => "nb", + const fn name(self) -> &'static str { + match self { + Self::Nr => "nr", + Self::Ns => "ns", + Self::Nb => "nb", + } } - } } fn main() { - let cli = Cli::parse(); + let cli = Cli::parse(); - match cli.command { - Command::Multicall { - bin_dir, - main_binary, - } => { - if let Err(error) = create_multicall_binaries(&bin_dir, &main_binary) { - eprintln!("error creating multicall binaries: {error}"); - process::exit(1); - } - }, - Command::Completions { shell, output_dir } => { - if let Err(error) = generate_completions(shell, &output_dir) { - eprintln!("error generating completions: {error}"); - process::exit(1); - } - }, - } + match cli.command { + Command::Multicall { + bin_dir, + main_binary, + } => { + if let Err(error) = create_multicall_binaries(&bin_dir, &main_binary) { + eprintln!("error creating multicall binaries: {error}"); + process::exit(1); + } + } + Command::Completions { shell, output_dir } => { + if let Err(error) = generate_completions(shell, &output_dir) { + eprintln!("error generating completions: {error}"); + process::exit(1); + } + } + } } fn create_multicall_binaries( - bin_dir: &Path, - main_binary: &Path, + bin_dir: &Path, + main_binary: &Path, ) -> Result<(), Box> { - println!("creating multicall binaries..."); + println!("creating multicall binaries..."); - fs::create_dir_all(bin_dir)?; + fs::create_dir_all(bin_dir)?; - if !main_binary.exists() { - return Err( - format!("main binary not found at: {}", main_binary.display()).into(), - ); - } - - let multicall_binaries = [Binary::Nr, Binary::Ns, Binary::Nb]; - let bin_path = Path::new(bin_dir); - - for binary in multicall_binaries { - let target_path = bin_path.join(binary.name()); - - if target_path.exists() { - fs::remove_file(&target_path)?; + if !main_binary.exists() { + return Err(format!("main binary not found at: {}", main_binary.display()).into()); } - if let Err(e) = fs::hard_link(main_binary, &target_path) { - eprintln!( - " warning: could not create hardlink for {}: {e}", - binary.name(), - ); - eprintln!(" warning: falling back to copying binary..."); + let multicall_binaries = [Binary::Nr, Binary::Ns, Binary::Nb]; + let bin_path = Path::new(bin_dir); - fs::copy(main_binary, &target_path)?; + for binary in multicall_binaries { + let target_path = bin_path.join(binary.name()); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(&target_path)?.permissions(); - perms.set_mode(perms.mode() | 0o755); - fs::set_permissions(&target_path, perms)?; - } + if target_path.exists() { + fs::remove_file(&target_path)?; + } - println!(" created copy: {}", target_path.display()); - } else { - println!( - " created hardlink: {} points to {}", - target_path.display(), - main_binary.display(), - ); + match fs::hard_link(main_binary, &target_path) { + Ok(()) => { + println!( + " created hardlink: {} points to {}", + target_path.display(), + main_binary.display(), + ); + } + Err(e) => { + eprintln!( + " warning: could not create hardlink for {}: {e}", + binary.name(), + ); + eprintln!(" warning: falling back to copying binary..."); + + fs::copy(main_binary, &target_path)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&target_path)?.permissions(); + perms.set_mode(perms.mode() | 0o755); + fs::set_permissions(&target_path, perms)?; + } + + println!(" created copy: {}", target_path.display()); + } + } } - } - println!("multicall binaries created successfully!"); - println!("multicall binaries are in: {}", bin_dir.display()); - println!(); + println!("multicall binaries created successfully!"); + println!("multicall binaries are in: {}", bin_dir.display()); + println!(); - Ok(()) + Ok(()) } -fn generate_completions( - shell: Shell, - output_dir: &Path, -) -> Result<(), Box> { - println!("generating {shell} completions..."); +fn generate_completions(shell: Shell, output_dir: &Path) -> Result<(), Box> { + println!("generating {shell} completions..."); - fs::create_dir_all(output_dir)?; + fs::create_dir_all(output_dir)?; - let mut cmd = eh::Cli::command(); - let bin_name = "eh"; + let mut cmd = eh::Cli::command(); + let bin_name = "eh"; - let completion_file = output_dir.join(format!("{bin_name}.{shell}")); - let mut file = fs::File::create(&completion_file)?; + let completion_file = output_dir.join(format!("{bin_name}.{shell}")); + let mut file = fs::File::create(&completion_file)?; - generate(shell, &mut cmd, bin_name, &mut file); + generate(shell, &mut cmd, bin_name, &mut file); - println!("completion file generated: {}", completion_file.display()); + println!("completion file generated: {}", completion_file.display()); - // Create symlinks for multicall binaries - let multicall_names = ["nb", "nr", "ns"]; - for name in &multicall_names { - let symlink_path = output_dir.join(format!("{name}.{shell}")); - if symlink_path.exists() { - fs::remove_file(&symlink_path)?; + // Create symlinks for multicall binaries + let multicall_names = ["nb", "nr", "ns"]; + for name in &multicall_names { + let symlink_path = output_dir.join(format!("{name}.{shell}")); + if symlink_path.exists() { + fs::remove_file(&symlink_path)?; + } + + #[cfg(unix)] + { + std::os::unix::fs::symlink(&completion_file, &symlink_path)?; + println!("completion symlink created: {}", symlink_path.display()); + } + + #[cfg(not(unix))] + { + fs::copy(&completion_file, &symlink_path)?; + println!("completion copy created: {}", symlink_path.display()); + } } - #[cfg(unix)] - { - std::os::unix::fs::symlink(&completion_file, &symlink_path)?; - println!("completion symlink created: {}", symlink_path.display()); - } - - #[cfg(not(unix))] - { - fs::copy(&completion_file, &symlink_path)?; - println!("completion copy created: {}", symlink_path.display()); - } - } - - println!("completions generated successfully!"); - Ok(()) + println!("completions generated successfully!"); + Ok(()) }