Compare commits

...

5 commits

Author SHA1 Message Date
43fdbe5ae6
chore: format rustfmt config with taplo
Some checks are pending
Rust / build (push) Waiting to run
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I3450f16aeb183d33e98d4ddd6287d74a6a6a6964
2026-05-12 17:50:22 +03:00
3f1d906bb2
eh: dissolve eh::util into focused modules; migrate to config and nix-command crates
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I00d4f300a63e2140a320bf601e68cd266a6a6964
2026-05-12 17:50:21 +03:00
74bdf0a045
meta: extract configuration loading and command execution into workspace crates
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I2da9bbddc01186af23e12c0dbbf3b23e6a6a6964
2026-05-12 17:50:20 +03:00
d744510ab2
nix: add cargo-nextets to devshell; minor cleanup
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ie10228eac138766923d6325f4b06070f6a6a6964
2026-05-12 17:50:19 +03:00
4707a7e49f
eh-log: add debug level; respect verbosity levels
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Idc62fd26efc10f0c1e49424cf337e6d16a6a6964
2026-05-12 17:50:18 +03:00
24 changed files with 1598 additions and 2078 deletions

View file

@ -24,4 +24,3 @@ unstable_features = true
use_field_init_shorthand = true
use_try_shorthand = true
wrap_comments = true

86
Cargo.lock generated
View file

@ -104,24 +104,56 @@ dependencies = [
"shell-words",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys",
]
[[package]]
name = "eh"
version = "0.2.0"
dependencies = [
"clap",
"dialoguer",
"eh-config",
"eh-log",
"nix-command",
"regex",
"serde",
"serde_json",
"tempfile",
"textwrap",
"thiserror",
"toml",
"walkdir",
"yansi",
]
[[package]]
name = "eh-config"
version = "0.2.0"
dependencies = [
"dirs",
"eh-log",
"serde",
"toml",
]
[[package]]
name = "eh-log"
version = "0.2.0"
@ -163,6 +195,17 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.4.2"
@ -233,6 +276,15 @@ version = "0.2.185"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
[[package]]
name = "libredox"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
dependencies = [
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@ -251,12 +303,25 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "nix-command"
version = "0.2.0"
dependencies = [
"thiserror",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "prettyplease"
version = "0.2.37"
@ -291,6 +356,17 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "redox_users"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror",
]
[[package]]
name = "regex"
version = "1.12.3"
@ -430,7 +506,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys",
@ -532,6 +608,12 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"

View file

@ -13,9 +13,15 @@ rust-version = "1.94.0"
version = "0.2.0"
[workspace.dependencies]
eh = { path = "./eh", version = "0.2.0" }
eh-config = { path = "./crates/eh-config", version = "0.2.0" }
eh-log = { path = "./crates/eh-log", version = "0.2.0" }
nix-command = { path = "./crates/nix-command", version = "0.2.0" }
clap = { default-features = false, features = [ "std", "help", "derive" ], version = "4.6.0" }
clap_complete = "4.6.0"
dialoguer = { default-features = false, version = "0.12.0" }
dirs = "6.0.0"
regex = "1.12.3"
serde = { features = [ "derive" ], version = "1.0.149" }
serde_json = "1.0.149"
@ -26,9 +32,6 @@ toml = { default-features = false, features = [ "parse", "serde" ], ver
walkdir = "2.5.0"
yansi = "1.0.1"
eh = { path = "./eh" }
eh-log = { path = "./crates/eh-log" }
[profile.release]
codegen-units = 1
lto = true

View file

@ -0,0 +1,13 @@
[package]
name = "eh-config"
description = "Configuration loading for eh"
version.workspace = true
edition.workspace = true
authors.workspace = true
rust-version.workspace = true
[dependencies]
dirs.workspace = true
eh-log.workspace = true
serde.workspace = true
toml.workspace = true

134
crates/eh-config/src/lib.rs Normal file
View file

@ -0,0 +1,134 @@
use std::{
collections::HashMap,
env,
fs,
path::{Path, PathBuf},
};
use eh_log::log_warn;
use serde::Deserialize;
#[derive(Debug, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub impure: Option<bool>,
#[serde(default)]
pub commands: HashMap<String, CommandConfig>,
}
#[derive(Debug, Deserialize, Default, Clone)]
#[serde(deny_unknown_fields)]
pub struct CommandConfig {
#[serde(default)]
pub impure: Option<bool>,
#[serde(default)]
pub env: HashMap<String, String>,
}
impl Config {
#[must_use]
pub fn for_command(&self, command: &str) -> CommandConfig {
let mut cmd = self.commands.get(command).cloned().unwrap_or_default();
cmd.impure = cmd.impure.or(self.impure);
cmd
}
}
#[must_use]
pub fn load() -> Config {
find_project_config()
.and_then(|path| load_from_file(&path))
.or_else(|| global_config_path().and_then(|path| load_from_file(&path)))
.unwrap_or_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> {
dirs::config_dir().map(|dir| dir.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) => {
log_warn!("failed to parse config file {}: {}", path.display(), e);
None
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_config_defaults() {
let cfg: Config = toml::from_str("").unwrap();
assert!(cfg.impure.is_none());
assert!(cfg.commands.is_empty());
}
#[test]
fn command_impure_overrides_global() {
let cfg: Config = toml::from_str(
r#"
impure = false
[commands.build]
impure = true
"#,
)
.unwrap();
assert_eq!(cfg.for_command("build").impure, Some(true));
assert_eq!(cfg.for_command("run").impure, Some(false));
}
#[test]
fn command_env_supports_inline_and_table_syntax() {
let inline: Config = toml::from_str(
r#"
[commands.develop]
env = { FOO = "bar" }
"#,
)
.unwrap();
assert_eq!(
inline.for_command("develop").env.get("FOO"),
Some(&"bar".into())
);
let table: Config = toml::from_str(
r#"
[commands.shell.env]
MY_VAR = "hello"
"#,
)
.unwrap();
assert_eq!(
table.for_command("shell").env.get("MY_VAR"),
Some(&"hello".into())
);
}
#[test]
fn rejects_unknown_fields() {
assert!(toml::de::from_str::<Config>("unknown_key = true").is_err());
assert!(
toml::de::from_str::<Config>("[commands.build]\ntypo = true").is_err()
);
}
}

View file

@ -1,6 +1,6 @@
[package]
name = "eh-log"
description = "Styled logging for eh"
description = "Tiny, styled logging crate for eh"
version.workspace = true
edition.workspace = true
authors.workspace = true

View file

@ -1,26 +1,64 @@
use std::fmt;
use std::{
fmt,
sync::atomic::{AtomicI8, Ordering},
};
use yansi::Paint;
static VERBOSITY: AtomicI8 = AtomicI8::new(0);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Level {
Error = -2,
Warn = -1,
Info = 0,
Debug = 1,
}
pub fn set_verbosity(verbosity: i8) {
VERBOSITY.store(verbosity, Ordering::Relaxed);
}
fn enabled(level: Level) -> bool {
VERBOSITY.load(Ordering::Relaxed) >= level as i8
}
pub fn info(args: fmt::Arguments) {
eprintln!(" {} {args}", "->".green().bold());
if enabled(Level::Info) {
eprintln!(" {} {args}", "->".green().bold());
}
}
pub fn debug(args: fmt::Arguments) {
if enabled(Level::Debug) {
eprintln!(" {} {args}", "*".blue().dim());
}
}
pub fn warn(args: fmt::Arguments) {
eprintln!(" {} {args}", "->".yellow().bold());
if enabled(Level::Warn) {
eprintln!(" {} {args}", "->".yellow().bold());
}
}
pub fn error(args: fmt::Arguments) {
eprintln!(" {} {args}", "!".red().bold());
if enabled(Level::Error) {
eprintln!(" {} {args}", "!".red().bold());
}
}
pub fn hint(args: fmt::Arguments) {
eprintln!(" {} {args}", "~".yellow().dim());
if enabled(Level::Info) {
eprintln!(" {} {args}", "~".yellow().dim());
}
}
#[macro_export]
macro_rules! log_info { ($($t:tt)*) => { $crate::info(format_args!($($t)*)) } }
#[macro_export]
macro_rules! log_debug { ($($t:tt)*) => { $crate::debug(format_args!($($t)*)) } }
#[macro_export]
macro_rules! log_warn { ($($t:tt)*) => { $crate::warn(format_args!($($t)*)) } }

View file

@ -0,0 +1,10 @@
[package]
name = "nix-command"
description = "Typed Nix command construction and execution"
version.workspace = true
edition.workspace = true
authors.workspace = true
rust-version.workspace = true
[dependencies]
thiserror.workspace = true

View file

@ -0,0 +1,416 @@
use std::{
io::{self, Read, Write},
process::{Command, ExitStatus, Output, Stdio},
sync::mpsc,
thread,
time::{Duration, Instant},
};
use thiserror::Error;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
#[derive(Debug, Error)]
pub enum Error {
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("command '{command}' failed")]
CommandFailed { command: String },
#[error("nix {command} timed out after {} seconds", duration.as_secs())]
Timeout {
command: String,
duration: Duration,
},
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CommandKind {
Build,
Develop,
Eval,
Flake,
Run,
Shell,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct CommandSpec {
pub name: &'static str,
pub print_build_logs: bool,
pub interactive: bool,
}
pub const COMMAND_SPECS: &[CommandSpec] = &[
CommandSpec {
name: "build",
print_build_logs: true,
interactive: false,
},
CommandSpec {
name: "develop",
print_build_logs: true,
interactive: true,
},
CommandSpec {
name: "eval",
print_build_logs: false,
interactive: false,
},
CommandSpec {
name: "flake",
print_build_logs: false,
interactive: false,
},
CommandSpec {
name: "run",
print_build_logs: true,
interactive: true,
},
CommandSpec {
name: "shell",
print_build_logs: true,
interactive: true,
},
];
impl CommandKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
self.spec().name
}
#[must_use]
pub const fn spec(self) -> CommandSpec {
match self {
Self::Build => COMMAND_SPECS[0],
Self::Develop => COMMAND_SPECS[1],
Self::Eval => COMMAND_SPECS[2],
Self::Flake => COMMAND_SPECS[3],
Self::Run => COMMAND_SPECS[4],
Self::Shell => COMMAND_SPECS[5],
}
}
}
impl TryFrom<&str> for CommandKind {
type Error = UnknownCommand;
fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
match value {
"build" => Ok(Self::Build),
"develop" => Ok(Self::Develop),
"eval" => Ok(Self::Eval),
"flake" => Ok(Self::Flake),
"run" => Ok(Self::Run),
"shell" => Ok(Self::Shell),
command => {
Err(UnknownCommand {
command: command.to_string(),
})
},
}
}
}
#[derive(Debug, Error, Eq, PartialEq)]
#[error("unknown nix command '{command}'")]
pub struct UnknownCommand {
command: String,
}
pub struct StdIo;
impl StdIo {
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; 4096];
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 {
kind: CommandKind,
args: Vec<String>,
env: Vec<(String, String)>,
impure: bool,
print_build_logs: bool,
interactive: bool,
}
impl NixCommand {
#[must_use]
pub fn new(kind: CommandKind) -> Self {
let spec = kind.spec();
Self {
kind,
args: Vec::new(),
env: Vec::new(),
impure: false,
print_build_logs: spec.print_build_logs,
interactive: spec.interactive,
}
}
#[must_use]
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
}
#[must_use]
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 fn envs<I, K, V>(mut self, env: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
self
.env
.extend(env.into_iter().map(|(k, v)| (k.into(), v.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
}
#[must_use]
pub fn argv(&self) -> Vec<String> {
let mut argv = vec!["nix".to_string(), self.kind.as_str().to_string()];
if self.print_build_logs
&& !self.args.iter().any(|a| a == "--no-build-output")
{
argv.push("--print-build-logs".to_string());
}
if self.impure {
argv.push("--impure".to_string());
}
argv.extend(self.args.iter().cloned());
argv
}
fn build_command(&self) -> Command {
let argv = self.argv();
let mut cmd = Command::new(&argv[0]);
cmd.args(&argv[1..]);
for (k, v) in &self.env {
cmd.env(k, v);
}
cmd
}
pub fn run_with_logs(&self, mut interceptor: StdIo) -> Result<ExitStatus> {
let mut cmd = self.build_command();
if self.interactive {
return Ok(
cmd
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.stdin(Stdio::inherit())
.status()?,
);
}
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn()?;
let stdout = child.stdout.take().ok_or_else(|| self.command_failed())?;
let stderr = child.stderr.take().ok_or_else(|| self.command_failed())?;
let (tx, rx) = mpsc::channel();
let stdout_thread = thread::spawn({
let tx = tx.clone();
move || read_pipe(stdout, tx, false)
});
let stderr_thread = thread::spawn(move || read_pipe(stderr, tx, true));
let start = Instant::now();
loop {
if start.elapsed() > DEFAULT_TIMEOUT {
self.kill_wait_join(&mut child, stdout_thread, stderr_thread)?;
return Err(self.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)) => {
self.kill_wait_join(&mut child, stdout_thread, stderr_thread)?;
return Err(Error::Io(e));
},
Err(mpsc::RecvTimeoutError::Timeout) => {},
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
let _ = stdout_thread.join();
let _ = stderr_thread.join();
Ok(child.wait()?)
}
pub fn output(&self) -> Result<Output> {
let mut cmd = self.build_command();
if self.interactive {
return Ok(
cmd
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.stdin(Stdio::inherit())
.output()?,
);
}
Ok(cmd.output()?)
}
fn kill_wait_join(
&self,
child: &mut std::process::Child,
stdout_thread: thread::JoinHandle<()>,
stderr_thread: thread::JoinHandle<()>,
) -> Result<()> {
let _ = child.kill();
let _ = stdout_thread.join();
let _ = stderr_thread.join();
let _ = child.wait()?;
Ok(())
}
fn command_failed(&self) -> Error {
Error::CommandFailed {
command: self.kind.as_str().to_string(),
}
}
fn timeout(&self) -> Error {
Error::Timeout {
command: self.kind.as_str().to_string(),
duration: DEFAULT_TIMEOUT,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_parses_supported_commands() {
for spec in COMMAND_SPECS {
let kind = CommandKind::try_from(spec.name).unwrap();
assert_eq!(kind.as_str(), spec.name);
}
}
#[test]
fn schema_rejects_unknown_commands() {
assert_eq!(
CommandKind::try_from("repl"),
Err(UnknownCommand {
command: "repl".to_string(),
})
);
}
#[test]
fn argv_is_deterministic_and_schema_driven() {
let argv = NixCommand::new(CommandKind::Build)
.arg("nixpkgs#hello")
.impure(true)
.argv();
assert_eq!(argv, [
"nix",
"build",
"--print-build-logs",
"--impure",
"nixpkgs#hello"
]);
}
#[test]
fn no_build_output_suppresses_print_build_logs() {
let argv = NixCommand::new(CommandKind::Build)
.arg("--no-build-output")
.argv();
assert_eq!(argv, ["nix", "build", "--no-build-output"]);
}
#[test]
fn eval_defaults_to_quiet_schema() {
assert_eq!(NixCommand::new(CommandKind::Eval).argv(), ["nix", "eval"]);
}
#[test]
fn interactive_defaults_come_from_schema() {
assert!(NixCommand::new(CommandKind::Run).interactive);
assert!(NixCommand::new(CommandKind::Shell).interactive);
assert!(NixCommand::new(CommandKind::Develop).interactive);
assert!(!NixCommand::new(CommandKind::Build).interactive);
}
}

View file

@ -11,15 +11,16 @@ crate-type = [ "lib" ]
name = "eh"
[dependencies]
clap.workspace = true
dialoguer.workspace = true
eh-log.workspace = true
regex.workspace = true
serde.workspace = true
serde_json.workspace = true
tempfile.workspace = true
textwrap.workspace = true
thiserror.workspace = true
toml.workspace = true
walkdir.workspace = true
yansi.workspace = true
clap.workspace = true
dialoguer.workspace = true
eh-config.workspace = true
eh-log.workspace = true
nix-command.workspace = true
regex.workspace = true
serde.workspace = true
serde_json.workspace = true
tempfile.workspace = true
textwrap.workspace = true
thiserror.workspace = true
walkdir.workspace = true
yansi.workspace = true

View file

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

View file

@ -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"));
}
}

View file

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

View file

@ -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"
);
}
}

View file

@ -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
View 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
View 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);
}
}

View file

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

View file

@ -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
View 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
View 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
View 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"]
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -5,13 +5,14 @@
rustfmt,
clippy,
taplo,
rust-analyzer-unwrapped,
rustPlatform,
rust-analyzer,
cargo-nextest,
}:
mkShell {
name = "rust";
packages = [
strictDeps = true;
nativeBuildInputs = [
rustc
cargo
@ -19,8 +20,8 @@ mkShell {
clippy
cargo
taplo
rust-analyzer-unwrapped
];
rust-analyzer
env.RUST_SRC_PATH = "${rustPlatform.rustLibSrc}";
cargo-nextest
];
}