chore: use nightly rustfmt rules

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ia3745c82c92a78e8326d2e0e446f20d66a6a6964
This commit is contained in:
raf 2025-11-14 21:47:33 +03:00
commit c3321858c4
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
11 changed files with 912 additions and 799 deletions

View file

@ -1 +1,27 @@
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

View file

@ -1,11 +1,18 @@
use crate::error::Result; use crate::{
use crate::util::{HashExtractor, NixErrorClassifier, NixFileFixer, handle_nix_with_retry}; error::Result,
util::{
HashExtractor,
NixErrorClassifier,
NixFileFixer,
handle_nix_with_retry,
},
};
pub fn handle_nix_build( pub fn handle_nix_build(
args: &[String], args: &[String],
hash_extractor: &dyn HashExtractor, hash_extractor: &dyn HashExtractor,
fixer: &dyn NixFileFixer, fixer: &dyn NixFileFixer,
classifier: &dyn NixErrorClassifier, classifier: &dyn NixErrorClassifier,
) -> Result<i32> { ) -> Result<i32> {
handle_nix_with_retry("build", args, hash_extractor, fixer, classifier, false) handle_nix_with_retry("build", args, hash_extractor, fixer, classifier, false)
} }

View file

@ -1,24 +1,27 @@
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 {
fn on_stderr(&mut self, chunk: &[u8]); fn on_stderr(&mut self, chunk: &[u8]);
fn on_stdout(&mut self, chunk: &[u8]); fn on_stdout(&mut self, chunk: &[u8]);
} }
/// Default log interceptor that just writes to stdio. /// Default log interceptor that just writes to stdio.
pub struct StdIoInterceptor; pub struct StdIoInterceptor;
impl LogInterceptor for StdIoInterceptor { impl LogInterceptor for StdIoInterceptor {
fn on_stderr(&mut self, chunk: &[u8]) { fn on_stderr(&mut self, chunk: &[u8]) {
let _ = io::stderr().write_all(chunk); let _ = io::stderr().write_all(chunk);
} }
fn on_stdout(&mut self, chunk: &[u8]) { fn on_stdout(&mut self, chunk: &[u8]) {
let _ = io::stdout().write_all(chunk); let _ = io::stdout().write_all(chunk);
} }
} }
/// Default buffer size for reading command output /// Default buffer size for reading command output
@ -26,167 +29,179 @@ const DEFAULT_BUFFER_SIZE: usize = 4096;
/// Builder and executor for Nix commands. /// Builder and executor for Nix commands.
pub struct NixCommand { pub struct NixCommand {
subcommand: String, subcommand: String,
args: Vec<String>, args: Vec<String>,
env: Vec<(String, String)>, env: Vec<(String, String)>,
impure: bool, impure: bool,
print_build_logs: bool, print_build_logs: bool,
interactive: bool, interactive: bool,
} }
impl NixCommand { impl NixCommand {
pub fn new<S: Into<String>>(subcommand: S) -> Self { pub fn new<S: Into<String>>(subcommand: S) -> Self {
Self { Self {
subcommand: subcommand.into(), subcommand: subcommand.into(),
args: Vec::new(), args: Vec::new(),
env: Vec::new(), env: Vec::new(),
impure: false, impure: false,
print_build_logs: true, print_build_logs: true,
interactive: false, interactive: false,
}
} }
}
pub fn arg<S: Into<String>>(mut self, arg: S) -> Self { pub fn arg<S: Into<String>>(mut self, arg: S) -> Self {
self.args.push(arg.into()); self.args.push(arg.into());
self self
} }
pub fn args<I, S>(mut self, args: I) -> Self pub fn args<I, S>(mut self, args: I) -> Self
where where
I: IntoIterator<Item = S>, I: IntoIterator<Item = S>,
S: Into<String>, S: Into<String>,
{
self.args.extend(args.into_iter().map(Into::into));
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
}
#[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<I: LogInterceptor + 'static>(
&self,
mut interceptor: I,
) -> Result<ExitStatus> {
let mut cmd = Command::new("nix");
cmd.arg(&self.subcommand);
if self.print_build_logs
&& !self.args.iter().any(|a| a == "--no-build-output")
{ {
self.args.extend(args.into_iter().map(Into::into)); cmd.arg("--print-build-logs");
self }
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()?);
} }
pub fn env<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self { cmd.stdout(Stdio::piped());
self.env.push((key.into(), value.into())); cmd.stderr(Stdio::piped());
self
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;
}
} }
#[must_use] let status = child.wait()?;
pub const fn impure(mut self, yes: bool) -> Self { Ok(status)
self.impure = yes; }
self
/// Run the command and capture all output.
pub fn output(&self) -> Result<Output> {
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] Ok(cmd.output()?)
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<I: LogInterceptor + 'static>(
&self,
mut interceptor: I,
) -> Result<ExitStatus> {
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<Output> {
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()?)
}
} }

View file

@ -2,44 +2,44 @@ use thiserror::Error;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum EhError { pub enum EhError {
#[error("Nix command failed: {0}")] #[error("Nix command failed: {0}")]
NixCommandFailed(String), NixCommandFailed(String),
#[error("IO error: {0}")] #[error("IO error: {0}")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[error("Regex error: {0}")] #[error("Regex error: {0}")]
Regex(#[from] regex::Error), Regex(#[from] regex::Error),
#[error("UTF-8 conversion error: {0}")] #[error("UTF-8 conversion error: {0}")]
Utf8(#[from] std::string::FromUtf8Error), Utf8(#[from] std::string::FromUtf8Error),
#[error("Hash extraction failed")] #[error("Hash extraction failed")]
HashExtractionFailed, HashExtractionFailed,
#[error("No Nix files found")] #[error("No Nix files found")]
NoNixFilesFound, NoNixFilesFound,
#[error("Failed to fix hash in file: {path}")] #[error("Failed to fix hash in file: {path}")]
HashFixFailed { path: String }, HashFixFailed { path: String },
#[error("Process exited with code: {code}")] #[error("Process exited with code: {code}")]
ProcessExit { code: i32 }, ProcessExit { code: i32 },
#[error("Command execution failed: {command}")] #[error("Command execution failed: {command}")]
CommandFailed { command: String }, CommandFailed { command: String },
} }
pub type Result<T> = std::result::Result<T, EhError>; pub type Result<T> = std::result::Result<T, EhError>;
impl EhError { impl EhError {
#[must_use] #[must_use]
pub const fn exit_code(&self) -> i32 { pub const fn exit_code(&self) -> i32 {
match self { match self {
Self::ProcessExit { code } => *code, Self::ProcessExit { code } => *code,
Self::NixCommandFailed(_) => 1, Self::NixCommandFailed(_) => 1,
Self::CommandFailed { .. } => 1, Self::CommandFailed { .. } => 1,
_ => 1, _ => 1,
}
} }
}
} }

View file

@ -13,25 +13,25 @@ pub use error::{EhError, Result};
#[command(about = "Ergonomic Nix helper", long_about = None)] #[command(about = "Ergonomic Nix helper", long_about = None)]
#[command(version)] #[command(version)]
pub struct Cli { pub struct Cli {
#[command(subcommand)] #[command(subcommand)]
pub command: Option<Command>, pub command: Option<Command>,
} }
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum Command { pub enum Command {
/// Run a Nix derivation /// Run a Nix derivation
Run { Run {
#[arg(trailing_var_arg = true)] #[arg(trailing_var_arg = true)]
args: Vec<String>, args: Vec<String>,
}, },
/// Enter a Nix shell /// Enter a Nix shell
Shell { Shell {
#[arg(trailing_var_arg = true)] #[arg(trailing_var_arg = true)]
args: Vec<String>, args: Vec<String>,
}, },
/// Build a Nix derivation /// Build a Nix derivation
Build { Build {
#[arg(trailing_var_arg = true)] #[arg(trailing_var_arg = true)]
args: Vec<String>, args: Vec<String>,
}, },
} }

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;
@ -11,91 +11,100 @@ mod shell;
mod util; mod util;
fn main() { 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_level(true) // don't include levels in formatted output
.with_target(true) // don't include targets .with_target(true) // don't include targets
.with_thread_ids(false) // include the thread ID of the current thread .with_thread_ids(false) // include the thread ID of the current thread
.with_thread_names(false) // include the name of the current thread .with_thread_names(false) // include the name of the current thread
.compact(); // use the `Compact` formatting style. .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 { match result {
Ok(code) => std::process::exit(code), Ok(code) => std::process::exit(code),
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(app_name: &str, args: std::env::Args) -> Option<Result<i32>> { fn dispatch_multicall(
let rest: Vec<String> = args.collect(); app_name: &str,
let hash_extractor = util::RegexHashExtractor; args: std::env::Args,
let fixer = util::DefaultNixFileFixer; ) -> Option<Result<i32>> {
let classifier = util::DefaultNixErrorClassifier; let rest: Vec<String> = args.collect();
let hash_extractor = util::RegexHashExtractor;
let fixer = util::DefaultNixFileFixer;
let classifier = util::DefaultNixErrorClassifier;
match app_name { match app_name {
"nr" => Some(run::handle_nix_run( "nr" => {
&rest, Some(run::handle_nix_run(
&hash_extractor, &rest,
&fixer, &hash_extractor,
&classifier, &fixer,
)), &classifier,
"ns" => Some(shell::handle_nix_shell( ))
&rest, },
&hash_extractor, "ns" => {
&fixer, Some(shell::handle_nix_shell(
&classifier, &rest,
)), &hash_extractor,
"nb" => Some(build::handle_nix_build( &fixer,
&rest, &classifier,
&hash_extractor, ))
&fixer, },
&classifier, "nb" => {
)), Some(build::handle_nix_build(
_ => None, &rest,
} &hash_extractor,
&fixer,
&classifier,
))
},
_ => None,
}
} }
fn run_app() -> Result<i32> { fn run_app() -> Result<i32> {
let mut args = env::args(); let mut args = env::args();
let bin = args.next().unwrap_or_else(|| "eh".to_string()); let bin = args.next().unwrap_or_else(|| "eh".to_string());
let app_name = Path::new(&bin) let app_name = Path::new(&bin)
.file_name() .file_name()
.and_then(|name| name.to_str()) .and_then(|name| name.to_str())
.unwrap_or("eh"); .unwrap_or("eh");
// If invoked as nr/ns/nb, dispatch directly and exit // If invoked as nr/ns/nb, dispatch directly and exit
if let Some(result) = dispatch_multicall(app_name, args) { if let Some(result) = dispatch_multicall(app_name, args) {
return result; return result;
} }
let cli = Cli::parse(); let cli = Cli::parse();
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 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,11 +1,18 @@
use crate::error::Result; use crate::{
use crate::util::{HashExtractor, NixErrorClassifier, NixFileFixer, handle_nix_with_retry}; error::Result,
util::{
HashExtractor,
NixErrorClassifier,
NixFileFixer,
handle_nix_with_retry,
},
};
pub fn handle_nix_run( pub fn handle_nix_run(
args: &[String], args: &[String],
hash_extractor: &dyn HashExtractor, hash_extractor: &dyn HashExtractor,
fixer: &dyn NixFileFixer, fixer: &dyn NixFileFixer,
classifier: &dyn NixErrorClassifier, classifier: &dyn NixErrorClassifier,
) -> Result<i32> { ) -> Result<i32> {
handle_nix_with_retry("run", args, hash_extractor, fixer, classifier, true) handle_nix_with_retry("run", args, hash_extractor, fixer, classifier, true)
} }

View file

@ -1,11 +1,18 @@
use crate::error::Result; use crate::{
use crate::util::{HashExtractor, NixErrorClassifier, NixFileFixer, handle_nix_with_retry}; error::Result,
util::{
HashExtractor,
NixErrorClassifier,
NixFileFixer,
handle_nix_with_retry,
},
};
pub fn handle_nix_shell( pub fn handle_nix_shell(
args: &[String], args: &[String],
hash_extractor: &dyn HashExtractor, hash_extractor: &dyn HashExtractor,
fixer: &dyn NixFileFixer, fixer: &dyn NixFileFixer,
classifier: &dyn NixErrorClassifier, classifier: &dyn NixErrorClassifier,
) -> Result<i32> { ) -> Result<i32> {
handle_nix_with_retry("shell", args, hash_extractor, fixer, classifier, true) handle_nix_with_retry("shell", args, hash_extractor, fixer, classifier, true)
} }

View file

@ -1,293 +1,314 @@
use crate::command::{NixCommand, StdIoInterceptor}; use std::{
use crate::error::{EhError, Result}; fs,
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>;
} }
pub struct RegexHashExtractor; pub struct RegexHashExtractor;
impl HashExtractor for RegexHashExtractor { impl HashExtractor for RegexHashExtractor {
fn extract_hash(&self, stderr: &str) -> Option<String> { fn extract_hash(&self, stderr: &str) -> Option<String> {
let patterns = [ let patterns = [
r"got:\s+(sha256-[a-zA-Z0-9+/=]+)", r"got:\s+(sha256-[a-zA-Z0-9+/=]+)",
r"actual:\s+(sha256-[a-zA-Z0-9+/=]+)", r"actual:\s+(sha256-[a-zA-Z0-9+/=]+)",
r"have:\s+(sha256-[a-zA-Z0-9+/=]+)", r"have:\s+(sha256-[a-zA-Z0-9+/=]+)",
]; ];
for pattern in &patterns { for pattern in &patterns {
if let Ok(re) = Regex::new(pattern) if let Ok(re) = Regex::new(pattern)
&& let Some(captures) = re.captures(stderr) && let Some(captures) = re.captures(stderr)
&& let Some(hash) = captures.get(1) && let Some(hash) = captures.get(1)
{ {
return Some(hash.as_str().to_string()); return Some(hash.as_str().to_string());
} }
}
None
} }
None
}
} }
pub trait NixFileFixer { pub trait NixFileFixer {
fn fix_hash_in_files(&self, new_hash: &str) -> Result<bool>; fn fix_hash_in_files(&self, new_hash: &str) -> Result<bool>;
fn find_nix_files(&self) -> Result<Vec<PathBuf>>; fn find_nix_files(&self) -> Result<Vec<PathBuf>>;
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>;
} }
pub struct DefaultNixFileFixer; pub struct DefaultNixFileFixer;
impl NixFileFixer for DefaultNixFileFixer { impl NixFileFixer for DefaultNixFileFixer {
fn fix_hash_in_files(&self, new_hash: &str) -> Result<bool> { fn fix_hash_in_files(&self, new_hash: &str) -> Result<bool> {
let nix_files = self.find_nix_files()?; let nix_files = self.find_nix_files()?;
let mut fixed = false; let mut fixed = false;
for file_path in nix_files { for file_path in nix_files {
if self.fix_hash_in_file(&file_path, new_hash)? { if self.fix_hash_in_file(&file_path, new_hash)? {
println!("Updated hash in {}", file_path.display()); println!("Updated hash in {}", file_path.display());
fixed = true; fixed = true;
} }
}
Ok(fixed)
} }
Ok(fixed)
}
fn find_nix_files(&self) -> Result<Vec<PathBuf>> { fn find_nix_files(&self) -> Result<Vec<PathBuf>> {
let files: Vec<PathBuf> = WalkDir::new(".") let files: Vec<PathBuf> = WalkDir::new(".")
.into_iter() .into_iter()
.filter_map(|entry| entry.ok()) .filter_map(|entry| entry.ok())
.filter(|entry| { .filter(|entry| {
entry.file_type().is_file() entry.file_type().is_file()
&& entry && entry
.path() .path()
.extension() .extension()
.map(|ext| ext.eq_ignore_ascii_case("nix")) .map(|ext| ext.eq_ignore_ascii_case("nix"))
.unwrap_or(false) .unwrap_or(false)
}) })
.map(|entry| entry.path().to_path_buf()) .map(|entry| entry.path().to_path_buf())
.collect(); .collect();
if files.is_empty() { if files.is_empty() {
return Err(EhError::NoNixFilesFound); return Err(EhError::NoNixFilesFound);
}
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)?;
let patterns = [ let patterns = [
(r#"hash\s*=\s*"[^"]*""#, format!(r#"hash = "{new_hash}""#)), (r#"hash\s*=\s*"[^"]*""#, format!(r#"hash = "{new_hash}""#)),
( (
r#"sha256\s*=\s*"[^"]*""#, r#"sha256\s*=\s*"[^"]*""#,
format!(r#"sha256 = "{new_hash}""#), format!(r#"sha256 = "{new_hash}""#),
), ),
( (
r#"outputHash\s*=\s*"[^"]*""#, r#"outputHash\s*=\s*"[^"]*""#,
format!(r#"outputHash = "{new_hash}""#), format!(r#"outputHash = "{new_hash}""#),
), ),
]; ];
let mut new_content = content; let mut new_content = content;
let mut replaced = false; let mut replaced = false;
for (pattern, replacement) in &patterns { for (pattern, replacement) in &patterns {
let re = Regex::new(pattern)?; let re = Regex::new(pattern)?;
if re.is_match(&new_content) { if re.is_match(&new_content) {
new_content = re new_content = re
.replace_all(&new_content, replacement.as_str()) .replace_all(&new_content, replacement.as_str())
.into_owned(); .into_owned();
replaced = true; 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)
}
} }
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)
}
}
} }
pub trait NixErrorClassifier { pub trait NixErrorClassifier {
fn should_retry(&self, stderr: &str) -> bool; fn should_retry(&self, stderr: &str) -> bool;
} }
/// Pre-evaluate expression to catch errors early /// Pre-evaluate expression to catch errors early
fn pre_evaluate(_subcommand: &str, args: &[String]) -> Result<bool> { fn pre_evaluate(_subcommand: &str, args: &[String]) -> Result<bool> {
// Find flake references or expressions to evaluate // Find flake references or expressions to evaluate
// Only take the first non-flag argument (the package/expression) // Only take the first non-flag argument (the package/expression)
let eval_arg = args.iter().find(|arg| !arg.starts_with('-')); let eval_arg = args.iter().find(|arg| !arg.starts_with('-'));
let Some(eval_arg) = eval_arg else { let Some(eval_arg) = eval_arg else {
return Ok(true); // No expression to evaluate 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() { if output.status.success() {
return Ok(true); 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 // If eval fails due to unfree/insecure/broken, don't fail pre-evaluation
// Let the main command handle it with retry logic // Let the main command handle it with retry logic
if stderr.contains("has an unfree license") if stderr.contains("has an unfree license")
|| stderr.contains("refusing to evaluate") || stderr.contains("refusing to evaluate")
|| stderr.contains("has been marked as insecure") || stderr.contains("has been marked as insecure")
|| stderr.contains("has been marked as broken") || stderr.contains("has been marked as broken")
{ {
return Ok(true); return Ok(true);
} }
// For other eval failures, fail early // For other eval failures, fail early
Ok(false) Ok(false)
} }
/// Shared retry logic for nix commands (build/run/shell). /// Shared retry logic for nix commands (build/run/shell).
pub fn handle_nix_with_retry( pub fn handle_nix_with_retry(
subcommand: &str, subcommand: &str,
args: &[String], args: &[String],
hash_extractor: &dyn HashExtractor, hash_extractor: &dyn HashExtractor,
fixer: &dyn NixFileFixer, fixer: &dyn NixFileFixer,
classifier: &dyn NixErrorClassifier, classifier: &dyn NixErrorClassifier,
interactive: bool, interactive: bool,
) -> Result<i32> { ) -> Result<i32> {
// Pre-evaluate for build commands to catch errors early // Pre-evaluate for build commands to catch errors early
if !pre_evaluate(subcommand, args)? { if !pre_evaluate(subcommand, args)? {
return Err(EhError::NixCommandFailed( return Err(EhError::NixCommandFailed(
"Expression evaluation failed".to_string(), "Expression evaluation failed".to_string(),
)); ));
} }
// 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 mut cmd = NixCommand::new(subcommand) let mut cmd = NixCommand::new(subcommand)
.print_build_logs(true) .print_build_logs(true)
.interactive(true); .interactive(true);
for arg in args { for arg in args {
cmd = cmd.arg(arg); cmd = cmd.arg(arg);
}
let status = cmd.run_with_logs(StdIoInterceptor)?;
if status.success() {
return Ok(0);
}
} }
let status = cmd.run_with_logs(StdIoInterceptor)?;
if status.success() {
return Ok(0);
}
}
// 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)
.args(args.iter().cloned());
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(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);
}
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) .print_build_logs(true)
.args(args.iter().cloned()); .args(args.iter().cloned())
let output = output_cmd.output()?; .env("NIXPKGS_ALLOW_UNFREE", "1")
let stderr = String::from_utf8_lossy(&output.stderr); .impure(true);
if interactive {
// Check if we need to retry with special flags retry_cmd = retry_cmd.interactive(true);
if let Some(new_hash) = hash_extractor.extract_hash(&stderr) { }
match fixer.fix_hash_in_files(&new_hash) { let retry_status = retry_cmd.run_with_logs(StdIoInterceptor)?;
Ok(true) => { return Ok(retry_status.code().unwrap_or(1));
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);
} }
if stderr.contains("has been marked as insecure")
if classifier.should_retry(&stderr) { && stderr.contains("refusing")
if stderr.contains("has an unfree license") && stderr.contains("refusing") { {
warn!( warn!(
"{}", "{}",
Paint::yellow("⚠ Unfree package detected, retrying with NIXPKGS_ALLOW_UNFREE=1...") Paint::yellow(
); "⚠ Insecure package detected, retrying with \
let mut retry_cmd = NixCommand::new(subcommand) NIXPKGS_ALLOW_INSECURE=1..."
.print_build_logs(true) )
.args(args.iter().cloned()) );
.env("NIXPKGS_ALLOW_UNFREE", "1") let mut retry_cmd = NixCommand::new(subcommand)
.impure(true); .print_build_logs(true)
if interactive { .args(args.iter().cloned())
retry_cmd = retry_cmd.interactive(true); .env("NIXPKGS_ALLOW_INSECURE", "1")
} .impure(true);
let retry_status = retry_cmd.run_with_logs(StdIoInterceptor)?; if interactive {
return Ok(retry_status.code().unwrap_or(1)); retry_cmd = retry_cmd.interactive(true);
} }
if stderr.contains("has been marked as insecure") && stderr.contains("refusing") { let retry_status = retry_cmd.run_with_logs(StdIoInterceptor)?;
warn!( return Ok(retry_status.code().unwrap_or(1));
"{}",
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 stderr.contains("has been marked as broken")
// If the first attempt succeeded, we're done && stderr.contains("refusing")
if output.status.success() { {
return Ok(0); 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));
} }
}
// Otherwise, show the error and return error // If the first attempt succeeded, we're done
std::io::stderr() if output.status.success() {
.write_all(&output.stderr) return Ok(0);
.map_err(EhError::Io)?; }
Err(EhError::ProcessExit {
code: output.status.code().unwrap_or(1), // 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),
})
} }
pub struct DefaultNixErrorClassifier; 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("refusing")) || (stderr.contains("has an unfree license")
|| (stderr.contains("has been marked as insecure") && stderr.contains("refusing")) && stderr.contains("refusing"))
|| (stderr.contains("has been marked as broken") && stderr.contains("refusing")) || (stderr.contains("has been marked as insecure")
} && stderr.contains("refusing"))
|| (stderr.contains("has been marked as broken")
&& stderr.contains("refusing"))
}
} }

View file

@ -1,78 +1,84 @@
use std::{fs, process::Command};
use eh::util::{ use eh::util::{
DefaultNixErrorClassifier, DefaultNixFileFixer, HashExtractor, NixErrorClassifier, DefaultNixErrorClassifier,
NixFileFixer, RegexHashExtractor, DefaultNixFileFixer,
HashExtractor,
NixErrorClassifier,
NixFileFixer,
RegexHashExtractor,
}; };
use std::fs;
use std::process::Command;
use tempfile::TempDir; use tempfile::TempDir;
#[test] #[test]
fn test_hash_extraction_from_real_nix_errors() { fn test_hash_extraction_from_real_nix_errors() {
// Test hash extraction from actual Nix error messages // Test hash extraction from actual Nix error messages
let extractor = RegexHashExtractor; let extractor = RegexHashExtractor;
let test_cases = [ let test_cases = [
( (
r#"error: hash mismatch in fixed-output derivation '/nix/store/xxx-foo.drv': r#"error: hash mismatch in fixed-output derivation '/nix/store/xxx-foo.drv':
specified: sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= specified: sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
got: sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="#, got: sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="#,
Some("sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=".to_string()), Some("sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=".to_string()),
), ),
( (
"actual: sha256-abc123def456", "actual: sha256-abc123def456",
Some("sha256-abc123def456".to_string()), Some("sha256-abc123def456".to_string()),
), ),
("have: sha256-xyz789", Some("sha256-xyz789".to_string())), ("have: sha256-xyz789", Some("sha256-xyz789".to_string())),
("no hash here", None), ("no hash here", None),
]; ];
for (input, expected) in test_cases { for (input, expected) in test_cases {
assert_eq!(extractor.extract_hash(input), expected); assert_eq!(extractor.extract_hash(input), expected);
} }
} }
#[test] #[test]
fn test_error_classification_for_retry_logic() { fn test_error_classification_for_retry_logic() {
// Test that the classifier correctly identifies errors that should be retried // Test that the classifier correctly identifies errors that should be retried
let classifier = DefaultNixErrorClassifier; let classifier = DefaultNixErrorClassifier;
// These should trigger retries // These should trigger retries
let retry_cases = [ let retry_cases = [
"Package 'discord-1.0.0' has an unfree license ('unfree'), refusing to evaluate.", "Package 'discord-1.0.0' has an unfree license ('unfree'), refusing to \
"Package 'openssl-1.1.1' has been marked as insecure, refusing to evaluate.", evaluate.",
"Package 'broken-1.0' has been marked as broken, refusing to evaluate.", "Package 'openssl-1.1.1' has been marked as insecure, refusing to \
"hash mismatch in fixed-output derivation\ngot: sha256-newhash", 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 { for error in retry_cases {
assert!(classifier.should_retry(error), "Should retry: {}", error); assert!(classifier.should_retry(error), "Should retry: {}", error);
} }
// These should NOT trigger retries // These should NOT trigger retries
let no_retry_cases = [ let no_retry_cases = [
"build failed", "build failed",
"random error", "random error",
"permission denied", "permission denied",
"network error", "network error",
]; ];
for error in no_retry_cases { for error in no_retry_cases {
assert!( assert!(
!classifier.should_retry(error), !classifier.should_retry(error),
"Should not retry: {}", "Should not retry: {}",
error error
); );
} }
} }
#[test] #[test]
fn test_hash_fixing_in_nix_files() { fn test_hash_fixing_in_nix_files() {
// Test that hash fixing actually works on real Nix files // Test that hash fixing actually works on real Nix files
let temp_dir = TempDir::new().expect("Failed to create temp dir"); let temp_dir = TempDir::new().expect("Failed to create temp dir");
let fixer = DefaultNixFileFixer; let fixer = DefaultNixFileFixer;
// Create a mock Nix file with various hash formats // Create a mock Nix file with various hash formats
let nix_content = r#" let nix_content = r#"
stdenv.mkDerivation { stdenv.mkDerivation {
name = "test-package"; name = "test-package";
src = fetchurl { src = fetchurl {
@ -89,131 +95,140 @@ stdenv.mkDerivation {
} }
"#; "#;
let file_path = temp_dir.path().join("test.nix"); let file_path = temp_dir.path().join("test.nix");
fs::write(&file_path, nix_content).expect("Failed to write test file"); fs::write(&file_path, nix_content).expect("Failed to write test file");
// Test hash replacement // Test hash replacement
let new_hash = "sha256-newhashabc"; let new_hash = "sha256-newhashabc";
let was_fixed = fixer let was_fixed = fixer
.fix_hash_in_file(&file_path, new_hash) .fix_hash_in_file(&file_path, new_hash)
.expect("Failed to fix hash"); .expect("Failed to fix hash");
assert!(was_fixed, "File should have been modified"); assert!(was_fixed, "File should have been modified");
let updated_content = fs::read_to_string(&file_path).expect("Failed to read updated file"); let updated_content =
fs::read_to_string(&file_path).expect("Failed to read updated file");
// All hash formats should be updated // All hash formats should be updated
assert!(updated_content.contains(&format!(r#"hash = "{}""#, new_hash))); assert!(updated_content.contains(&format!(r#"hash = "{}""#, new_hash)));
assert!(updated_content.contains(&format!(r#"sha256 = "{}""#, new_hash))); assert!(updated_content.contains(&format!(r#"sha256 = "{}""#, new_hash)));
assert!(updated_content.contains(&format!(r#"outputHash = "{}""#, new_hash))); assert!(updated_content.contains(&format!(r#"outputHash = "{}""#, new_hash)));
// Old hashes should be gone // Old hashes should be gone
assert!(!updated_content.contains("oldhash123")); assert!(!updated_content.contains("oldhash123"));
assert!(!updated_content.contains("oldhash456")); assert!(!updated_content.contains("oldhash456"));
assert!(!updated_content.contains("oldhash789")); assert!(!updated_content.contains("oldhash789"));
} }
#[test] #[test]
fn test_multicall_binary_dispatch() { fn test_multicall_binary_dispatch() {
// Test that multicall binaries work without needing actual Nix evaluation // Test that multicall binaries work without needing actual Nix evaluation
let commands = [("nb", "build"), ("nr", "run"), ("ns", "shell")]; let commands = [("nb", "build"), ("nr", "run"), ("ns", "shell")];
for (binary_name, _expected_command) in &commands { for (binary_name, _expected_command) in &commands {
// Test that the binary starts and handles invalid arguments gracefully // Test that the binary starts and handles invalid arguments gracefully
let output = Command::new("timeout") let output = Command::new("timeout")
.args(["5", "cargo", "run", "--bin", "eh", "--"]) .args(["5", "cargo", "run", "--bin", "eh", "--"])
.env("CARGO_BIN_NAME", binary_name) .env("CARGO_BIN_NAME", binary_name)
.arg("invalid-package-ref") .arg("invalid-package-ref")
.output() .output()
.expect("Failed to execute command"); .expect("Failed to execute command");
// Should fail gracefully (not panic or hang) // Should fail gracefully (not panic or hang)
assert!( assert!(
output.status.code().is_some(), output.status.code().is_some(),
"{} should exit with a code", "{} should exit with a code",
binary_name binary_name
); );
// Should show an error message, not crash // Should show an error message, not crash
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
assert!( assert!(
stderr.contains("Error:") || stderr.contains("error:") || stderr.contains("failed"), stderr.contains("Error:")
"{} should show error for invalid package", || stderr.contains("error:")
binary_name || stderr.contains("failed"),
); "{} should show error for invalid package",
} binary_name
);
}
} }
#[test] #[test]
fn test_invalid_expression_handling() { fn test_invalid_expression_handling() {
// Test that invalid Nix expressions fail fast with proper error messages // Test that invalid Nix expressions fail fast with proper error messages
let invalid_refs = [ let invalid_refs = [
"invalid-flake-ref", "invalid-flake-ref",
"nonexistent-package", "nonexistent-package",
"file:///nonexistent/path", "file:///nonexistent/path",
]; ];
for invalid_ref in invalid_refs { for invalid_ref in invalid_refs {
let output = Command::new("timeout") let output = Command::new("timeout")
.args([ .args([
"10", "10",
"cargo", "cargo",
"run", "run",
"--bin", "--bin",
"eh", "eh",
"--", "--",
"build", "build",
invalid_ref, invalid_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 with a proper error, not hang or crash
assert!( assert!(
!output.status.success(), !output.status.success(),
"Invalid ref '{}' should fail", "Invalid ref '{}' should fail",
invalid_ref invalid_ref
); );
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
assert!( assert!(
stderr.contains("Error:") || stderr.contains("error:") || stderr.contains("failed"), stderr.contains("Error:")
"Should show error message for invalid ref '{}': {}", || stderr.contains("error:")
invalid_ref, || stderr.contains("failed"),
stderr "Should show error message for invalid ref '{}': {}",
); invalid_ref,
} stderr
);
}
} }
#[test] #[test]
fn test_nix_file_discovery() { fn test_nix_file_discovery() {
// Test that the fixer can find Nix files in a directory structure // Test that the fixer can find Nix files in a directory structure
let temp_dir = TempDir::new().expect("Failed to create temp dir"); let temp_dir = TempDir::new().expect("Failed to create temp dir");
let fixer = DefaultNixFileFixer; let fixer = DefaultNixFileFixer;
// Create directory structure with Nix files // Create directory structure with Nix files
fs::create_dir_all(temp_dir.path().join("subdir")).expect("Failed to create subdir"); fs::create_dir_all(temp_dir.path().join("subdir"))
.expect("Failed to create subdir");
let files = [ let files = [
("test.nix", "stdenv.mkDerivation { name = \"test\"; }"), ("test.nix", "stdenv.mkDerivation { name = \"test\"; }"),
("subdir/other.nix", "pkgs.hello"), ("subdir/other.nix", "pkgs.hello"),
("not-nix.txt", "not a nix file"), ("not-nix.txt", "not a nix file"),
("default.nix", "import ./test.nix"), ("default.nix", "import ./test.nix"),
]; ];
for (path, content) in files { for (path, content) in files {
fs::write(temp_dir.path().join(path), content).expect("Failed to write file"); fs::write(temp_dir.path().join(path), content)
} .expect("Failed to write file");
}
// Change to temp dir for file discovery // Change to temp dir for file discovery
let original_dir = std::env::current_dir().expect("Failed to get current dir"); let original_dir =
std::env::set_current_dir(temp_dir.path()).expect("Failed to change directory"); 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"); let found_files = fixer.find_nix_files().expect("Failed to find Nix files");
// Should find 3 .nix files (not the .txt file) // Should find 3 .nix files (not the .txt file)
assert_eq!(found_files.len(), 3, "Should find exactly 3 .nix files"); assert_eq!(found_files.len(), 3, "Should find exactly 3 .nix files");
// Restore original directory // Restore original directory
std::env::set_current_dir(original_dir).expect("Failed to restore directory"); std::env::set_current_dir(original_dir).expect("Failed to restore directory");
} }

View file

@ -1,7 +1,8 @@
use std::{ use std::{
error, fs, error,
path::{Path, PathBuf}, fs,
process, path::{Path, PathBuf},
process,
}; };
use clap::{CommandFactory, Parser}; use clap::{CommandFactory, Parser};
@ -9,164 +10,169 @@ use clap_complete::{Shell, generate};
#[derive(clap::Parser)] #[derive(clap::Parser)]
struct Cli { struct Cli {
#[clap(subcommand)] #[clap(subcommand)]
command: Command, command: Command,
} }
#[derive(clap::Subcommand)] #[derive(clap::Subcommand)]
enum Command { enum Command {
/// Create multicall binaries (hardlinks or copies). /// Create multicall binaries (hardlinks or copies).
Multicall { Multicall {
/// Directory to install multicall binaries. /// Directory to install multicall binaries.
#[arg(long, default_value = "bin")] #[arg(long, default_value = "bin")]
bin_dir: PathBuf, bin_dir: PathBuf,
/// Path to the main binary. /// Path to the main binary.
#[arg(long, default_value = "target/release/eh")] #[arg(long, default_value = "target/release/eh")]
main_binary: PathBuf, main_binary: PathBuf,
}, },
/// Generate shell completion scripts /// Generate shell completion scripts
Completions { Completions {
/// Shell to generate completions for /// Shell to generate completions for
#[arg(value_enum)] #[arg(value_enum)]
shell: Shell, shell: Shell,
/// Directory to output completion files /// Directory to output completion files
#[arg(long, default_value = "completions")] #[arg(long, default_value = "completions")]
output_dir: PathBuf, output_dir: PathBuf,
}, },
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
enum Binary { enum Binary {
Nr, Nr,
Ns, Ns,
Nb, Nb,
} }
impl Binary { impl Binary {
const fn name(self) -> &'static str { const fn name(self) -> &'static str {
match self { match self {
Self::Nr => "nr", Self::Nr => "nr",
Self::Ns => "ns", Self::Ns => "ns",
Self::Nb => "nb", Self::Nb => "nb",
}
} }
}
} }
fn main() { fn main() {
let cli = Cli::parse(); let cli = Cli::parse();
match cli.command { match cli.command {
Command::Multicall { Command::Multicall {
bin_dir, bin_dir,
main_binary, main_binary,
} => { } => {
if let Err(error) = create_multicall_binaries(&bin_dir, &main_binary) { if let Err(error) = create_multicall_binaries(&bin_dir, &main_binary) {
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);
} }
} },
} }
} }
fn create_multicall_binaries( fn create_multicall_binaries(
bin_dir: &Path, bin_dir: &Path,
main_binary: &Path, main_binary: &Path,
) -> Result<(), Box<dyn error::Error>> { ) -> Result<(), Box<dyn error::Error>> {
println!("creating multicall binaries..."); println!("creating multicall binaries...");
fs::create_dir_all(bin_dir)?; fs::create_dir_all(bin_dir)?;
if !main_binary.exists() { if !main_binary.exists() {
return Err(format!("main binary not found at: {}", main_binary.display()).into()); 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)?;
} }
let multicall_binaries = [Binary::Nr, Binary::Ns, Binary::Nb]; if let Err(e) = fs::hard_link(main_binary, &target_path) {
let bin_path = Path::new(bin_dir); eprintln!(
" warning: could not create hardlink for {}: {e}",
binary.name(),
);
eprintln!(" warning: falling back to copying binary...");
for binary in multicall_binaries { fs::copy(main_binary, &target_path)?;
let target_path = bin_path.join(binary.name());
if target_path.exists() { #[cfg(unix)]
fs::remove_file(&target_path)?; {
} 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 let Err(e) = fs::hard_link(main_binary, &target_path) { println!(" created copy: {}", target_path.display());
eprintln!( } else {
" warning: could not create hardlink for {}: {e}", println!(
binary.name(), " created hardlink: {} points to {}",
); target_path.display(),
eprintln!(" warning: falling back to copying binary..."); main_binary.display(),
);
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());
} else {
println!(
" created hardlink: {} points to {}",
target_path.display(),
main_binary.display(),
);
}
} }
}
println!("multicall binaries created successfully!"); println!("multicall binaries created successfully!");
println!("multicall binaries are in: {}", bin_dir.display()); println!("multicall binaries are in: {}", bin_dir.display());
println!(); println!();
Ok(()) Ok(())
} }
fn generate_completions(shell: Shell, output_dir: &Path) -> Result<(), Box<dyn error::Error>> { fn generate_completions(
println!("generating {shell} completions..."); shell: Shell,
output_dir: &Path,
) -> Result<(), Box<dyn error::Error>> {
println!("generating {shell} completions...");
fs::create_dir_all(output_dir)?; fs::create_dir_all(output_dir)?;
let mut cmd = eh::Cli::command(); let mut cmd = eh::Cli::command();
let bin_name = "eh"; let bin_name = "eh";
let completion_file = output_dir.join(format!("{bin_name}.{shell}")); let completion_file = output_dir.join(format!("{bin_name}.{shell}"));
let mut file = fs::File::create(&completion_file)?; 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 // Create symlinks for multicall binaries
let multicall_names = ["nb", "nr", "ns"]; let multicall_names = ["nb", "nr", "ns"];
for name in &multicall_names { for name in &multicall_names {
let symlink_path = output_dir.join(format!("{name}.{shell}")); let symlink_path = output_dir.join(format!("{name}.{shell}"));
if symlink_path.exists() { if symlink_path.exists() {
fs::remove_file(&symlink_path)?; 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());
}
} }
println!("completions generated successfully!"); #[cfg(unix)]
Ok(()) {
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(())
} }