Compare commits

..

6 commits

Author SHA1 Message Date
4a57561d7b
chore: bump crate version
Some checks are pending
Rust / build (push) Waiting to run
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I4fe011e195458cb6bd3e708a647086146a6a6964
2026-03-03 23:28:48 +03:00
770c57b0fd
util: document public functions; extract magic values into named constants
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I75252138a82464969a766354c96a39f36a6a6964
2026-03-03 23:28:47 +03:00
49a0becfdf
commands: add unit tests for piping logic
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I21f6f6f1402d870fce7cdca27c3a2e706a6a6964
2026-03-03 23:28:46 +03:00
5fe3bc61c6
util: optimize package flag checking; better error handling
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I1fb76c5fe6360339e568936d32f9e7656a6a6964
2026-03-03 23:28:45 +03:00
3e7d0f9459
util: reduce number of nix calls in check_package_flags
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Id8ebb042b001b88c10fcde91da410f706a6a6964
2026-03-03 23:28:45 +03:00
ccbcce8c08
treewide: move per-command logic into a commands module
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ia7260a8691eea628f559ab8866aa51de6a6a6964
2026-03-03 23:28:44 +03:00
13 changed files with 745 additions and 547 deletions

6
Cargo.lock generated
View file

@ -101,7 +101,7 @@ dependencies = [
[[package]] [[package]]
name = "eh" name = "eh"
version = "0.1.4" version = "0.1.5"
dependencies = [ dependencies = [
"clap", "clap",
"dialoguer", "dialoguer",
@ -116,7 +116,7 @@ dependencies = [
[[package]] [[package]]
name = "eh-log" name = "eh-log"
version = "0.1.4" version = "0.1.5"
dependencies = [ dependencies = [
"yansi", "yansi",
] ]
@ -421,7 +421,7 @@ checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
[[package]] [[package]]
name = "xtask" name = "xtask"
version = "0.1.4" version = "0.1.5"
dependencies = [ dependencies = [
"clap", "clap",
"clap_complete", "clap_complete",

View file

@ -9,8 +9,8 @@ description = "Ergonomic Nix CLI helper"
edition = "2024" edition = "2024"
license = "MPL-2.0" license = "MPL-2.0"
readme = true readme = true
rust-version = "1.90" rust-version = "1.91.0"
version = "0.1.4" version = "0.1.5"
[workspace.dependencies] [workspace.dependencies]
clap = { default-features = false, features = [ "std", "help", "derive" ], version = "4.5.56" } clap = { default-features = false, features = [ "std", "help", "derive" ], version = "4.5.56" }

View file

@ -1,18 +0,0 @@
use crate::{
error::Result,
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,
) -> Result<i32> {
handle_nix_with_retry("build", args, hash_extractor, fixer, classifier, false)
}

View file

@ -6,15 +6,26 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use crate::error::{EhError, Result}; use crate::{
error::{EhError, Result},
util::{
HashExtractor,
NixErrorClassifier,
NixFileFixer,
handle_nix_with_retry,
},
};
pub mod update;
const DEFAULT_BUFFER_SIZE: usize = 4096;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
/// 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.
pub struct StdIoInterceptor; pub struct StdIoInterceptor;
impl LogInterceptor for StdIoInterceptor { impl LogInterceptor for StdIoInterceptor {
@ -26,19 +37,13 @@ impl LogInterceptor for StdIoInterceptor {
} }
} }
/// Default buffer size for reading command output #[derive(Debug)]
const DEFAULT_BUFFER_SIZE: usize = 4096;
/// Default timeout for command execution
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300); // 5 minutes
enum PipeEvent { enum PipeEvent {
Stdout(Vec<u8>), Stdout(Vec<u8>),
Stderr(Vec<u8>), Stderr(Vec<u8>),
Error(io::Error), Error(io::Error),
} }
/// Drain a pipe reader, sending chunks through the channel.
fn read_pipe<R: Read>( fn read_pipe<R: Read>(
mut reader: R, mut reader: R,
tx: mpsc::Sender<PipeEvent>, tx: mpsc::Sender<PipeEvent>,
@ -66,7 +71,6 @@ fn read_pipe<R: Read>(
} }
} }
/// Builder and executor for Nix commands.
pub struct NixCommand { pub struct NixCommand {
subcommand: String, subcommand: String,
args: Vec<String>, args: Vec<String>,
@ -126,8 +130,6 @@ impl NixCommand {
self self
} }
/// Build the underlying `std::process::Command` with all configured
/// arguments, environment variables, and flags.
fn build_command(&self) -> Command { fn build_command(&self) -> Command {
let mut cmd = Command::new("nix"); let mut cmd = Command::new("nix");
cmd.arg(&self.subcommand); cmd.arg(&self.subcommand);
@ -147,10 +149,6 @@ impl NixCommand {
cmd cmd
} }
/// Run the command, streaming output to the provided interceptor.
///
/// Stdout and stderr are read concurrently using background threads
/// so that neither pipe blocks the other.
pub fn run_with_logs<I: LogInterceptor + 'static>( pub fn run_with_logs<I: LogInterceptor + 'static>(
&self, &self,
mut interceptor: I, mut interceptor: I,
@ -212,7 +210,6 @@ impl NixCommand {
return Err(EhError::Io(e)); return Err(EhError::Io(e));
}, },
Err(mpsc::RecvTimeoutError::Timeout) => {}, Err(mpsc::RecvTimeoutError::Timeout) => {},
// All senders dropped — both reader threads finished
Err(mpsc::RecvTimeoutError::Disconnected) => break, Err(mpsc::RecvTimeoutError::Disconnected) => break,
} }
} }
@ -224,7 +221,6 @@ impl NixCommand {
Ok(status) Ok(status)
} }
/// Run the command and capture all output (with timeout).
pub fn output(&self) -> Result<Output> { pub fn output(&self) -> Result<Output> {
let mut cmd = self.build_command(); let mut cmd = self.build_command();
@ -317,3 +313,156 @@ impl NixCommand {
}) })
} }
} }
pub fn handle_nix_command(
command: &str,
args: &[String],
hash_extractor: &dyn HashExtractor,
fixer: &dyn NixFileFixer,
classifier: &dyn NixErrorClassifier,
) -> Result<i32> {
let intercept_env = matches!(command, "run" | "shell");
handle_nix_with_retry(
command,
args,
hash_extractor,
fixer,
classifier,
intercept_env,
)
}
#[cfg(test)]
mod tests {
use std::io::{Cursor, Error};
use super::*;
#[test]
fn test_read_pipe_stdout() {
let data = b"hello world";
let cursor = Cursor::new(data);
let (tx, rx) = mpsc::channel();
let tx_clone = tx.clone();
std::thread::spawn(move || {
read_pipe(cursor, tx_clone, false);
});
drop(tx);
let events: Vec<PipeEvent> = rx.iter().take(10).collect();
assert!(!events.is_empty());
let stdout_events: Vec<_> = events
.iter()
.filter(|e| matches!(e, PipeEvent::Stdout(_)))
.collect();
assert!(!stdout_events.is_empty());
let combined: Vec<u8> = events
.iter()
.filter_map(|e| {
match e {
PipeEvent::Stdout(b) => Some(b.clone()),
_ => None,
}
})
.flatten()
.collect();
assert_eq!(combined, data);
}
#[test]
fn test_read_pipe_stderr() {
let data = b"error output";
let cursor = Cursor::new(data);
let (tx, rx) = mpsc::channel();
let tx_clone = tx.clone();
std::thread::spawn(move || {
read_pipe(cursor, tx_clone, true);
});
drop(tx);
let events: Vec<PipeEvent> = rx.iter().take(10).collect();
let stderr_events: Vec<_> = events
.iter()
.filter(|e| matches!(e, PipeEvent::Stderr(_)))
.collect();
assert!(!stderr_events.is_empty());
let combined: Vec<u8> = events
.iter()
.filter_map(|e| {
match e {
PipeEvent::Stderr(b) => Some(b.clone()),
_ => None,
}
})
.flatten()
.collect();
assert_eq!(combined, data);
}
#[test]
fn test_read_pipe_empty() {
let cursor = Cursor::new(b"");
let (tx, rx) = mpsc::channel();
let tx_clone = tx.clone();
std::thread::spawn(move || {
read_pipe(cursor, tx_clone, false);
});
drop(tx);
let events: Vec<PipeEvent> = rx.iter().take(10).collect();
assert!(events.is_empty());
}
#[test]
fn test_read_pipe_error() {
struct ErrorReader;
impl Read for ErrorReader {
fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
Err(std::io::Error::other("test error"))
}
}
let reader = ErrorReader;
let (tx, rx) = mpsc::channel();
let tx_clone = tx.clone();
std::thread::spawn(move || {
read_pipe(reader, tx_clone, false);
});
drop(tx);
let events: Vec<PipeEvent> = rx.iter().take(10).collect();
let error_events: Vec<_> = events
.iter()
.filter(|e| matches!(e, PipeEvent::Error(_)))
.collect();
assert!(!error_events.is_empty());
}
#[test]
fn test_pipe_event_debug() {
let stdout_event = PipeEvent::Stdout(b"test".to_vec());
let stderr_event = PipeEvent::Stderr(b"error".to_vec());
let error_event = PipeEvent::Error(Error::other("test"));
let debug_stdout = format!("{:?}", stdout_event);
let debug_stderr = format!("{:?}", stderr_event);
let debug_error = format!("{:?}", error_event);
assert!(debug_stdout.contains("Stdout"));
assert!(debug_stderr.contains("Stderr"));
assert!(debug_error.contains("Error"));
}
}

View file

@ -1,13 +1,14 @@
use crate::{ use crate::{
command::{NixCommand, StdIoInterceptor}, commands::{NixCommand, StdIoInterceptor},
error::{EhError, Result}, error::{EhError, Result},
}; };
/// Parse flake input names from `nix flake metadata --json` output. /// Parse flake input names from `nix flake metadata --json` output.
pub fn parse_flake_inputs(stdout: &str) -> Result<Vec<String>> { pub fn parse_flake_inputs(stdout: &str) -> Result<Vec<String>> {
let value: serde_json::Value = let value: serde_json::Value = serde_json::from_str(stdout).map_err(|e| {
serde_json::from_str(stdout).map_err(|e| EhError::JsonParse { EhError::JsonParse {
detail: e.to_string(), detail: e.to_string(),
}
})?; })?;
let inputs = value let inputs = value

View file

@ -81,7 +81,7 @@ impl EhError {
} }
#[must_use] #[must_use]
pub fn hint(&self) -> Option<&str> { pub const fn hint(&self) -> Option<&str> {
match self { match self {
Self::NixCommandFailed { .. } => { Self::NixCommandFailed { .. } => {
Some("run with --show-trace for more details") Some("run with --show-trace for more details")
@ -175,13 +175,7 @@ mod tests {
12 12
); );
assert_eq!(EhError::ProcessExit { code: 42 }.exit_code(), 42); assert_eq!(EhError::ProcessExit { code: 42 }.exit_code(), 42);
assert_eq!( assert_eq!(EhError::JsonParse { detail: "x".into() }.exit_code(), 13);
EhError::JsonParse {
detail: "x".into(),
}
.exit_code(),
13
);
assert_eq!(EhError::NoFlakeInputs.exit_code(), 14); assert_eq!(EhError::NoFlakeInputs.exit_code(), 14);
assert_eq!(EhError::UpdateCancelled.exit_code(), 0); assert_eq!(EhError::UpdateCancelled.exit_code(), 0);
} }
@ -249,13 +243,7 @@ mod tests {
.hint() .hint()
.is_some() .is_some()
); );
assert!( assert!(EhError::JsonParse { detail: "x".into() }.hint().is_some());
EhError::JsonParse {
detail: "x".into(),
}
.hint()
.is_some()
);
assert!(EhError::NoFlakeInputs.hint().is_some()); assert!(EhError::NoFlakeInputs.hint().is_some());
// Variants without hints // Variants without hints
assert!( assert!(

View file

@ -1,9 +1,5 @@
pub mod build; pub mod commands;
pub mod command;
pub mod error; pub mod error;
pub mod run;
pub mod shell;
pub mod update;
pub mod util; pub mod util;
pub use clap::{CommandFactory, Parser, Subcommand}; pub use clap::{CommandFactory, Parser, Subcommand};

View file

@ -4,12 +4,8 @@ use eh::{Cli, Command, CommandFactory, Parser};
use error::Result; use error::Result;
use yansi::Paint; use yansi::Paint;
mod build; mod commands;
mod command;
mod error; mod error;
mod run;
mod shell;
mod update;
mod util; mod util;
fn main() { fn main() {
@ -66,7 +62,7 @@ fn dispatch_multicall(
} }
if subcommand == "update" { if subcommand == "update" {
return Some(update::handle_update(&rest)); return Some(commands::update::handle_update(&rest));
} }
let hash_extractor = util::RegexHashExtractor; let hash_extractor = util::RegexHashExtractor;
@ -74,12 +70,14 @@ fn dispatch_multicall(
let classifier = util::DefaultNixErrorClassifier; let classifier = util::DefaultNixErrorClassifier;
Some(match subcommand { Some(match subcommand {
"run" => run::handle_nix_run(&rest, &hash_extractor, &fixer, &classifier), "run" | "shell" | "build" => {
"shell" => { commands::handle_nix_command(
shell::handle_nix_shell(&rest, &hash_extractor, &fixer, &classifier) subcommand,
}, &rest,
"build" => { &hash_extractor,
build::handle_nix_build(&rest, &hash_extractor, &fixer, &classifier) &fixer,
&classifier,
)
}, },
// subcommand is assigned from the match on app_name above; // subcommand is assigned from the match on app_name above;
// only "run"/"shell"/"build" are possible values. // only "run"/"shell"/"build" are possible values.
@ -108,18 +106,36 @@ 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) commands::handle_nix_command(
"run",
&args,
&hash_extractor,
&fixer,
&classifier,
)
}, },
Some(Command::Shell { args }) => { Some(Command::Shell { args }) => {
shell::handle_nix_shell(&args, &hash_extractor, &fixer, &classifier) commands::handle_nix_command(
"shell",
&args,
&hash_extractor,
&fixer,
&classifier,
)
}, },
Some(Command::Build { args }) => { Some(Command::Build { args }) => {
build::handle_nix_build(&args, &hash_extractor, &fixer, &classifier) commands::handle_nix_command(
"build",
&args,
&hash_extractor,
&fixer,
&classifier,
)
}, },
Some(Command::Update { args }) => update::handle_update(&args), Some(Command::Update { args }) => commands::update::handle_update(&args),
None => { None => {
Cli::command().print_help()?; Cli::command().print_help()?;

View file

@ -1,18 +0,0 @@
use crate::{
error::Result,
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,
) -> Result<i32> {
handle_nix_with_retry("run", args, hash_extractor, fixer, classifier, true)
}

View file

@ -1,18 +0,0 @@
use crate::{
error::Result,
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,
) -> Result<i32> {
handle_nix_with_retry("shell", args, hash_extractor, fixer, classifier, true)
}

View file

@ -11,10 +11,13 @@ use walkdir::WalkDir;
use yansi::Paint; use yansi::Paint;
use crate::{ use crate::{
command::{NixCommand, StdIoInterceptor}, commands::{NixCommand, StdIoInterceptor},
error::{EhError, Result}, error::{EhError, Result},
}; };
/// Maximum directory depth when searching for .nix files.
const MAX_DIR_DEPTH: usize = 3;
/// Compiled regex patterns for extracting the actual hash from nix stderr. /// Compiled regex patterns for extracting the actual hash from nix stderr.
static HASH_EXTRACT_PATTERNS: LazyLock<[Regex; 3]> = LazyLock::new(|| { static HASH_EXTRACT_PATTERNS: LazyLock<[Regex; 3]> = LazyLock::new(|| {
[ [
@ -39,11 +42,15 @@ static HASH_FIX_PATTERNS: LazyLock<[Regex; 3]> = LazyLock::new(|| {
] ]
}); });
/// Trait for extracting store paths and hashes from nix output.
pub trait HashExtractor { pub trait HashExtractor {
/// Extract the new store path/hash from nix output.
fn extract_hash(&self, stderr: &str) -> Option<String>; fn extract_hash(&self, stderr: &str) -> Option<String>;
/// Extract the old store path/hash from nix output (for hash updates).
fn extract_old_hash(&self, stderr: &str) -> Option<String>; fn extract_old_hash(&self, stderr: &str) -> Option<String>;
} }
/// Default implementation of [`HashExtractor`] using regex patterns.
pub struct RegexHashExtractor; pub struct RegexHashExtractor;
impl HashExtractor for RegexHashExtractor { impl HashExtractor for RegexHashExtractor {
@ -66,13 +73,19 @@ impl HashExtractor for RegexHashExtractor {
} }
} }
/// Trait for fixing hash mismatches in nix files.
pub trait NixFileFixer { pub trait NixFileFixer {
/// Attempt to fix hash in all nix files found in the current directory.
/// Returns `true` if at least one file was fixed.
fn fix_hash_in_files( fn fix_hash_in_files(
&self, &self,
old_hash: Option<&str>, old_hash: Option<&str>,
new_hash: &str, new_hash: &str,
) -> Result<bool>; ) -> Result<bool>;
/// Find all .nix files in the current directory (respects MAX_DIR_DEPTH).
fn find_nix_files(&self) -> Result<Vec<PathBuf>>; fn find_nix_files(&self) -> Result<Vec<PathBuf>>;
/// Attempt to fix hash in a single file.
/// Returns `true` if the file was modified.
fn fix_hash_in_file( fn fix_hash_in_file(
&self, &self,
file_path: &Path, file_path: &Path,
@ -81,6 +94,7 @@ pub trait NixFileFixer {
) -> Result<bool>; ) -> Result<bool>;
} }
/// Default implementation of [`NixFileFixer`] that walks the directory tree.
pub struct DefaultNixFileFixer; pub struct DefaultNixFileFixer;
impl NixFileFixer for DefaultNixFileFixer { impl NixFileFixer for DefaultNixFileFixer {
@ -112,7 +126,7 @@ impl NixFileFixer for DefaultNixFileFixer {
}; };
let files: Vec<PathBuf> = WalkDir::new(".") let files: Vec<PathBuf> = WalkDir::new(".")
.max_depth(3) .max_depth(MAX_DIR_DEPTH)
.into_iter() .into_iter()
.filter_entry(|e| !should_skip(e)) .filter_entry(|e| !should_skip(e))
.filter_map(std::result::Result::ok) .filter_map(std::result::Result::ok)
@ -144,7 +158,7 @@ impl NixFileFixer for DefaultNixFileFixer {
let mut result_content = content; let mut result_content = content;
if let Some(old) = old_hash { if let Some(old) = old_hash {
// Targeted replacement: only replace attributes whose value matches the // Only replace attributes whose value matches the
// old hash. Uses regexes to handle variable whitespace around `=`. // old hash. Uses regexes to handle variable whitespace around `=`.
let old_escaped = regex::escape(old); let old_escaped = regex::escape(old);
let targeted_patterns = [ let targeted_patterns = [
@ -171,7 +185,7 @@ impl NixFileFixer for DefaultNixFileFixer {
} }
} }
} else { } else {
// Fallback: replace all hash attributes (original behavior) // Fallback: replace all hash attributes
let replacements = [ let replacements = [
format!(r#"hash = "{new_hash}""#), format!(r#"hash = "{new_hash}""#),
format!(r#"sha256 = "{new_hash}""#), format!(r#"sha256 = "{new_hash}""#),
@ -208,23 +222,34 @@ impl NixFileFixer for DefaultNixFileFixer {
} }
} }
/// Trait for classifying nix errors and determining if a retry with modified
/// environment is appropriate.
pub trait NixErrorClassifier { pub trait NixErrorClassifier {
/// Determine if the given stderr output should trigger a retry with modified
/// environment variables (e.g., NIXPKGS_ALLOW_UNFREE).
fn should_retry(&self, stderr: &str) -> bool; fn should_retry(&self, stderr: &str) -> bool;
} }
/// Classifies what retry action should be taken based on nix stderr output. /// Classifies what retry action should be taken based on nix stderr output.
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub enum RetryAction { pub enum RetryAction {
/// Package has an unfree license, retry with NIXPKGS_ALLOW_UNFREE=1
AllowUnfree, AllowUnfree,
/// Package is marked insecure, retry with
/// NIXPKGS_ALLOW_INSECURE_DERIVATIONS=1
AllowInsecure, AllowInsecure,
/// Package is marked broken, retry with NIXPKGS_ALLOW_BROKEN=1
AllowBroken, AllowBroken,
/// No retry needed
None, None,
} }
impl RetryAction { impl RetryAction {
/// Returns `(env_var, reason)` for this retry action, /// # Returns
/// or `None` if no retry is needed. ///
fn env_override(&self) -> Option<(&str, &str)> { /// `(env_var, reason)` for this retry action, or `None` if no retry is
/// needed.
const fn env_override(&self) -> Option<(&str, &str)> {
match self { match self {
Self::AllowUnfree => { Self::AllowUnfree => {
Some(("NIXPKGS_ALLOW_UNFREE", "has an unfree license")) Some(("NIXPKGS_ALLOW_UNFREE", "has an unfree license"))
@ -245,8 +270,7 @@ fn package_name(args: &[String]) -> &str {
args args
.iter() .iter()
.find(|a| !a.starts_with('-')) .find(|a| !a.starts_with('-'))
.map(String::as_str) .map_or("<unknown>", String::as_str)
.unwrap_or("<unknown>")
} }
/// Print a retry message with consistent formatting. /// Print a retry message with consistent formatting.
@ -261,6 +285,7 @@ fn print_retry_msg(pkg: &str, reason: &str, env_var: &str) {
} }
/// Classify stderr into a retry action. /// Classify stderr into a retry action.
#[must_use]
pub fn classify_retry_action(stderr: &str) -> RetryAction { pub fn classify_retry_action(stderr: &str) -> RetryAction {
if stderr.contains("has an unfree license") && stderr.contains("refusing") { if stderr.contains("has an unfree license") && stderr.contains("refusing") {
RetryAction::AllowUnfree RetryAction::AllowUnfree
@ -284,22 +309,98 @@ fn is_hash_mismatch_error(stderr: &str) -> bool {
|| (stderr.contains("specified:") && stderr.contains("got:")) || (stderr.contains("specified:") && stderr.contains("got:"))
} }
/// Pre-evaluate expression to catch errors early. /// Check if a package has an unfree, insecure, or broken attribute set.
/// /// Returns the appropriate `RetryAction` if any of these are true. Makes a
/// Returns a `RetryAction` if the evaluation fails with a retryable error /// single nix eval call to minimize overhead.
/// (unfree/insecure/broken), allowing the caller to retry with the right fn check_package_flags(args: &[String]) -> Result<RetryAction> {
/// environment variables without ever streaming the verbose nix error output. // Default to "." if no argument provided (like `nix build` without args)
fn pre_evaluate(args: &[String]) -> Result<RetryAction> { let eval_arg = args
// Find flake references or expressions to evaluate .iter()
// Only take the first non-flag argument (the package/expression) .find(|arg| !arg.starts_with('-'))
let eval_arg = args.iter().find(|arg| !arg.starts_with('-')); .cloned()
.unwrap_or_else(|| ".".to_string());
let Some(eval_arg) = eval_arg else { let eval_expr = format!("nixpkgs#{eval_arg}.meta");
return Ok(RetryAction::None); // No expression to evaluate let eval_cmd = NixCommand::new("eval")
.arg("--json")
.arg(&eval_expr)
.print_build_logs(false);
let output = match eval_cmd.output() {
Ok(o) if o.status.success() => o,
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
if stderr.contains("does not provide attribute") {
return Ok(RetryAction::None);
}
log_warn!(
"failed to check package flags for '{}': {}",
eval_arg,
stderr.trim()
);
return Ok(RetryAction::None);
},
Err(e) => {
log_warn!("failed to check package flags for '{}': {}", eval_arg, e);
return Ok(RetryAction::None);
},
}; };
let meta: serde_json::Value = match serde_json::from_slice(&output.stdout) {
Ok(v) => v,
Err(e) => {
log_warn!("failed to parse package metadata for '{}': {}", eval_arg, e);
return Ok(RetryAction::None);
},
};
let flags = [
("unfree", RetryAction::AllowUnfree),
("insecure", RetryAction::AllowInsecure),
("broken", RetryAction::AllowBroken),
];
for (key, action) in flags {
if meta
.get(key)
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
{
return Ok(action);
}
}
Ok(RetryAction::None)
}
/// Pre-evaluate expression to catch errors early.
///
/// Returns a `RetryAction` if the package has retryable flags
/// (unfree/insecure/broken), allowing the caller to retry with the right
/// environment variables.
fn pre_evaluate(args: &[String]) -> Result<RetryAction> {
// First, check package meta flags directly to avoid error message parsing
let action = check_package_flags(args)?;
if action != RetryAction::None {
return Ok(action);
}
// Find flake references or expressions to evaluate
// Only take the first non-flag argument (the package/expression)
// Default to "." if no argument provided (like `nix build` without args)
let eval_arg = args
.iter()
.find(|arg| !arg.starts_with('-'))
.cloned()
.unwrap_or_else(|| {
log_warn!("no package specified, defaulting to '.' (current directory)");
".".to_string()
});
let eval_arg_ref = &eval_arg;
let eval_cmd = NixCommand::new("eval") let eval_cmd = NixCommand::new("eval")
.arg(eval_arg) .arg(eval_arg_ref)
.print_build_logs(false); .print_build_logs(false);
let output = eval_cmd.output()?; let output = eval_cmd.output()?;
@ -311,12 +412,13 @@ fn pre_evaluate(args: &[String]) -> Result<RetryAction> {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
// Classify whether this is a retryable error (unfree/insecure/broken) // Classify whether this is a retryable error (unfree/insecure/broken)
// Fallback for errors that slip through (e.g., from dependencies)
let action = classify_retry_action(&stderr); let action = classify_retry_action(&stderr);
if action != RetryAction::None { if action != RetryAction::None {
return Ok(action); return Ok(action);
} }
// Non-retryable eval failure fail fast with a clear message // Non-retryable eval failure, we should fail fast with a clear message
// rather than running the full command and showing the same error again. // rather than running the full command and showing the same error again.
let stderr_clean = stderr let stderr_clean = stderr
.trim() .trim()