eh: dissolve eh::util into focused modules; migrate to config and nix-command crates
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I00d4f300a63e2140a320bf601e68cd266a6a6964
This commit is contained in:
parent
74bdf0a045
commit
3f1d906bb2
13 changed files with 871 additions and 2048 deletions
|
|
@ -1,15 +1,19 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use eh_log::{log_error, log_info};
|
||||
use nix_command::{CommandKind, NixCommand};
|
||||
use serde::Deserialize;
|
||||
use yansi::Paint;
|
||||
|
||||
use crate::{
|
||||
commands::NixCommand,
|
||||
error::{EhError, Result},
|
||||
util::{make_eval_expr, print_error_suggestions},
|
||||
eval::make_eval_expr,
|
||||
nix_config::ApplyCommandConfig,
|
||||
suggestions::print_error_suggestions,
|
||||
};
|
||||
|
||||
const UNKNOWN_LICENSE: &str = "Unknown";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PackageMeta {
|
||||
name: String,
|
||||
|
|
@ -33,41 +37,24 @@ struct PackageOutputs {
|
|||
|
||||
pub fn handle_info(
|
||||
args: &[String],
|
||||
cfg: &crate::config::CommandConfig,
|
||||
cfg: &eh_config::CommandConfig,
|
||||
) -> Result<i32> {
|
||||
// Get the package argument (skip flags)
|
||||
let pkg = args
|
||||
.iter()
|
||||
.find(|arg| !arg.starts_with('-'))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ".".to_string());
|
||||
|
||||
let eval_arg = make_eval_expr(&pkg);
|
||||
let pkg_name: String = if eval_arg.contains("#") {
|
||||
eval_arg
|
||||
.split("#")
|
||||
.last()
|
||||
.unwrap_or(&eval_arg)
|
||||
.trim_end_matches(".meta")
|
||||
.to_string()
|
||||
} else {
|
||||
eval_arg.trim_end_matches(".meta").to_string()
|
||||
};
|
||||
// Handle .# case - show "default" as the package name
|
||||
let pkg_name = if pkg_name.is_empty() {
|
||||
"default".to_string()
|
||||
} else {
|
||||
pkg_name
|
||||
};
|
||||
let eval_arg = make_eval_expr(&pkg)?;
|
||||
let pkg_name = package_name_from_eval_expr(&eval_arg);
|
||||
|
||||
log_info!("Fetching info for {}", pkg_name.bold());
|
||||
|
||||
// Fetch metadata
|
||||
let meta_cmd = NixCommand::new("eval")
|
||||
let meta_cmd = NixCommand::new(CommandKind::Eval)
|
||||
.arg("--json")
|
||||
.arg(&eval_arg)
|
||||
.print_build_logs(false)
|
||||
.with_config(cfg);
|
||||
.apply_config(cfg);
|
||||
|
||||
let meta_output = meta_cmd.output()?;
|
||||
|
||||
|
|
@ -87,16 +74,15 @@ pub fn handle_info(
|
|||
)))
|
||||
})?;
|
||||
|
||||
// Fetch outputs
|
||||
let outputs_expr = eval_arg
|
||||
.strip_suffix(".meta")
|
||||
.unwrap_or(&eval_arg)
|
||||
.to_string();
|
||||
let outputs_cmd = NixCommand::new("eval")
|
||||
let outputs_cmd = NixCommand::new(CommandKind::Eval)
|
||||
.arg("--json")
|
||||
.arg(format!("{}.outputs", outputs_expr))
|
||||
.print_build_logs(false)
|
||||
.with_config(cfg);
|
||||
.apply_config(cfg);
|
||||
|
||||
let outputs_output = outputs_cmd.output()?;
|
||||
let outputs: Option<PackageOutputs> = if outputs_output.status.success() {
|
||||
|
|
@ -105,12 +91,49 @@ pub fn handle_info(
|
|||
None
|
||||
};
|
||||
|
||||
// Print formatted info
|
||||
print_package_info(&meta, outputs.as_ref(), &pkg);
|
||||
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn package_name_from_eval_expr(eval_arg: &str) -> String {
|
||||
let name = eval_arg
|
||||
.rsplit_once('#')
|
||||
.map_or(eval_arg, |(_, name)| name)
|
||||
.trim_end_matches(".meta");
|
||||
if name.is_empty() { "default" } else { name }.to_string()
|
||||
}
|
||||
|
||||
fn license_name(license: &serde_json::Value) -> Option<String> {
|
||||
match license {
|
||||
serde_json::Value::String(s) => Some(s.clone()),
|
||||
serde_json::Value::Object(obj) => {
|
||||
obj
|
||||
.get("spdxId")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| obj.get("shortName").and_then(|v| v.as_str()))
|
||||
.map(str::to_string)
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_license(license: &serde_json::Value) -> String {
|
||||
match license {
|
||||
serde_json::Value::Array(licenses) => {
|
||||
let names = licenses.iter().filter_map(license_name).collect::<Vec<_>>();
|
||||
if names.is_empty() {
|
||||
UNKNOWN_LICENSE.to_string()
|
||||
} else {
|
||||
names.join(", ")
|
||||
}
|
||||
},
|
||||
license => {
|
||||
license_name(license).unwrap_or_else(|| UNKNOWN_LICENSE.to_string())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn print_package_info(
|
||||
meta: &PackageMeta,
|
||||
outputs: Option<&PackageOutputs>,
|
||||
|
|
@ -118,7 +141,6 @@ fn print_package_info(
|
|||
) {
|
||||
println!();
|
||||
|
||||
// Header
|
||||
println!(" {} {}", "Package:".bold(), meta.name);
|
||||
|
||||
if let Some(ref version) = meta.version {
|
||||
|
|
@ -129,7 +151,6 @@ fn print_package_info(
|
|||
println!(" {} {}", "Description:".bold(), desc);
|
||||
}
|
||||
|
||||
// Show long description if available and different from short description
|
||||
if let Some(ref long_desc) = meta.long_description {
|
||||
let should_show = meta
|
||||
.description
|
||||
|
|
@ -138,7 +159,6 @@ fn print_package_info(
|
|||
.unwrap_or(true);
|
||||
if should_show {
|
||||
println!();
|
||||
// Wrap long description to 70 chars for readability
|
||||
let wrapped = textwrap::fill(long_desc, 70);
|
||||
for line in wrapped.lines() {
|
||||
println!(" {}", line);
|
||||
|
|
@ -146,58 +166,17 @@ fn print_package_info(
|
|||
}
|
||||
}
|
||||
|
||||
// License
|
||||
if let Some(ref license) = meta.license {
|
||||
let license_str = match license {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Object(obj) => {
|
||||
obj
|
||||
.get("spdxId")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| obj.get("shortName").and_then(|v| v.as_str()))
|
||||
.unwrap_or("Unknown")
|
||||
.to_string()
|
||||
},
|
||||
serde_json::Value::Array(licenses) => {
|
||||
// Handle multiple licenses (e.g., neovim has Apache-2.0 AND Vim)
|
||||
let license_names: Vec<String> = licenses
|
||||
.iter()
|
||||
.filter_map(|lic| {
|
||||
match lic {
|
||||
serde_json::Value::Object(obj) => {
|
||||
obj
|
||||
.get("spdxId")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| obj.get("shortName").and_then(|v| v.as_str()))
|
||||
.map(|s| s.to_string())
|
||||
},
|
||||
serde_json::Value::String(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if license_names.is_empty() {
|
||||
"Unknown".to_string()
|
||||
} else {
|
||||
license_names.join(", ")
|
||||
}
|
||||
},
|
||||
_ => "Unknown".to_string(),
|
||||
};
|
||||
println!(" {} {}", "License:".bold(), license_str);
|
||||
println!(" {} {}", "License:".bold(), format_license(license));
|
||||
}
|
||||
|
||||
// Homepage
|
||||
if let Some(ref homepage) = meta.homepage {
|
||||
println!(" {} {}", "Homepage:".bold(), homepage);
|
||||
}
|
||||
|
||||
// Meta section
|
||||
println!();
|
||||
println!(" {}", "Meta:".bold());
|
||||
|
||||
// Status indicators
|
||||
let mut status_parts = Vec::new();
|
||||
if meta.broken == Some(true) {
|
||||
status_parts.push("Broken".red().to_string());
|
||||
|
|
@ -215,7 +194,6 @@ fn print_package_info(
|
|||
println!(" {} {}", "Status:".bold(), status_parts.join(", "));
|
||||
}
|
||||
|
||||
// Platforms
|
||||
if let Some(ref platforms) = meta.platforms {
|
||||
let platform_list: Vec<_> = platforms.iter().take(4).cloned().collect();
|
||||
let platform_str = if platforms.len() > 4 {
|
||||
|
|
@ -230,7 +208,6 @@ fn print_package_info(
|
|||
println!(" {} {}", "Platforms:".bold(), platform_str);
|
||||
}
|
||||
|
||||
// Outputs section
|
||||
if let Some(outputs) = outputs {
|
||||
println!();
|
||||
println!(" {}", "Outputs:".bold());
|
||||
|
|
@ -241,7 +218,6 @@ fn print_package_info(
|
|||
}
|
||||
}
|
||||
|
||||
// Usage section
|
||||
println!();
|
||||
println!(" {}", "Usage:".bold());
|
||||
println!(
|
||||
|
|
|
|||
|
|
@ -1,486 +1,27 @@
|
|||
use std::{
|
||||
io::{self, Read, Write},
|
||||
process::{Command, ExitStatus, Output, Stdio},
|
||||
sync::mpsc,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
error::{EhError, Result},
|
||||
util::{
|
||||
HashExtractor,
|
||||
NixErrorClassifier,
|
||||
NixFileFixer,
|
||||
handle_nix_with_retry,
|
||||
},
|
||||
error::Result,
|
||||
hash::{HashExtractor, NixFileFixer},
|
||||
retry::{NixErrorClassifier, handle_nix_with_retry},
|
||||
};
|
||||
|
||||
pub mod info;
|
||||
pub mod update;
|
||||
|
||||
const DEFAULT_BUFFER_SIZE: usize = 4096;
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
pub trait LogInterceptor: Send {
|
||||
fn on_stderr(&mut self, chunk: &[u8]);
|
||||
fn on_stdout(&mut self, chunk: &[u8]);
|
||||
}
|
||||
|
||||
pub struct StdIoInterceptor;
|
||||
|
||||
impl LogInterceptor for StdIoInterceptor {
|
||||
fn on_stderr(&mut self, chunk: &[u8]) {
|
||||
let _ = io::stderr().write_all(chunk);
|
||||
}
|
||||
fn on_stdout(&mut self, chunk: &[u8]) {
|
||||
let _ = io::stdout().write_all(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum PipeEvent {
|
||||
Stdout(Vec<u8>),
|
||||
Stderr(Vec<u8>),
|
||||
Error(io::Error),
|
||||
}
|
||||
|
||||
fn read_pipe<R: Read>(
|
||||
mut reader: R,
|
||||
tx: mpsc::Sender<PipeEvent>,
|
||||
is_stderr: bool,
|
||||
) {
|
||||
let mut buf = [0u8; DEFAULT_BUFFER_SIZE];
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
let event = if is_stderr {
|
||||
PipeEvent::Stderr(buf[..n].to_vec())
|
||||
} else {
|
||||
PipeEvent::Stdout(buf[..n].to_vec())
|
||||
};
|
||||
if tx.send(event).is_err() {
|
||||
break;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let _ = tx.send(PipeEvent::Error(e));
|
||||
break;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NixCommand {
|
||||
subcommand: String,
|
||||
args: Vec<String>,
|
||||
env: Vec<(String, String)>,
|
||||
impure: bool,
|
||||
print_build_logs: bool,
|
||||
interactive: bool,
|
||||
}
|
||||
|
||||
impl NixCommand {
|
||||
pub fn new<S: Into<String>>(subcommand: S) -> Self {
|
||||
Self {
|
||||
subcommand: subcommand.into(),
|
||||
args: Vec::new(),
|
||||
env: Vec::new(),
|
||||
impure: false,
|
||||
print_build_logs: true,
|
||||
interactive: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn arg<S: Into<String>>(mut self, arg: S) -> Self {
|
||||
self.args.push(arg.into());
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn args_ref(mut self, args: &[String]) -> 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
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
|
||||
/// Apply per-command configuration: sets `--impure` (when explicitly enabled)
|
||||
/// and any extra environment variables declared in the config file. Call
|
||||
/// this before any retry-specific overrides so that retry logic can still
|
||||
/// force `impure(true)` afterwards.
|
||||
#[must_use]
|
||||
pub fn with_config(mut self, cfg: &crate::config::CommandConfig) -> Self {
|
||||
if cfg.impure == Some(true) {
|
||||
self = self.impure(true);
|
||||
}
|
||||
for (k, v) in &cfg.env {
|
||||
self = self.env(k, v);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
fn build_command(&self) -> Command {
|
||||
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);
|
||||
cmd
|
||||
}
|
||||
|
||||
pub fn run_with_logs<I: LogInterceptor + 'static>(
|
||||
&self,
|
||||
mut interceptor: I,
|
||||
) -> Result<ExitStatus> {
|
||||
let mut cmd = self.build_command();
|
||||
|
||||
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 stdout = child.stdout.take().ok_or_else(|| {
|
||||
EhError::CommandFailed {
|
||||
command: format!("nix {}", self.subcommand),
|
||||
}
|
||||
})?;
|
||||
let stderr = child.stderr.take().ok_or_else(|| {
|
||||
EhError::CommandFailed {
|
||||
command: format!("nix {}", self.subcommand),
|
||||
}
|
||||
})?;
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let tx_out = tx.clone();
|
||||
let stdout_thread = thread::spawn(move || read_pipe(stdout, tx_out, false));
|
||||
|
||||
let tx_err = tx;
|
||||
let stderr_thread = thread::spawn(move || read_pipe(stderr, tx_err, true));
|
||||
|
||||
let start_time = Instant::now();
|
||||
|
||||
loop {
|
||||
if start_time.elapsed() > DEFAULT_TIMEOUT {
|
||||
let _ = child.kill();
|
||||
let _ = stdout_thread.join();
|
||||
let _ = stderr_thread.join();
|
||||
let _ = child.wait();
|
||||
return Err(EhError::Timeout {
|
||||
command: format!("nix {}", self.subcommand),
|
||||
duration: DEFAULT_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
match rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(PipeEvent::Stdout(data)) => interceptor.on_stdout(&data),
|
||||
Ok(PipeEvent::Stderr(data)) => interceptor.on_stderr(&data),
|
||||
Ok(PipeEvent::Error(e)) => {
|
||||
let _ = child.kill();
|
||||
let _ = stdout_thread.join();
|
||||
let _ = stderr_thread.join();
|
||||
let _ = child.wait();
|
||||
return Err(EhError::Io(e));
|
||||
},
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {},
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
|
||||
let _ = stdout_thread.join();
|
||||
let _ = stderr_thread.join();
|
||||
|
||||
let status = child.wait()?;
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
pub fn output(&self) -> Result<Output> {
|
||||
let mut cmd = self.build_command();
|
||||
|
||||
if self.interactive {
|
||||
cmd.stdout(Stdio::inherit());
|
||||
cmd.stderr(Stdio::inherit());
|
||||
cmd.stdin(Stdio::inherit());
|
||||
return Ok(cmd.output()?);
|
||||
}
|
||||
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn()?;
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let tx_out = tx.clone();
|
||||
let stdout_thread = thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
if let Some(mut r) = stdout {
|
||||
let _ = r.read_to_end(&mut buf);
|
||||
}
|
||||
let _ = tx_out.send((false, buf));
|
||||
});
|
||||
|
||||
let tx_err = tx;
|
||||
let stderr_thread = thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
if let Some(mut r) = stderr {
|
||||
let _ = r.read_to_end(&mut buf);
|
||||
}
|
||||
let _ = tx_err.send((true, buf));
|
||||
});
|
||||
|
||||
let start_time = Instant::now();
|
||||
let mut stdout_buf = Vec::new();
|
||||
let mut stderr_buf = Vec::new();
|
||||
let mut received = 0;
|
||||
|
||||
while received < 2 {
|
||||
let remaining = DEFAULT_TIMEOUT
|
||||
.checked_sub(start_time.elapsed())
|
||||
.unwrap_or(Duration::ZERO);
|
||||
|
||||
if remaining.is_zero() {
|
||||
let _ = child.kill();
|
||||
let _ = stdout_thread.join();
|
||||
let _ = stderr_thread.join();
|
||||
let _ = child.wait();
|
||||
return Err(EhError::Timeout {
|
||||
command: format!("nix {}", self.subcommand),
|
||||
duration: DEFAULT_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
match rx.recv_timeout(remaining) {
|
||||
Ok((true, buf)) => {
|
||||
stderr_buf = buf;
|
||||
received += 1;
|
||||
},
|
||||
Ok((false, buf)) => {
|
||||
stdout_buf = buf;
|
||||
received += 1;
|
||||
},
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
let _ = child.kill();
|
||||
let _ = stdout_thread.join();
|
||||
let _ = stderr_thread.join();
|
||||
let _ = child.wait();
|
||||
return Err(EhError::Timeout {
|
||||
command: format!("nix {}", self.subcommand),
|
||||
duration: DEFAULT_TIMEOUT,
|
||||
});
|
||||
},
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
|
||||
let _ = stdout_thread.join();
|
||||
let _ = stderr_thread.join();
|
||||
|
||||
let status = child.wait()?;
|
||||
Ok(Output {
|
||||
status,
|
||||
stdout: stdout_buf,
|
||||
stderr: stderr_buf,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_nix_command(
|
||||
command: &str,
|
||||
args: &[String],
|
||||
hash_extractor: &dyn HashExtractor,
|
||||
fixer: &dyn NixFileFixer,
|
||||
classifier: &dyn NixErrorClassifier,
|
||||
cfg: &crate::config::CommandConfig,
|
||||
cfg: &eh_config::CommandConfig,
|
||||
) -> Result<i32> {
|
||||
let intercept_env = matches!(command, "run" | "shell");
|
||||
handle_nix_with_retry(
|
||||
command,
|
||||
args,
|
||||
hash_extractor,
|
||||
fixer,
|
||||
classifier,
|
||||
intercept_env,
|
||||
matches!(command, "run" | "shell" | "develop"),
|
||||
cfg,
|
||||
)
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
use nix_command::{CommandKind, NixCommand, StdIo};
|
||||
|
||||
use crate::{
|
||||
commands::{NixCommand, StdIoInterceptor},
|
||||
error::{EhError, Result},
|
||||
nix_config::ApplyCommandConfig,
|
||||
};
|
||||
|
||||
/// Parse flake input names from `nix flake metadata --json` output.
|
||||
|
|
@ -26,7 +28,7 @@ pub fn parse_flake_inputs(stdout: &str) -> Result<Vec<String>> {
|
|||
|
||||
/// Fetch flake input names by running `nix flake metadata --json`.
|
||||
fn fetch_flake_inputs() -> Result<Vec<String>> {
|
||||
let output = NixCommand::new("flake")
|
||||
let output = NixCommand::new(CommandKind::Flake)
|
||||
.arg("metadata")
|
||||
.arg("--json")
|
||||
.print_build_logs(false)
|
||||
|
|
@ -57,9 +59,10 @@ fn prompt_input_selection(inputs: &[String]) -> Result<Vec<String>> {
|
|||
/// Otherwise, fetch inputs interactively and prompt for selection.
|
||||
pub fn handle_update(
|
||||
args: &[String],
|
||||
cfg: &crate::config::CommandConfig,
|
||||
cfg: &eh_config::CommandConfig,
|
||||
) -> Result<i32> {
|
||||
let selected = if args.is_empty() {
|
||||
eh_log::log_debug!("checking flake inputs");
|
||||
let inputs = fetch_flake_inputs()?;
|
||||
if inputs.is_empty() {
|
||||
return Err(EhError::NoFlakeInputs);
|
||||
|
|
@ -69,14 +72,16 @@ pub fn handle_update(
|
|||
args.to_vec()
|
||||
};
|
||||
|
||||
let mut cmd = NixCommand::new("flake").arg("lock").with_config(cfg);
|
||||
let mut cmd = NixCommand::new(CommandKind::Flake)
|
||||
.arg("lock")
|
||||
.apply_config(cfg);
|
||||
for name in &selected {
|
||||
cmd = cmd.arg("--update-input").arg(name);
|
||||
}
|
||||
|
||||
eh_log::log_info!("updating inputs: {}", selected.join(", "));
|
||||
|
||||
let status = cmd.run_with_logs(StdIoInterceptor)?;
|
||||
let status = cmd.run_with_logs(StdIo)?;
|
||||
Ok(status.code().unwrap_or(1))
|
||||
}
|
||||
|
||||
|
|
|
|||
236
eh/src/config.rs
236
eh/src/config.rs
|
|
@ -1,236 +0,0 @@
|
|||
use std::{
|
||||
collections::HashMap,
|
||||
env,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
/// When `Some(true)`, pass `--impure` to every Nix command.
|
||||
/// When `Some(false)`, block automatic impure retries for every command.
|
||||
/// When absent (`None`), retry behaviour is automatic (default).
|
||||
#[serde(default)]
|
||||
pub impure: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub commands: HashMap<String, CommandConfig>,
|
||||
}
|
||||
|
||||
/// Per-command configuration.
|
||||
#[derive(Debug, Deserialize, Default, Clone)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CommandConfig {
|
||||
/// When `Some(true)`, pass `--impure` to the underlying Nix command.
|
||||
/// When `Some(false)`, block automatic impure retries for this command.
|
||||
/// When absent (`None`), the global setting is used; if that is also absent,
|
||||
/// retry behaviour is automatic (default).
|
||||
#[serde(default)]
|
||||
pub impure: Option<bool>,
|
||||
/// Additional environment variables to set for the Nix command.
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Return the [`CommandConfig`] for `command`.
|
||||
///
|
||||
/// Resolution order: per-command `impure` takes precedence over the global
|
||||
/// `impure`. Neither being set means automatic retry behaviour.
|
||||
pub fn for_command(&self, command: &str) -> CommandConfig {
|
||||
let mut cmd = self.commands.get(command).cloned().unwrap_or_default();
|
||||
// Per-command setting wins; fall back to global.
|
||||
if cmd.impure.is_none() {
|
||||
cmd.impure = self.impure;
|
||||
}
|
||||
cmd
|
||||
}
|
||||
}
|
||||
|
||||
/// Load configuration from the first `.eh.toml` found by walking up from the
|
||||
/// current directory, or from `~/.config/eh/config.toml` as a global
|
||||
/// fallback. Returns a default (empty) config if no file is found or if
|
||||
/// parsing fails.
|
||||
pub fn load() -> Config {
|
||||
if let Some(path) = find_project_config()
|
||||
&& let Some(cfg) = load_from_file(&path)
|
||||
{
|
||||
return cfg;
|
||||
}
|
||||
|
||||
if let Some(path) = global_config_path()
|
||||
&& let Some(cfg) = load_from_file(&path)
|
||||
{
|
||||
return cfg;
|
||||
}
|
||||
|
||||
Config::default()
|
||||
}
|
||||
|
||||
fn find_project_config() -> Option<PathBuf> {
|
||||
let mut dir = env::current_dir().ok()?;
|
||||
loop {
|
||||
let candidate = dir.join(".eh.toml");
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
if !dir.pop() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn global_config_path() -> Option<PathBuf> {
|
||||
let home = env::var("HOME").ok()?;
|
||||
Some(
|
||||
PathBuf::from(home)
|
||||
.join(".config")
|
||||
.join("eh")
|
||||
.join("config.toml"),
|
||||
)
|
||||
}
|
||||
|
||||
fn load_from_file(path: &Path) -> Option<Config> {
|
||||
let content = fs::read_to_string(path).ok()?;
|
||||
match toml::de::from_str::<Config>(&content) {
|
||||
Ok(cfg) => Some(cfg),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"eh: warning: failed to parse config file {}: {}",
|
||||
path.display(),
|
||||
e
|
||||
);
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_empty_config_defaults() {
|
||||
let cfg: Config = toml::from_str("").unwrap();
|
||||
assert!(cfg.impure.is_none());
|
||||
assert!(cfg.commands.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_config_impure_true() {
|
||||
let cfg: Config = toml::from_str(
|
||||
r#"
|
||||
[commands.build]
|
||||
impure = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cfg.for_command("build").impure, Some(true));
|
||||
assert_eq!(cfg.for_command("run").impure, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_config_impure_false() {
|
||||
let cfg: Config = toml::from_str(
|
||||
r#"
|
||||
[commands.build]
|
||||
impure = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cfg.for_command("build").impure, Some(false));
|
||||
assert_eq!(cfg.for_command("run").impure, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_impure_propagates_to_unconfigured_commands() {
|
||||
let cfg: Config = toml::from_str("impure = true").unwrap();
|
||||
// Commands with no per-command entry inherit global.
|
||||
assert_eq!(cfg.for_command("build").impure, Some(true));
|
||||
assert_eq!(cfg.for_command("nonexistent").impure, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_impure_false_propagates_to_unconfigured_commands() {
|
||||
let cfg: Config = toml::from_str("impure = false").unwrap();
|
||||
assert_eq!(cfg.for_command("build").impure, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_per_command_impure_overrides_global() {
|
||||
// Per-command setting wins over global.
|
||||
let cfg: Config = toml::from_str(
|
||||
r#"
|
||||
impure = false
|
||||
|
||||
[commands.build]
|
||||
impure = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cfg.for_command("build").impure, Some(true));
|
||||
// Command without per-command entry falls back to global false.
|
||||
assert_eq!(cfg.for_command("run").impure, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_config_env() {
|
||||
let cfg: Config = toml::from_str(
|
||||
r#"
|
||||
[commands.develop]
|
||||
env = { FOO = "bar", BAZ = "1" }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let dev = cfg.for_command("develop");
|
||||
assert_eq!(dev.env.get("FOO").map(String::as_str), Some("bar"));
|
||||
assert_eq!(dev.env.get("BAZ").map(String::as_str), Some("1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_config_env_table_syntax() {
|
||||
let cfg: Config = toml::from_str(
|
||||
r#"
|
||||
[commands.shell]
|
||||
impure = true
|
||||
|
||||
[commands.shell.env]
|
||||
MY_VAR = "hello"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let shell = cfg.for_command("shell");
|
||||
assert_eq!(shell.impure, Some(true));
|
||||
assert_eq!(shell.env.get("MY_VAR").map(String::as_str), Some("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_for_command_missing_returns_default() {
|
||||
let cfg = Config::default();
|
||||
let cc = cfg.for_command("nonexistent");
|
||||
assert_eq!(cc.impure, None);
|
||||
assert!(cc.env.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_top_level_key_is_rejected() {
|
||||
let result = toml::de::from_str::<Config>("unknown_key = true");
|
||||
assert!(result.is_err(), "unknown top-level keys should be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_command_key_is_rejected() {
|
||||
let result = toml::de::from_str::<Config>(
|
||||
r#"
|
||||
[commands.build]
|
||||
typo_key = true
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"unknown per-command keys should be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
|
|
@ -10,6 +8,9 @@ pub enum EhError {
|
|||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
NixCommand(#[from] nix_command::Error),
|
||||
|
||||
#[error("regex: {0}")]
|
||||
Regex(#[from] regex::Error),
|
||||
|
||||
|
|
@ -28,24 +29,12 @@ pub enum EhError {
|
|||
#[error("process exited with code {code}")]
|
||||
ProcessExit { code: i32 },
|
||||
|
||||
#[error("command '{command}' failed")]
|
||||
CommandFailed { command: String },
|
||||
|
||||
#[error("nix {command} timed out after {} seconds", duration.as_secs())]
|
||||
Timeout {
|
||||
command: String,
|
||||
duration: Duration,
|
||||
},
|
||||
|
||||
#[error("'{expression}' failed to evaluate: {stderr}")]
|
||||
PreEvalFailed {
|
||||
expression: String,
|
||||
stderr: String,
|
||||
},
|
||||
|
||||
#[error("invalid input '{input}': {reason}")]
|
||||
InvalidInput { input: String, reason: String },
|
||||
|
||||
#[error("failed to parse JSON from nix output: {detail}")]
|
||||
JsonParse { detail: String },
|
||||
|
||||
|
|
@ -55,6 +44,9 @@ pub enum EhError {
|
|||
#[error("no inputs selected")]
|
||||
UpdateCancelled,
|
||||
|
||||
#[error("empty nix expression")]
|
||||
InvalidEvalInput,
|
||||
|
||||
#[error(
|
||||
"package {reason} but `--impure` is disabled for `{command}` in config"
|
||||
)]
|
||||
|
|
@ -69,20 +61,18 @@ impl EhError {
|
|||
match self {
|
||||
Self::ProcessExit { code } => *code,
|
||||
Self::NixCommandFailed { .. } => 2,
|
||||
Self::CommandFailed { .. } => 3,
|
||||
Self::HashExtractionFailed { .. } => 4,
|
||||
Self::NoNixFilesFound => 5,
|
||||
Self::HashFixFailed { .. } => 6,
|
||||
Self::InvalidInput { .. } => 7,
|
||||
Self::Io(_) => 8,
|
||||
Self::Io(_) | Self::NixCommand(_) => 8,
|
||||
Self::Regex(_) => 9,
|
||||
Self::Utf8(_) => 10,
|
||||
Self::Timeout { .. } => 11,
|
||||
Self::PreEvalFailed { .. } => 12,
|
||||
Self::JsonParse { .. } => 13,
|
||||
Self::NoFlakeInputs => 14,
|
||||
Self::UpdateCancelled => 0,
|
||||
Self::ImpureRequired { .. } => 15,
|
||||
Self::InvalidEvalInput => 16,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -101,15 +91,6 @@ impl EhError {
|
|||
Self::NoNixFilesFound => {
|
||||
Some("run this command from a directory containing .nix files")
|
||||
},
|
||||
Self::Timeout { .. } => {
|
||||
Some(
|
||||
"the command took too long; try a faster network or a smaller \
|
||||
derivation",
|
||||
)
|
||||
},
|
||||
Self::InvalidInput { .. } => {
|
||||
Some("avoid shell metacharacters in nix arguments")
|
||||
},
|
||||
Self::JsonParse { .. } => {
|
||||
Some("ensure 'nix flake metadata --json' produces valid output")
|
||||
},
|
||||
|
|
@ -122,12 +103,15 @@ impl EhError {
|
|||
~/.config/eh/config.toml, or pass `--impure` manually",
|
||||
)
|
||||
},
|
||||
Self::InvalidEvalInput => {
|
||||
Some("pass a package name, flake reference, or path")
|
||||
},
|
||||
Self::Io(_)
|
||||
| Self::NixCommand(_)
|
||||
| Self::Regex(_)
|
||||
| Self::Utf8(_)
|
||||
| Self::HashFixFailed { .. }
|
||||
| Self::ProcessExit { .. }
|
||||
| Self::CommandFailed { .. }
|
||||
| Self::UpdateCancelled => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -146,13 +130,6 @@ mod tests {
|
|||
.exit_code(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
EhError::CommandFailed {
|
||||
command: "x".into(),
|
||||
}
|
||||
.exit_code(),
|
||||
3
|
||||
);
|
||||
assert_eq!(
|
||||
EhError::HashExtractionFailed {
|
||||
stderr: String::new(),
|
||||
|
|
@ -162,22 +139,6 @@ mod tests {
|
|||
);
|
||||
assert_eq!(EhError::NoNixFilesFound.exit_code(), 5);
|
||||
assert_eq!(EhError::HashFixFailed { path: "x".into() }.exit_code(), 6);
|
||||
assert_eq!(
|
||||
EhError::InvalidInput {
|
||||
input: "x".into(),
|
||||
reason: "y".into(),
|
||||
}
|
||||
.exit_code(),
|
||||
7
|
||||
);
|
||||
assert_eq!(
|
||||
EhError::Timeout {
|
||||
command: "build".into(),
|
||||
duration: Duration::from_secs(300),
|
||||
}
|
||||
.exit_code(),
|
||||
11
|
||||
);
|
||||
assert_eq!(
|
||||
EhError::PreEvalFailed {
|
||||
expression: "x".into(),
|
||||
|
|
@ -194,12 +155,6 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_display_messages() {
|
||||
let err = EhError::Timeout {
|
||||
command: "build".into(),
|
||||
duration: Duration::from_secs(300),
|
||||
};
|
||||
assert_eq!(err.to_string(), "nix build timed out after 300 seconds");
|
||||
|
||||
let err = EhError::PreEvalFailed {
|
||||
expression: "nixpkgs#hello".into(),
|
||||
stderr: "attribute not found".into(),
|
||||
|
|
@ -231,22 +186,6 @@ mod tests {
|
|||
.is_some()
|
||||
);
|
||||
assert!(EhError::NoNixFilesFound.hint().is_some());
|
||||
assert!(
|
||||
EhError::Timeout {
|
||||
command: "x".into(),
|
||||
duration: Duration::from_secs(1),
|
||||
}
|
||||
.hint()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
EhError::InvalidInput {
|
||||
input: "x".into(),
|
||||
reason: "y".into(),
|
||||
}
|
||||
.hint()
|
||||
.is_some()
|
||||
);
|
||||
// Variants with hints
|
||||
assert!(
|
||||
EhError::NixCommandFailed {
|
||||
|
|
@ -258,13 +197,6 @@ mod tests {
|
|||
assert!(EhError::JsonParse { detail: "x".into() }.hint().is_some());
|
||||
assert!(EhError::NoFlakeInputs.hint().is_some());
|
||||
// Variants without hints
|
||||
assert!(
|
||||
EhError::CommandFailed {
|
||||
command: "x".into(),
|
||||
}
|
||||
.hint()
|
||||
.is_none()
|
||||
);
|
||||
assert!(EhError::ProcessExit { code: 1 }.hint().is_none());
|
||||
assert!(EhError::UpdateCancelled.hint().is_none());
|
||||
}
|
||||
|
|
|
|||
48
eh/src/eval.rs
Normal file
48
eh/src/eval.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
use crate::error::{EhError, Result};
|
||||
|
||||
pub fn package_arg(args: &[String]) -> Option<&str> {
|
||||
args
|
||||
.iter()
|
||||
.find(|arg| !arg.starts_with('-'))
|
||||
.map(String::as_str)
|
||||
}
|
||||
|
||||
pub fn make_eval_expr(eval_arg: &str) -> Result<String> {
|
||||
let eval_arg = eval_arg.trim();
|
||||
if eval_arg.is_empty() {
|
||||
return Err(EhError::InvalidEvalInput);
|
||||
}
|
||||
let eval_arg = if eval_arg == "." { ".#" } else { eval_arg };
|
||||
if eval_arg.ends_with('#') {
|
||||
Ok(format!("{eval_arg}default.meta"))
|
||||
} else if eval_arg.contains('#') {
|
||||
Ok(format!("{eval_arg}.meta"))
|
||||
} else {
|
||||
Ok(format!("nixpkgs#{eval_arg}.meta"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builds_metadata_eval_expressions() {
|
||||
assert_eq!(make_eval_expr("hello").unwrap(), "nixpkgs#hello.meta");
|
||||
assert_eq!(make_eval_expr(".").unwrap(), ".#default.meta");
|
||||
assert_eq!(make_eval_expr(".#").unwrap(), ".#default.meta");
|
||||
assert_eq!(
|
||||
make_eval_expr("github:nixos/nixpkgs#hello").unwrap(),
|
||||
"github:nixos/nixpkgs#hello.meta"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_eval_expression() {
|
||||
assert!(matches!(make_eval_expr(""), Err(EhError::InvalidEvalInput)));
|
||||
assert!(matches!(
|
||||
make_eval_expr(" "),
|
||||
Err(EhError::InvalidEvalInput)
|
||||
));
|
||||
}
|
||||
}
|
||||
275
eh/src/hash.rs
Normal file
275
eh/src/hash.rs
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
use std::{
|
||||
io::{BufWriter, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::LazyLock,
|
||||
};
|
||||
|
||||
use eh_log::log_info;
|
||||
use regex::Regex;
|
||||
use tempfile::NamedTempFile;
|
||||
use walkdir::WalkDir;
|
||||
use yansi::Paint;
|
||||
|
||||
use crate::error::{EhError, Result};
|
||||
|
||||
const MAX_DIR_DEPTH: usize = 3;
|
||||
|
||||
static HASH_EXTRACT_PATTERNS: LazyLock<[Regex; 3]> = LazyLock::new(|| {
|
||||
[
|
||||
Regex::new(r"got:\s+(sha256-[a-zA-Z0-9+/=]+)").unwrap(),
|
||||
Regex::new(r"actual:\s+(sha256-[a-zA-Z0-9+/=]+)").unwrap(),
|
||||
Regex::new(r"have:\s+(sha256-[a-zA-Z0-9+/=]+)").unwrap(),
|
||||
]
|
||||
});
|
||||
|
||||
static HASH_OLD_EXTRACT_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"specified:\s+(sha256-[a-zA-Z0-9+/=]+)").unwrap()
|
||||
});
|
||||
|
||||
static HASH_FIX_PATTERNS: LazyLock<[Regex; 3]> = LazyLock::new(|| {
|
||||
[
|
||||
Regex::new(r#"hash\s*=\s*"[^"]*""#).unwrap(),
|
||||
Regex::new(r#"sha256\s*=\s*"[^"]*""#).unwrap(),
|
||||
Regex::new(r#"outputHash\s*=\s*"[^"]*""#).unwrap(),
|
||||
]
|
||||
});
|
||||
|
||||
pub trait HashExtractor {
|
||||
fn extract_hash(&self, stderr: &str) -> Option<String>;
|
||||
fn extract_old_hash(&self, stderr: &str) -> Option<String>;
|
||||
}
|
||||
|
||||
pub struct RegexHashExtractor;
|
||||
|
||||
impl HashExtractor for RegexHashExtractor {
|
||||
fn extract_hash(&self, stderr: &str) -> Option<String> {
|
||||
HASH_EXTRACT_PATTERNS.iter().find_map(|re| {
|
||||
re.captures(stderr)
|
||||
.and_then(|captures| captures.get(1))
|
||||
.map(|hash| hash.as_str().to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_old_hash(&self, stderr: &str) -> Option<String> {
|
||||
HASH_OLD_EXTRACT_PATTERN
|
||||
.captures(stderr)
|
||||
.and_then(|c| c.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait NixFileFixer {
|
||||
fn fix_hash_in_files(
|
||||
&self,
|
||||
old_hash: Option<&str>,
|
||||
new_hash: &str,
|
||||
) -> Result<bool>;
|
||||
fn find_nix_files(&self) -> Result<Vec<PathBuf>>;
|
||||
fn fix_hash_in_file(
|
||||
&self,
|
||||
file_path: &Path,
|
||||
old_hash: Option<&str>,
|
||||
new_hash: &str,
|
||||
) -> Result<bool>;
|
||||
}
|
||||
|
||||
pub struct DefaultNixFileFixer;
|
||||
|
||||
impl NixFileFixer for DefaultNixFileFixer {
|
||||
fn fix_hash_in_files(
|
||||
&self,
|
||||
old_hash: Option<&str>,
|
||||
new_hash: &str,
|
||||
) -> Result<bool> {
|
||||
let mut fixed = false;
|
||||
for file_path in self.find_nix_files()? {
|
||||
if self.fix_hash_in_file(&file_path, old_hash, new_hash)? {
|
||||
log_info!("updated hash in {}", file_path.display().bold());
|
||||
fixed = true;
|
||||
}
|
||||
}
|
||||
Ok(fixed)
|
||||
}
|
||||
|
||||
fn find_nix_files(&self) -> Result<Vec<PathBuf>> {
|
||||
let files = WalkDir::new(".")
|
||||
.max_depth(MAX_DIR_DEPTH)
|
||||
.into_iter()
|
||||
.filter_entry(|entry| !should_skip(entry))
|
||||
.filter_map(std::result::Result::ok)
|
||||
.filter(|entry| {
|
||||
entry.file_type().is_file()
|
||||
&& entry.path().extension().is_some_and(|ext| ext == "nix")
|
||||
})
|
||||
.map(|entry| entry.path().to_path_buf())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if files.is_empty() {
|
||||
return Err(EhError::NoNixFilesFound);
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn fix_hash_in_file(
|
||||
&self,
|
||||
file_path: &Path,
|
||||
old_hash: Option<&str>,
|
||||
new_hash: &str,
|
||||
) -> Result<bool> {
|
||||
let content = std::fs::read_to_string(file_path)?;
|
||||
let result_content = if let Some(old) = old_hash {
|
||||
replace_target_hash(&content, old, new_hash)?
|
||||
} else {
|
||||
replace_any_hash(&content, new_hash)
|
||||
};
|
||||
|
||||
if result_content == content {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let temp_file =
|
||||
NamedTempFile::new_in(file_path.parent().unwrap_or(Path::new(".")))?;
|
||||
{
|
||||
let mut writer = BufWriter::new(temp_file.as_file());
|
||||
writer.write_all(result_content.as_bytes())?;
|
||||
writer.flush()?;
|
||||
}
|
||||
temp_file.persist(file_path).map_err(|_| {
|
||||
EhError::HashFixFailed {
|
||||
path: file_path.to_string_lossy().to_string(),
|
||||
}
|
||||
})?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
fn should_skip(entry: &walkdir::DirEntry) -> bool {
|
||||
if entry.depth() == 0 || !entry.file_type().is_dir() {
|
||||
return false;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy();
|
||||
name.starts_with('.')
|
||||
|| matches!(name.as_ref(), "node_modules" | "target" | "result")
|
||||
}
|
||||
|
||||
fn replace_target_hash(
|
||||
content: &str,
|
||||
old_hash: &str,
|
||||
new_hash: &str,
|
||||
) -> Result<String> {
|
||||
let old = regex::escape(old_hash);
|
||||
let replacements = [
|
||||
(
|
||||
Regex::new(&format!(r#"hash\s*=\s*"{old}""#))?,
|
||||
format!(r#"hash = "{new_hash}""#),
|
||||
),
|
||||
(
|
||||
Regex::new(&format!(r#"sha256\s*=\s*"{old}""#))?,
|
||||
format!(r#"sha256 = "{new_hash}""#),
|
||||
),
|
||||
(
|
||||
Regex::new(&format!(r#"outputHash\s*=\s*"{old}""#))?,
|
||||
format!(r#"outputHash = "{new_hash}""#),
|
||||
),
|
||||
];
|
||||
Ok(replace_with_patterns(
|
||||
content,
|
||||
replacements.iter().map(|(re, value)| (re, value.as_str())),
|
||||
))
|
||||
}
|
||||
|
||||
fn replace_any_hash(content: &str, new_hash: &str) -> String {
|
||||
let replacements = [
|
||||
format!(r#"hash = "{new_hash}""#),
|
||||
format!(r#"sha256 = "{new_hash}""#),
|
||||
format!(r#"outputHash = "{new_hash}""#),
|
||||
];
|
||||
replace_with_patterns(
|
||||
content,
|
||||
HASH_FIX_PATTERNS
|
||||
.iter()
|
||||
.zip(replacements.iter().map(String::as_str)),
|
||||
)
|
||||
}
|
||||
|
||||
fn replace_with_patterns<'a>(
|
||||
content: &str,
|
||||
patterns: impl Iterator<Item = (&'a Regex, &'a str)>,
|
||||
) -> String {
|
||||
patterns.fold(content.to_string(), |acc, (re, replacement)| {
|
||||
re.replace_all(&acc, replacement).into_owned()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_hash_mismatch_error(stderr: &str) -> bool {
|
||||
stderr.contains("hash mismatch")
|
||||
|| (stderr.contains("specified:") && stderr.contains("got:"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Write;
|
||||
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extracts_new_and_old_hashes() {
|
||||
let stderr = "specified: sha256-OLD\n got: sha256-NEW=";
|
||||
let extractor = RegexHashExtractor;
|
||||
assert_eq!(
|
||||
extractor.extract_hash(stderr),
|
||||
Some("sha256-NEW=".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extractor.extract_old_hash(stderr),
|
||||
Some("sha256-OLD".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_only_matching_old_hash() {
|
||||
let file = NamedTempFile::new().unwrap();
|
||||
let path = file.path();
|
||||
std::fs::write(
|
||||
path,
|
||||
r#"hash = "sha256-old";
|
||||
sha256 = "sha256-other";
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
DefaultNixFileFixer
|
||||
.fix_hash_in_file(path, Some("sha256-old"), "sha256-new")
|
||||
.unwrap()
|
||||
);
|
||||
let updated = std::fs::read_to_string(path).unwrap();
|
||||
assert!(updated.contains(r#"hash = "sha256-new""#));
|
||||
assert!(updated.contains(r#"sha256 = "sha256-other""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_all_hash_attributes_without_old_hash() {
|
||||
let file = NamedTempFile::new().unwrap();
|
||||
let path = file.path();
|
||||
let mut writer = std::fs::File::create(path).unwrap();
|
||||
writer
|
||||
.write_all(
|
||||
br#"hash = "a";
|
||||
sha256 = "b";
|
||||
outputHash = "c";
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
DefaultNixFileFixer
|
||||
.fix_hash_in_file(path, None, "sha256-new")
|
||||
.unwrap()
|
||||
);
|
||||
let updated = std::fs::read_to_string(path).unwrap();
|
||||
assert_eq!(updated.matches("sha256-new").count(), 3);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,4 @@
|
|||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod util;
|
||||
|
||||
pub use clap::{CommandFactory, Parser, Subcommand};
|
||||
pub use error::{EhError, Result};
|
||||
|
|
@ -11,6 +8,14 @@ pub use error::{EhError, Result};
|
|||
#[command(about = "Ergonomic Nix helper", long_about = None)]
|
||||
#[command(version)]
|
||||
pub struct Cli {
|
||||
/// Increase logging verbosity (-v, -vv, -vvv)
|
||||
#[arg(short, long, action = clap::ArgAction::Count, global = true)]
|
||||
pub verbose: u8,
|
||||
|
||||
/// Decrease logging verbosity (-q, -qq)
|
||||
#[arg(short, long, action = clap::ArgAction::Count, global = true)]
|
||||
pub quiet: u8,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@ use eh::{Cli, Command, CommandFactory, Parser};
|
|||
use yansi::Paint;
|
||||
|
||||
mod commands;
|
||||
mod config;
|
||||
mod error;
|
||||
mod util;
|
||||
mod eval;
|
||||
mod hash;
|
||||
mod nix_config;
|
||||
mod retry;
|
||||
mod suggestions;
|
||||
|
||||
fn main() {
|
||||
let result = run_app();
|
||||
|
|
@ -27,10 +30,10 @@ fn main() {
|
|||
}
|
||||
|
||||
fn handle_command(command: &str, args: &[String]) -> error::Result<i32> {
|
||||
let hash_extractor = util::RegexHashExtractor;
|
||||
let fixer = util::DefaultNixFileFixer;
|
||||
let classifier = util::DefaultNixErrorClassifier;
|
||||
let cfg = config::load();
|
||||
let hash_extractor = hash::RegexHashExtractor;
|
||||
let fixer = hash::DefaultNixFileFixer;
|
||||
let classifier = retry::DefaultNixErrorClassifier;
|
||||
let cfg = eh_config::load();
|
||||
let cmd_cfg = cfg.for_command(command);
|
||||
|
||||
match command {
|
||||
|
|
@ -53,9 +56,21 @@ fn handle_command(command: &str, args: &[String]) -> error::Result<i32> {
|
|||
|
||||
fn dispatch_multicall(
|
||||
app_name: &str,
|
||||
args: std::env::Args,
|
||||
args: impl IntoIterator<Item = String>,
|
||||
) -> Option<error::Result<i32>> {
|
||||
let rest: Vec<String> = args.collect();
|
||||
let mut verbosity = 0i8;
|
||||
let mut rest = Vec::new();
|
||||
for arg in args {
|
||||
match arg.as_str() {
|
||||
"-v" | "--verbose" => verbosity += 1,
|
||||
"-vv" => verbosity += 2,
|
||||
"-vvv" => verbosity += 3,
|
||||
"-q" | "--quiet" => verbosity -= 1,
|
||||
"-qq" => verbosity -= 2,
|
||||
_ => rest.push(arg),
|
||||
}
|
||||
}
|
||||
eh_log::set_verbosity(verbosity);
|
||||
|
||||
let subcommand = match app_name {
|
||||
"nr" => "run",
|
||||
|
|
@ -104,6 +119,7 @@ fn run_app() -> error::Result<i32> {
|
|||
}
|
||||
|
||||
let cli = Cli::parse();
|
||||
eh_log::set_verbosity(cli.verbose as i8 - cli.quiet as i8);
|
||||
|
||||
match cli.command {
|
||||
Some(Command::Run { args }) => handle_command("run", &args),
|
||||
|
|
|
|||
14
eh/src/nix_config.rs
Normal file
14
eh/src/nix_config.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
use nix_command::NixCommand;
|
||||
|
||||
pub trait ApplyCommandConfig {
|
||||
fn apply_config(self, cfg: &eh_config::CommandConfig) -> Self;
|
||||
}
|
||||
|
||||
impl ApplyCommandConfig for NixCommand {
|
||||
fn apply_config(mut self, cfg: &eh_config::CommandConfig) -> Self {
|
||||
if cfg.impure == Some(true) {
|
||||
self = self.impure(true);
|
||||
}
|
||||
self.envs(cfg.env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
|
||||
}
|
||||
}
|
||||
365
eh/src/retry.rs
Normal file
365
eh/src/retry.rs
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
use std::io::{IsTerminal, Write};
|
||||
|
||||
use eh_log::{log_debug, log_info, log_warn};
|
||||
use nix_command::{CommandKind, NixCommand, StdIo};
|
||||
use yansi::Paint;
|
||||
|
||||
use crate::{
|
||||
error::{EhError, Result},
|
||||
eval::{make_eval_expr, package_arg},
|
||||
hash::{HashExtractor, NixFileFixer, is_hash_mismatch_error},
|
||||
nix_config::ApplyCommandConfig,
|
||||
suggestions::print_error_suggestions,
|
||||
};
|
||||
|
||||
pub trait NixErrorClassifier {
|
||||
fn should_retry(&self, stderr: &str) -> bool;
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum RetryAction {
|
||||
AllowUnfree,
|
||||
AllowInsecure,
|
||||
AllowBroken,
|
||||
None,
|
||||
}
|
||||
|
||||
impl RetryAction {
|
||||
const fn env_override(&self) -> Option<(&str, &str)> {
|
||||
match self {
|
||||
Self::AllowUnfree => {
|
||||
Some(("NIXPKGS_ALLOW_UNFREE", "has an unfree license"))
|
||||
},
|
||||
Self::AllowInsecure => {
|
||||
Some(("NIXPKGS_ALLOW_INSECURE", "has been marked as insecure"))
|
||||
},
|
||||
Self::AllowBroken => {
|
||||
Some(("NIXPKGS_ALLOW_BROKEN", "has been marked as broken"))
|
||||
},
|
||||
Self::None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn classify_retry_action(stderr: &str) -> RetryAction {
|
||||
if stderr.contains("refusing") && stderr.contains("has an unfree license") {
|
||||
RetryAction::AllowUnfree
|
||||
} else if stderr.contains("refusing")
|
||||
&& stderr.contains("has been marked as insecure")
|
||||
{
|
||||
RetryAction::AllowInsecure
|
||||
} else if stderr.contains("refusing")
|
||||
&& stderr.contains("has been marked as broken")
|
||||
{
|
||||
RetryAction::AllowBroken
|
||||
} else {
|
||||
RetryAction::None
|
||||
}
|
||||
}
|
||||
|
||||
fn command_kind(subcommand: &str) -> Result<CommandKind> {
|
||||
CommandKind::try_from(subcommand).map_err(|_| {
|
||||
EhError::NixCommandFailed {
|
||||
command: subcommand.to_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn nix_command(
|
||||
subcommand: &str,
|
||||
args: &[String],
|
||||
cfg: &eh_config::CommandConfig,
|
||||
interactive: bool,
|
||||
) -> Result<NixCommand> {
|
||||
Ok(
|
||||
NixCommand::new(command_kind(subcommand)?)
|
||||
.args_ref(args)
|
||||
.apply_config(cfg)
|
||||
.interactive(interactive),
|
||||
)
|
||||
}
|
||||
|
||||
fn run_nix_command(
|
||||
subcommand: &str,
|
||||
args: &[String],
|
||||
cfg: &eh_config::CommandConfig,
|
||||
interactive: bool,
|
||||
env_override: Option<&str>,
|
||||
) -> Result<i32> {
|
||||
let mut cmd = nix_command(subcommand, args, cfg, interactive)?;
|
||||
if let Some(env_var) = env_override {
|
||||
cmd = cmd.env(env_var, "1").impure(true);
|
||||
}
|
||||
if interactive {
|
||||
log_debug!("entering {}", command_display(subcommand, args));
|
||||
}
|
||||
Ok(cmd.run_with_logs(StdIo)?.code().unwrap_or(1))
|
||||
}
|
||||
|
||||
fn command_display(subcommand: &str, args: &[String]) -> String {
|
||||
if args.is_empty() {
|
||||
format!("nix {subcommand}")
|
||||
} else {
|
||||
format!("nix {} {}", subcommand, args.join(" "))
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_impure_allowed(
|
||||
cfg: &eh_config::CommandConfig,
|
||||
subcommand: &str,
|
||||
reason: &str,
|
||||
) -> Result<()> {
|
||||
if cfg.impure == Some(false) {
|
||||
return Err(EhError::ImpureRequired {
|
||||
command: subcommand.to_string(),
|
||||
reason: reason.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_package_flags(args: &[String]) -> Result<RetryAction> {
|
||||
let eval_arg = package_arg(args).unwrap_or(".");
|
||||
let eval_expr = make_eval_expr(eval_arg)?;
|
||||
let output = match NixCommand::new(CommandKind::Eval)
|
||||
.arg("--json")
|
||||
.arg(eval_expr)
|
||||
.output()
|
||||
{
|
||||
Ok(output) if output.status.success() => output,
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !stderr.contains("does not provide attribute") {
|
||||
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 = match serde_json::from_slice::<serde_json::Value>(&output.stdout) {
|
||||
Ok(meta) => meta,
|
||||
Err(e) => {
|
||||
log_warn!("failed to parse package metadata for '{}': {}", eval_arg, e);
|
||||
return Ok(RetryAction::None);
|
||||
},
|
||||
};
|
||||
|
||||
[
|
||||
("unfree", RetryAction::AllowUnfree),
|
||||
("insecure", RetryAction::AllowInsecure),
|
||||
("broken", RetryAction::AllowBroken),
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|(key, action)| {
|
||||
meta
|
||||
.get(key)
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
.then_some(action)
|
||||
})
|
||||
.ok_or(EhError::ProcessExit { code: 0 })
|
||||
.or(Ok(RetryAction::None))
|
||||
}
|
||||
|
||||
fn pre_evaluate(args: &[String]) -> Result<RetryAction> {
|
||||
let action = check_package_flags(args)?;
|
||||
if action != RetryAction::None {
|
||||
return Ok(action);
|
||||
}
|
||||
|
||||
let eval_arg = package_arg(args).unwrap_or(".");
|
||||
let output = NixCommand::new(CommandKind::Eval).arg(eval_arg).output()?;
|
||||
if output.status.success() {
|
||||
return Ok(RetryAction::None);
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let action = classify_retry_action(&stderr);
|
||||
if action != RetryAction::None {
|
||||
return Ok(action);
|
||||
}
|
||||
|
||||
let stderr = stderr
|
||||
.trim()
|
||||
.strip_prefix("error:")
|
||||
.unwrap_or(stderr.trim())
|
||||
.trim();
|
||||
Err(EhError::PreEvalFailed {
|
||||
expression: eval_arg.to_string(),
|
||||
stderr: stderr.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn handle_nix_with_retry(
|
||||
subcommand: &str,
|
||||
args: &[String],
|
||||
hash_extractor: &dyn HashExtractor,
|
||||
fixer: &dyn NixFileFixer,
|
||||
classifier: &dyn NixErrorClassifier,
|
||||
interactive: bool,
|
||||
cfg: &eh_config::CommandConfig,
|
||||
) -> Result<i32> {
|
||||
let pkg = package_arg(args).unwrap_or("<unknown>");
|
||||
log_debug!("checking {}", command_display(subcommand, args));
|
||||
if let Some((env_var, reason)) = pre_evaluate(args)?.env_override() {
|
||||
ensure_impure_allowed(cfg, subcommand, reason)?;
|
||||
print_retry_msg(pkg, reason, env_var);
|
||||
return run_nix_command(subcommand, args, cfg, interactive, Some(env_var));
|
||||
}
|
||||
|
||||
if interactive {
|
||||
let code = run_nix_command(subcommand, args, cfg, true, None)?;
|
||||
if code == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
|
||||
log_debug!("running {}", command_display(subcommand, args));
|
||||
let output = nix_command(subcommand, args, cfg, false)?.output()?;
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
if let Some(new_hash) = hash_extractor.extract_hash(&stderr) {
|
||||
let ctx = HashFixContext {
|
||||
subcommand,
|
||||
args,
|
||||
cfg,
|
||||
interactive,
|
||||
pkg,
|
||||
fixer,
|
||||
};
|
||||
if let Some(code) = handle_hash_mismatch(
|
||||
ctx,
|
||||
hash_extractor.extract_old_hash(&stderr),
|
||||
&new_hash,
|
||||
)? {
|
||||
return Ok(code);
|
||||
}
|
||||
} else if is_hash_mismatch_error(&stderr) {
|
||||
return Err(EhError::HashExtractionFailed {
|
||||
stderr: stderr.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if classifier.should_retry(&stderr)
|
||||
&& let Some((env_var, reason)) =
|
||||
classify_retry_action(&stderr).env_override()
|
||||
{
|
||||
ensure_impure_allowed(cfg, subcommand, reason)?;
|
||||
print_retry_msg(pkg, reason, env_var);
|
||||
return run_nix_command(subcommand, args, cfg, interactive, Some(env_var));
|
||||
}
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
std::io::stderr()
|
||||
.write_all(&output.stderr)
|
||||
.map_err(EhError::Io)?;
|
||||
print_error_suggestions(&output.stderr);
|
||||
output.status.code().map_or_else(
|
||||
|| {
|
||||
Err(EhError::NixCommandFailed {
|
||||
command: subcommand.to_string(),
|
||||
})
|
||||
},
|
||||
|code| Err(EhError::ProcessExit { code }),
|
||||
)
|
||||
}
|
||||
|
||||
struct HashFixContext<'a> {
|
||||
subcommand: &'a str,
|
||||
args: &'a [String],
|
||||
cfg: &'a eh_config::CommandConfig,
|
||||
interactive: bool,
|
||||
pkg: &'a str,
|
||||
fixer: &'a dyn NixFileFixer,
|
||||
}
|
||||
|
||||
fn handle_hash_mismatch(
|
||||
ctx: HashFixContext<'_>,
|
||||
old_hash: Option<String>,
|
||||
new_hash: &str,
|
||||
) -> Result<Option<i32>> {
|
||||
if !std::io::stdin().is_terminal() {
|
||||
log_info!(
|
||||
"{}: skipping hash fix in non-interactive mode",
|
||||
ctx.pkg.bold()
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let should_fix = dialoguer::Confirm::new()
|
||||
.with_prompt(format!(
|
||||
"Hash mismatch detected for {}. Update hash in local .nix files?",
|
||||
ctx.pkg.bold()
|
||||
))
|
||||
.default(true)
|
||||
.interact()
|
||||
.map_err(|e| EhError::Io(std::io::Error::other(e)))?;
|
||||
|
||||
if !should_fix {
|
||||
log_warn!("{}: hash fix cancelled", ctx.pkg.bold());
|
||||
return Err(EhError::ProcessExit { code: 1 });
|
||||
}
|
||||
|
||||
match ctx.fixer.fix_hash_in_files(old_hash.as_deref(), new_hash) {
|
||||
Ok(true) => {
|
||||
log_info!(
|
||||
"{}: hash mismatch corrected in local files, rebuilding",
|
||||
ctx.pkg.bold()
|
||||
);
|
||||
run_nix_command(ctx.subcommand, ctx.args, ctx.cfg, ctx.interactive, None)
|
||||
.map(Some)
|
||||
},
|
||||
Ok(false) | Err(EhError::NoNixFilesFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DefaultNixErrorClassifier;
|
||||
|
||||
impl NixErrorClassifier for DefaultNixErrorClassifier {
|
||||
fn should_retry(&self, stderr: &str) -> bool {
|
||||
classify_retry_action(stderr) != RetryAction::None
|
||||
}
|
||||
}
|
||||
|
||||
fn print_retry_msg(pkg: &str, reason: &str, env_var: &str) {
|
||||
log_warn!(
|
||||
"{}: {}, setting {}",
|
||||
pkg.bold(),
|
||||
reason,
|
||||
format!("{env_var}=1").bold()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classifies_retryable_errors() {
|
||||
assert_eq!(
|
||||
classify_retry_action("refusing because it has an unfree license"),
|
||||
RetryAction::AllowUnfree
|
||||
);
|
||||
assert_eq!(
|
||||
classify_retry_action("refusing because it has been marked as insecure"),
|
||||
RetryAction::AllowInsecure
|
||||
);
|
||||
assert_eq!(
|
||||
classify_retry_action("refusing because it has been marked as broken"),
|
||||
RetryAction::AllowBroken
|
||||
);
|
||||
assert_eq!(classify_retry_action("ordinary error"), RetryAction::None);
|
||||
}
|
||||
}
|
||||
58
eh/src/suggestions.rs
Normal file
58
eh/src/suggestions.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
use std::sync::LazyLock;
|
||||
|
||||
use eh_log::log_info;
|
||||
use regex::Regex;
|
||||
use yansi::Paint;
|
||||
|
||||
static DID_YOU_MEAN_PATTERN: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r#"Did you mean (?:one of )?(.+?)\?"#).unwrap());
|
||||
|
||||
fn parse_nix_suggestions(did_you_mean_line: &str) -> Vec<String> {
|
||||
DID_YOU_MEAN_PATTERN
|
||||
.captures(did_you_mean_line)
|
||||
.and_then(|caps| caps.get(1))
|
||||
.map(|m| m.as_str())
|
||||
.map(|suggestions| {
|
||||
suggestions
|
||||
.split(", ")
|
||||
.flat_map(|part| part.split(" or "))
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn print_error_suggestions(stderr: &[u8]) {
|
||||
let stderr = String::from_utf8_lossy(stderr);
|
||||
let Some(line) = stderr.lines().find(|line| line.contains("Did you mean"))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let suggestions = parse_nix_suggestions(line);
|
||||
if suggestions.is_empty() {
|
||||
return;
|
||||
}
|
||||
let formatted = suggestions
|
||||
.iter()
|
||||
.map(|s| s.bold().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
log_info!("Did you mean: {}?", formatted);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_nix_suggestions() {
|
||||
assert_eq!(
|
||||
parse_nix_suggestions(
|
||||
"Did you mean one of neovim, hevi, navi, neo or neo4j?"
|
||||
),
|
||||
["neovim", "hevi", "navi", "neo", "neo4j"]
|
||||
);
|
||||
}
|
||||
}
|
||||
1176
eh/src/util.rs
1176
eh/src/util.rs
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue