Compare commits

..

No commits in common. "7498902d4664b506b63d2ca916e6c2f06cbca03d" and "185403436b455fd03049ae436b9990f26307908e" have entirely different histories.

14 changed files with 819 additions and 1076 deletions

View file

@ -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

94
Cargo.lock generated
View file

@ -17,12 +17,6 @@ version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd"
[[package]]
name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.1" version = "1.0.1"
@ -82,7 +76,6 @@ version = "0.1.2"
dependencies = [ dependencies = [
"clap", "clap",
"regex", "regex",
"tempfile",
"thiserror", "thiserror",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@ -90,34 +83,6 @@ dependencies = [
"yansi", "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]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@ -130,18 +95,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 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]] [[package]]
name = "log" name = "log"
version = "0.4.27" version = "0.4.27"
@ -193,12 +146,6 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.12.2" version = "1.12.2"
@ -228,19 +175,6 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 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]] [[package]]
name = "same-file" name = "same-file"
version = "1.0.6" version = "1.0.6"
@ -276,19 +210,6 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.17" version = "2.0.17"
@ -397,15 +318,6 @@ dependencies = [
"winapi-util", "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]] [[package]]
name = "winapi-util" name = "winapi-util"
version = "0.1.11" version = "0.1.11"
@ -430,12 +342,6 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "wit-bindgen"
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
[[package]] [[package]]
name = "xtask" name = "xtask"
version = "0.1.2" version = "0.1.2"

View file

@ -18,6 +18,3 @@ tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
walkdir.workspace = true walkdir.workspace = true
yansi.workspace = true yansi.workspace = true
[dev-dependencies]
tempfile = "3.0"

View file

@ -1,12 +1,5 @@
use crate::{ use crate::error::Result;
error::Result, use crate::util::{HashExtractor, NixErrorClassifier, NixFileFixer, handle_nix_with_retry};
util::{
HashExtractor,
NixErrorClassifier,
NixFileFixer,
handle_nix_with_retry,
},
};
pub fn handle_nix_build( pub fn handle_nix_build(
args: &[String], args: &[String],

View file

@ -1,10 +1,7 @@
use std::{
collections::VecDeque,
io::{self, Read, Write},
process::{Command, ExitStatus, Output, Stdio},
};
use crate::error::{EhError, Result}; 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. /// Trait for log interception and output handling.
pub trait LogInterceptor: Send { pub trait LogInterceptor: Send {
@ -63,16 +60,7 @@ impl NixCommand {
self self
} }
pub fn args_ref(mut self, args: &[String]) -> Self { pub fn env<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self {
self.args.extend(args.iter().cloned());
self
}
pub fn env<K: Into<String>, V: Into<String>>(
mut self,
key: K,
value: V,
) -> Self {
self.env.push((key.into(), value.into())); self.env.push((key.into(), value.into()));
self self
} }
@ -103,9 +91,7 @@ impl NixCommand {
let mut cmd = Command::new("nix"); let mut cmd = Command::new("nix");
cmd.arg(&self.subcommand); cmd.arg(&self.subcommand);
if self.print_build_logs if self.print_build_logs && !self.args.iter().any(|a| a == "--no-build-output") {
&& !self.args.iter().any(|a| a == "--no-build-output")
{
cmd.arg("--print-build-logs"); cmd.arg("--print-build-logs");
} }
if self.impure { if self.impure {
@ -127,15 +113,11 @@ impl NixCommand {
cmd.stderr(Stdio::piped()); cmd.stderr(Stdio::piped());
let mut child = cmd.spawn()?; let mut child = cmd.spawn()?;
let child_stdout = child.stdout.take().ok_or_else(|| { let child_stdout = child.stdout.take().ok_or_else(|| EhError::CommandFailed {
EhError::CommandFailed {
command: format!("nix {}", self.subcommand), command: format!("nix {}", self.subcommand),
}
})?; })?;
let child_stderr = child.stderr.take().ok_or_else(|| { let child_stderr = child.stderr.take().ok_or_else(|| EhError::CommandFailed {
EhError::CommandFailed {
command: format!("nix {}", self.subcommand), command: format!("nix {}", self.subcommand),
}
})?; })?;
let mut stdout = child_stdout; let mut stdout = child_stdout;
let mut stderr = child_stderr; let mut stderr = child_stderr;
@ -150,24 +132,24 @@ impl NixCommand {
let mut did_something = false; let mut did_something = false;
match stdout.read(&mut out_buf) { match stdout.read(&mut out_buf) {
Ok(0) => {}, Ok(0) => {}
Ok(n) => { Ok(n) => {
interceptor.on_stdout(&out_buf[..n]); interceptor.on_stdout(&out_buf[..n]);
out_queue.push_back(Vec::from(&out_buf[..n])); out_queue.push_back(Vec::from(&out_buf[..n]));
did_something = true; did_something = true;
}, }
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {}, Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {}
Err(e) => return Err(EhError::Io(e)), Err(e) => return Err(EhError::Io(e)),
} }
match stderr.read(&mut err_buf) { match stderr.read(&mut err_buf) {
Ok(0) => {}, Ok(0) => {}
Ok(n) => { Ok(n) => {
interceptor.on_stderr(&err_buf[..n]); interceptor.on_stderr(&err_buf[..n]);
err_queue.push_back(Vec::from(&err_buf[..n])); err_queue.push_back(Vec::from(&err_buf[..n]));
did_something = true; did_something = true;
}, }
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {}, Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {}
Err(e) => return Err(EhError::Io(e)), Err(e) => return Err(EhError::Io(e)),
} }
@ -185,9 +167,7 @@ impl NixCommand {
let mut cmd = Command::new("nix"); let mut cmd = Command::new("nix");
cmd.arg(&self.subcommand); cmd.arg(&self.subcommand);
if self.print_build_logs if self.print_build_logs && !self.args.iter().any(|a| a == "--no-build-output") {
&& !self.args.iter().any(|a| a == "--no-build-output")
{
cmd.arg("--print-build-logs"); cmd.arg("--print-build-logs");
} }
if self.impure { if self.impure {

View file

@ -11,7 +11,6 @@ pub use error::{EhError, Result};
#[derive(Parser)] #[derive(Parser)]
#[command(name = "eh")] #[command(name = "eh")]
#[command(about = "Ergonomic Nix helper", long_about = None)] #[command(about = "Ergonomic Nix helper", long_about = None)]
#[command(version)]
pub struct Cli { pub struct Cli {
#[command(subcommand)] #[command(subcommand)]
pub command: Option<Command>, pub command: Option<Command>,

View file

@ -1,7 +1,7 @@
use std::{env, path::Path};
use eh::{Cli, Command, CommandFactory, Parser}; use eh::{Cli, Command, CommandFactory, Parser};
use error::Result; use error::Result;
use std::env;
use std::path::Path;
mod build; mod build;
mod command; mod command;
@ -26,45 +26,36 @@ fn main() {
Err(e) => { Err(e) => {
eprintln!("Error: {e}"); eprintln!("Error: {e}");
std::process::exit(e.exit_code()); std::process::exit(e.exit_code());
}, }
} }
} }
// Design partially taken from Stash // Design partially taken from Stash
fn dispatch_multicall( fn dispatch_multicall(app_name: &str, args: std::env::Args) -> Option<Result<i32>> {
app_name: &str,
args: std::env::Args,
) -> Option<Result<i32>> {
let rest: Vec<String> = args.collect(); let rest: Vec<String> = args.collect();
let hash_extractor = util::RegexHashExtractor; let hash_extractor = util::RegexHashExtractor;
let fixer = util::DefaultNixFileFixer; let fixer = util::DefaultNixFileFixer;
let classifier = util::DefaultNixErrorClassifier; let classifier = util::DefaultNixErrorClassifier;
match app_name { match app_name {
"nr" => { "nr" => Some(run::handle_nix_run(
Some(run::handle_nix_run(
&rest, &rest,
&hash_extractor, &hash_extractor,
&fixer, &fixer,
&classifier, &classifier,
)) )),
}, "ns" => Some(shell::handle_nix_shell(
"ns" => {
Some(shell::handle_nix_shell(
&rest, &rest,
&hash_extractor, &hash_extractor,
&fixer, &fixer,
&classifier, &classifier,
)) )),
}, "nb" => Some(build::handle_nix_build(
"nb" => {
Some(build::handle_nix_build(
&rest, &rest,
&hash_extractor, &hash_extractor,
&fixer, &fixer,
&classifier, &classifier,
)) )),
},
_ => None, _ => None,
} }
} }
@ -91,20 +82,20 @@ fn run_app() -> Result<i32> {
match cli.command { match cli.command {
Some(Command::Run { args }) => { Some(Command::Run { args }) => {
run::handle_nix_run(&args, &hash_extractor, &fixer, &classifier) run::handle_nix_run(&args, &hash_extractor, &fixer, &classifier)
}, }
Some(Command::Shell { args }) => { Some(Command::Shell { args }) => {
shell::handle_nix_shell(&args, &hash_extractor, &fixer, &classifier) shell::handle_nix_shell(&args, &hash_extractor, &fixer, &classifier)
}, }
Some(Command::Build { args }) => { Some(Command::Build { args }) => {
build::handle_nix_build(&args, &hash_extractor, &fixer, &classifier) build::handle_nix_build(&args, &hash_extractor, &fixer, &classifier)
}, }
_ => { _ => {
Cli::command().print_help()?; Cli::command().print_help()?;
println!(); println!();
Ok(0) Ok(0)
}, }
} }
} }

View file

@ -1,12 +1,5 @@
use crate::{ use crate::error::Result;
error::Result, use crate::util::{HashExtractor, NixErrorClassifier, NixFileFixer, handle_nix_with_retry};
util::{
HashExtractor,
NixErrorClassifier,
NixFileFixer,
handle_nix_with_retry,
},
};
pub fn handle_nix_run( pub fn handle_nix_run(
args: &[String], args: &[String],

View file

@ -1,12 +1,5 @@
use crate::{ use crate::error::Result;
error::Result, use crate::util::{HashExtractor, NixErrorClassifier, NixFileFixer, handle_nix_with_retry};
util::{
HashExtractor,
NixErrorClassifier,
NixFileFixer,
handle_nix_with_retry,
},
};
pub fn handle_nix_shell( pub fn handle_nix_shell(
args: &[String], args: &[String],

View file

@ -1,19 +1,13 @@
use std::{ use crate::command::{NixCommand, StdIoInterceptor};
fs, use crate::error::{EhError, Result};
io::Write,
path::{Path, PathBuf},
};
use regex::Regex; use regex::Regex;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use tracing::{info, warn}; use tracing::{info, warn};
use walkdir::WalkDir; use walkdir::WalkDir;
use yansi::Paint; use yansi::Paint;
use crate::{
command::{NixCommand, StdIoInterceptor},
error::{EhError, Result},
};
pub trait HashExtractor { pub trait HashExtractor {
fn extract_hash(&self, stderr: &str) -> Option<String>; fn extract_hash(&self, stderr: &str) -> Option<String>;
} }
@ -76,10 +70,11 @@ impl NixFileFixer for DefaultNixFileFixer {
.collect(); .collect();
if files.is_empty() { if files.is_empty() {
return Err(EhError::NoNixFilesFound); Err(EhError::NoNixFilesFound)
} } else {
Ok(files) Ok(files)
} }
}
fn fix_hash_in_file(&self, file_path: &Path, new_hash: &str) -> Result<bool> { fn fix_hash_in_file(&self, file_path: &Path, new_hash: &str) -> Result<bool> {
let content = fs::read_to_string(file_path)?; let content = fs::read_to_string(file_path)?;
@ -106,10 +101,8 @@ impl NixFileFixer for DefaultNixFileFixer {
} }
} }
if replaced { if replaced {
fs::write(file_path, new_content).map_err(|_| { fs::write(file_path, new_content).map_err(|_| EhError::HashFixFailed {
EhError::HashFixFailed {
path: file_path.to_string_lossy().to_string(), path: file_path.to_string_lossy().to_string(),
}
})?; })?;
Ok(true) Ok(true)
} else { } else {
@ -174,11 +167,13 @@ pub fn handle_nix_with_retry(
// For run commands, try interactive first to avoid breaking terminal // For run commands, try interactive first to avoid breaking terminal
if subcommand == "run" && interactive { if subcommand == "run" && interactive {
let status = NixCommand::new(subcommand) let mut cmd = NixCommand::new(subcommand)
.print_build_logs(true) .print_build_logs(true)
.interactive(true) .interactive(true);
.args_ref(args) for arg in args {
.run_with_logs(StdIoInterceptor)?; cmd = cmd.arg(arg);
}
let status = cmd.run_with_logs(StdIoInterceptor)?;
if status.success() { if status.success() {
return Ok(0); return Ok(0);
} }
@ -187,7 +182,7 @@ pub fn handle_nix_with_retry(
// First, always capture output to check for errors that need retry // First, always capture output to check for errors that need retry
let output_cmd = NixCommand::new(subcommand) let output_cmd = NixCommand::new(subcommand)
.print_build_logs(true) .print_build_logs(true)
.args_ref(args); .args(args.iter().cloned());
let output = output_cmd.output()?; let output = output_cmd.output()?;
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
@ -198,27 +193,26 @@ pub fn handle_nix_with_retry(
info!("{}", Paint::green("✔ Fixed hash mismatch, retrying...")); info!("{}", Paint::green("✔ Fixed hash mismatch, retrying..."));
let mut retry_cmd = NixCommand::new(subcommand) let mut retry_cmd = NixCommand::new(subcommand)
.print_build_logs(true) .print_build_logs(true)
.args_ref(args); .args(args.iter().cloned());
if interactive { if interactive {
retry_cmd = retry_cmd.interactive(true); retry_cmd = retry_cmd.interactive(true);
} }
let retry_status = retry_cmd.run_with_logs(StdIoInterceptor)?; let retry_status = retry_cmd.run_with_logs(StdIoInterceptor)?;
return Ok(retry_status.code().unwrap_or(1)); return Ok(retry_status.code().unwrap_or(1));
}, }
Ok(false) => { Ok(false) => {
// No files were fixed, continue with normal error handling // No files were fixed, continue with normal error handling
}, }
Err(EhError::NoNixFilesFound) => { Err(EhError::NoNixFilesFound) => {
warn!("No .nix files found to fix hash in"); warn!("No .nix files found to fix hash in");
// Continue with normal error handling // Continue with normal error handling
}, }
Err(e) => { Err(e) => {
return Err(e); return Err(e);
}, }
} }
} else if stderr.contains("hash") || stderr.contains("sha256") { } else if stderr.contains("hash") || stderr.contains("sha256") {
// If there's a hash-related error but we couldn't extract it, that's a // If there's a hash-related error but we couldn't extract it, that's a failure
// failure
return Err(EhError::HashExtractionFailed); return Err(EhError::HashExtractionFailed);
} }
@ -226,13 +220,11 @@ pub fn handle_nix_with_retry(
if stderr.contains("has an unfree license") && stderr.contains("refusing") { if stderr.contains("has an unfree license") && stderr.contains("refusing") {
warn!( warn!(
"{}", "{}",
Paint::yellow( Paint::yellow("⚠ Unfree package detected, retrying with NIXPKGS_ALLOW_UNFREE=1...")
"⚠ Unfree package detected, retrying with NIXPKGS_ALLOW_UNFREE=1..."
)
); );
let mut retry_cmd = NixCommand::new(subcommand) let mut retry_cmd = NixCommand::new(subcommand)
.print_build_logs(true) .print_build_logs(true)
.args_ref(args) .args(args.iter().cloned())
.env("NIXPKGS_ALLOW_UNFREE", "1") .env("NIXPKGS_ALLOW_UNFREE", "1")
.impure(true); .impure(true);
if interactive { if interactive {
@ -241,19 +233,16 @@ pub fn handle_nix_with_retry(
let retry_status = retry_cmd.run_with_logs(StdIoInterceptor)?; let retry_status = retry_cmd.run_with_logs(StdIoInterceptor)?;
return Ok(retry_status.code().unwrap_or(1)); return Ok(retry_status.code().unwrap_or(1));
} }
if stderr.contains("has been marked as insecure") if stderr.contains("has been marked as insecure") && stderr.contains("refusing") {
&& stderr.contains("refusing")
{
warn!( warn!(
"{}", "{}",
Paint::yellow( Paint::yellow(
"⚠ Insecure package detected, retrying with \ "⚠ Insecure package detected, retrying with NIXPKGS_ALLOW_INSECURE=1..."
NIXPKGS_ALLOW_INSECURE=1..."
) )
); );
let mut retry_cmd = NixCommand::new(subcommand) let mut retry_cmd = NixCommand::new(subcommand)
.print_build_logs(true) .print_build_logs(true)
.args_ref(args) .args(args.iter().cloned())
.env("NIXPKGS_ALLOW_INSECURE", "1") .env("NIXPKGS_ALLOW_INSECURE", "1")
.impure(true); .impure(true);
if interactive { if interactive {
@ -262,18 +251,14 @@ pub fn handle_nix_with_retry(
let retry_status = retry_cmd.run_with_logs(StdIoInterceptor)?; let retry_status = retry_cmd.run_with_logs(StdIoInterceptor)?;
return Ok(retry_status.code().unwrap_or(1)); return Ok(retry_status.code().unwrap_or(1));
} }
if stderr.contains("has been marked as broken") if stderr.contains("has been marked as broken") && stderr.contains("refusing") {
&& stderr.contains("refusing")
{
warn!( warn!(
"{}", "{}",
Paint::yellow( Paint::yellow("⚠ Broken package detected, retrying with NIXPKGS_ALLOW_BROKEN=1...")
"⚠ Broken package detected, retrying with NIXPKGS_ALLOW_BROKEN=1..."
)
); );
let mut retry_cmd = NixCommand::new(subcommand) let mut retry_cmd = NixCommand::new(subcommand)
.print_build_logs(true) .print_build_logs(true)
.args_ref(args) .args(args.iter().cloned())
.env("NIXPKGS_ALLOW_BROKEN", "1") .env("NIXPKGS_ALLOW_BROKEN", "1")
.impure(true); .impure(true);
if interactive { if interactive {
@ -290,9 +275,7 @@ pub fn handle_nix_with_retry(
} }
// Otherwise, show the error and return error // Otherwise, show the error and return error
std::io::stderr() std::io::stderr().write_all(&output.stderr)?;
.write_all(&output.stderr)
.map_err(EhError::Io)?;
Err(EhError::ProcessExit { Err(EhError::ProcessExit {
code: output.status.code().unwrap_or(1), code: output.status.code().unwrap_or(1),
}) })
@ -302,11 +285,8 @@ pub struct DefaultNixErrorClassifier;
impl NixErrorClassifier for DefaultNixErrorClassifier { impl NixErrorClassifier for DefaultNixErrorClassifier {
fn should_retry(&self, stderr: &str) -> bool { fn should_retry(&self, stderr: &str) -> bool {
RegexHashExtractor.extract_hash(stderr).is_some() RegexHashExtractor.extract_hash(stderr).is_some()
|| (stderr.contains("has an unfree license") || (stderr.contains("has an unfree license") && stderr.contains("refusing"))
&& stderr.contains("refusing")) || (stderr.contains("has been marked as insecure") && stderr.contains("refusing"))
|| (stderr.contains("has been marked as insecure") || (stderr.contains("has been marked as broken") && stderr.contains("refusing"))
&& stderr.contains("refusing"))
|| (stderr.contains("has been marked as broken")
&& stderr.contains("refusing"))
} }
} }

View file

@ -1,168 +1,13 @@
use std::{fs, process::Command}; //! I hate writing tests, and I hate writing integration tests. This is the best
//! that you are getting, deal with it.
use eh::util::{ use std::process::{Command, Stdio};
DefaultNixErrorClassifier,
DefaultNixFileFixer,
HashExtractor,
NixErrorClassifier,
NixFileFixer,
RegexHashExtractor,
};
use tempfile::TempDir;
#[test] #[test]
fn test_hash_extraction_from_real_nix_errors() { fn nix_eval_validation() {
// Test hash extraction from actual Nix error messages // Test that invalid expressions are caught early for all commands
let extractor = RegexHashExtractor; let commands = ["build", "run", "shell"];
let test_cases = [ for cmd in &commands {
(
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 (input, expected) in test_cases {
assert_eq!(extractor.extract_hash(input), expected);
}
}
#[test]
fn test_error_classification_for_retry_logic() {
// Test that the classifier correctly identifies errors that should be retried
let classifier = DefaultNixErrorClassifier;
// 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",
];
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 {
assert!(
!classifier.should_retry(error),
"Should not retry: {}",
error
);
}
}
#[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;
// 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";
};
buildInputs = [ fetchurl {
url = "https://deps.com.tar.gz";
sha256 = "sha256-oldhash456";
}];
outputHash = "sha256-oldhash789";
}
"#;
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
);
// 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") let output = Command::new("timeout")
.args([ .args([
"10", "10",
@ -171,64 +16,163 @@ fn test_invalid_expression_handling() {
"--bin", "--bin",
"eh", "eh",
"--", "--",
"build", cmd,
invalid_ref, "invalid-flake-ref",
]) ])
.output() .output()
.expect("Failed to execute command"); .expect("Failed to execute command");
// Should fail with a proper error, not hang or crash // Should fail fast with eval error
assert!(
!output.status.success(),
"Invalid ref '{}' should fail",
invalid_ref
);
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
assert!( assert!(stderr.contains("Error: Expression evaluation failed") || !output.status.success());
stderr.contains("Error:")
|| stderr.contains("error:")
|| stderr.contains("failed"),
"Should show error message for invalid ref '{}': {}",
invalid_ref,
stderr
);
} }
} }
#[test] #[test]
fn test_nix_file_discovery() { fn unfree_package_handling() {
// Test that the fixer can find Nix files in a directory structure // Test that unfree packages are detected and handled correctly
let temp_dir = TempDir::new().expect("Failed to create temp dir"); let output = Command::new("timeout")
let fixer = DefaultNixFileFixer; .args([
"30",
"cargo",
"run",
"--bin",
"eh",
"--",
"build",
"nixpkgs#discord",
])
.output()
.expect("Failed to execute command");
// Create directory structure with Nix files let stderr = String::from_utf8_lossy(&output.stderr);
fs::create_dir_all(temp_dir.path().join("subdir")) let stdout = String::from_utf8_lossy(&output.stdout);
.expect("Failed to create subdir"); let combined = format!("{}{}", stdout, stderr);
let files = [ // Should detect unfree package and show appropriate message
("test.nix", "stdenv.mkDerivation { name = \"test\"; }"), assert!(
("subdir/other.nix", "pkgs.hello"), combined.contains("has an unfree license")
("not-nix.txt", "not a nix file"), || combined.contains("NIXPKGS_ALLOW_UNFREE")
("default.nix", "import ./test.nix"), || combined.contains("⚠ Unfree package detected")
]; );
}
for (path, content) in files {
fs::write(temp_dir.path().join(path), content) #[test]
.expect("Failed to write file"); fn insecure_package_handling() {
} // Test that error classification works for insecure packages
use eh::util::{DefaultNixErrorClassifier, NixErrorClassifier};
// Change to temp dir for file discovery
let original_dir = let classifier = DefaultNixErrorClassifier;
std::env::current_dir().expect("Failed to get current dir"); let stderr_insecure =
std::env::set_current_dir(temp_dir.path()) "Package 'example-1.0' has been marked as insecure, refusing to evaluate.";
.expect("Failed to change directory");
assert!(classifier.should_retry(stderr_insecure));
let found_files = fixer.find_nix_files().expect("Failed to find Nix files"); }
// Should find 3 .nix files (not the .txt file) #[test]
assert_eq!(found_files.len(), 3, "Should find exactly 3 .nix files"); fn broken_package_handling() {
// Test that error classification works for broken packages
// Restore original directory use eh::util::{DefaultNixErrorClassifier, NixErrorClassifier};
std::env::set_current_dir(original_dir).expect("Failed to restore directory");
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 extracted = extractor.extract_hash(stderr_with_mismatch);
assert_eq!(
extracted,
Some("sha256-newhashbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb=".to_string())
);
} }

View file

@ -1,26 +1,22 @@
{ {
mkShell, mkShell,
rustc, rust-analyzer,
cargo,
rustfmt, rustfmt,
clippy, clippy,
cargo,
taplo, taplo,
rust-analyzer-unwrapped,
rustPlatform, rustPlatform,
}: }:
mkShell { mkShell {
name = "rust"; name = "rust";
packages = [ packages = [
rustc rust-analyzer
cargo rustfmt
(rustfmt.override {asNightly = true;})
clippy clippy
cargo cargo
taplo taplo
rust-analyzer-unwrapped
]; ];
env.RUST_SRC_PATH = "${rustPlatform.rustLibSrc}"; RUST_SRC_PATH = "${rustPlatform.rustLibSrc}";
} }

View file

@ -1,6 +1,5 @@
use std::{ use std::{
error, error, fs,
fs,
path::{Path, PathBuf}, path::{Path, PathBuf},
process, process,
}; };
@ -66,13 +65,13 @@ fn main() {
eprintln!("error creating multicall binaries: {error}"); eprintln!("error creating multicall binaries: {error}");
process::exit(1); process::exit(1);
} }
}, }
Command::Completions { shell, output_dir } => { Command::Completions { shell, output_dir } => {
if let Err(error) = generate_completions(shell, &output_dir) { if let Err(error) = generate_completions(shell, &output_dir) {
eprintln!("error generating completions: {error}"); eprintln!("error generating completions: {error}");
process::exit(1); process::exit(1);
} }
}, }
} }
} }
@ -85,9 +84,7 @@ fn create_multicall_binaries(
fs::create_dir_all(bin_dir)?; fs::create_dir_all(bin_dir)?;
if !main_binary.exists() { if !main_binary.exists() {
return Err( return Err(format!("main binary not found at: {}", main_binary.display()).into());
format!("main binary not found at: {}", main_binary.display()).into(),
);
} }
let multicall_binaries = [Binary::Nr, Binary::Ns, Binary::Nb]; let multicall_binaries = [Binary::Nr, Binary::Ns, Binary::Nb];
@ -100,7 +97,15 @@ fn create_multicall_binaries(
fs::remove_file(&target_path)?; fs::remove_file(&target_path)?;
} }
if let Err(e) = fs::hard_link(main_binary, &target_path) { match fs::hard_link(main_binary, &target_path) {
Ok(()) => {
println!(
" created hardlink: {} points to {}",
target_path.display(),
main_binary.display(),
);
}
Err(e) => {
eprintln!( eprintln!(
" warning: could not create hardlink for {}: {e}", " warning: could not create hardlink for {}: {e}",
binary.name(), binary.name(),
@ -118,12 +123,7 @@ fn create_multicall_binaries(
} }
println!(" created copy: {}", target_path.display()); println!(" created copy: {}", target_path.display());
} else { }
println!(
" created hardlink: {} points to {}",
target_path.display(),
main_binary.display(),
);
} }
} }
@ -134,10 +134,7 @@ fn create_multicall_binaries(
Ok(()) Ok(())
} }
fn generate_completions( fn generate_completions(shell: Shell, output_dir: &Path) -> Result<(), Box<dyn error::Error>> {
shell: Shell,
output_dir: &Path,
) -> Result<(), Box<dyn error::Error>> {
println!("generating {shell} completions..."); println!("generating {shell} completions...");
fs::create_dir_all(output_dir)?; fs::create_dir_all(output_dir)?;