From 4707a7e49ff92628dcbdf1f14f4a6cdbefe36bd1 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 12 May 2026 14:48:12 +0300 Subject: [PATCH 1/5] eh-log: add debug level; respect verbosity levels Signed-off-by: NotAShelf Change-Id: Idc62fd26efc10f0c1e49424cf337e6d16a6a6964 --- crates/eh-log/Cargo.toml | 2 +- crates/eh-log/src/lib.rs | 48 +++++++++++++++++++++++++++++++++++----- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/crates/eh-log/Cargo.toml b/crates/eh-log/Cargo.toml index e3ef1f4..7433995 100644 --- a/crates/eh-log/Cargo.toml +++ b/crates/eh-log/Cargo.toml @@ -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 diff --git a/crates/eh-log/src/lib.rs b/crates/eh-log/src/lib.rs index 365deff..92d903d 100644 --- a/crates/eh-log/src/lib.rs +++ b/crates/eh-log/src/lib.rs @@ -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)*)) } } From d744510ab2a33f933f0ba7679e066f980373caf9 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 12 May 2026 17:10:35 +0300 Subject: [PATCH 2/5] nix: add cargo-nextets to devshell; minor cleanup Signed-off-by: NotAShelf Change-Id: Ie10228eac138766923d6325f4b06070f6a6a6964 --- nix/shell.nix | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/nix/shell.nix b/nix/shell.nix index 4584b59..e71bf12 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -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 + ]; } From 74bdf0a0450e8a5ba63d54119bcc96eee3f38b3f Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 12 May 2026 17:21:50 +0300 Subject: [PATCH 3/5] meta: extract configuration loading and command execution into workspace crates Signed-off-by: NotAShelf Change-Id: I2da9bbddc01186af23e12c0dbbf3b23e6a6a6964 --- Cargo.lock | 86 ++++++- Cargo.toml | 9 +- crates/eh-config/Cargo.toml | 13 ++ crates/eh-config/src/lib.rs | 134 +++++++++++ crates/nix-command/Cargo.toml | 10 + crates/nix-command/src/lib.rs | 416 ++++++++++++++++++++++++++++++++++ eh/Cargo.toml | 25 +- 7 files changed, 676 insertions(+), 17 deletions(-) create mode 100644 crates/eh-config/Cargo.toml create mode 100644 crates/eh-config/src/lib.rs create mode 100644 crates/nix-command/Cargo.toml create mode 100644 crates/nix-command/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index dd5c19e..9758723 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 931f336..cb7de60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/crates/eh-config/Cargo.toml b/crates/eh-config/Cargo.toml new file mode 100644 index 0000000..28ec0e1 --- /dev/null +++ b/crates/eh-config/Cargo.toml @@ -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 diff --git a/crates/eh-config/src/lib.rs b/crates/eh-config/src/lib.rs new file mode 100644 index 0000000..5b15dde --- /dev/null +++ b/crates/eh-config/src/lib.rs @@ -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, + #[serde(default)] + pub commands: HashMap, +} + +#[derive(Debug, Deserialize, Default, Clone)] +#[serde(deny_unknown_fields)] +pub struct CommandConfig { + #[serde(default)] + pub impure: Option, + #[serde(default)] + pub env: HashMap, +} + +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 { + 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 { + dirs::config_dir().map(|dir| dir.join("eh").join("config.toml")) +} + +fn load_from_file(path: &Path) -> Option { + let content = fs::read_to_string(path).ok()?; + match toml::de::from_str::(&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::("unknown_key = true").is_err()); + assert!( + toml::de::from_str::("[commands.build]\ntypo = true").is_err() + ); + } +} diff --git a/crates/nix-command/Cargo.toml b/crates/nix-command/Cargo.toml new file mode 100644 index 0000000..b18b531 --- /dev/null +++ b/crates/nix-command/Cargo.toml @@ -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 diff --git a/crates/nix-command/src/lib.rs b/crates/nix-command/src/lib.rs new file mode 100644 index 0000000..c3ef054 --- /dev/null +++ b/crates/nix-command/src/lib.rs @@ -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 = std::result::Result; + +#[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 { + 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), + Stderr(Vec), + Error(io::Error), +} + +fn read_pipe( + mut reader: R, + tx: mpsc::Sender, + 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, + 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>(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, V: Into>( + mut self, + key: K, + value: V, + ) -> Self { + self.env.push((key.into(), value.into())); + self + } + + #[must_use] + pub fn envs(mut self, env: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/eh/Cargo.toml b/eh/Cargo.toml index f3f4e51..0fba45b 100644 --- a/eh/Cargo.toml +++ b/eh/Cargo.toml @@ -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 From 3f1d906bb2d5e2a8668f9e8aa7931180c614ee16 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 12 May 2026 17:41:49 +0300 Subject: [PATCH 4/5] eh: dissolve `eh::util` into focused modules; migrate to config and nix-command crates Signed-off-by: NotAShelf Change-Id: I00d4f300a63e2140a320bf601e68cd266a6a6964 --- eh/src/commands/info.rs | 128 ++-- eh/src/commands/mod.rs | 469 +-------------- eh/src/commands/update.rs | 15 +- eh/src/config.rs | 236 -------- eh/src/error.rs | 92 +-- eh/src/eval.rs | 48 ++ eh/src/hash.rs | 275 +++++++++ eh/src/lib.rs | 11 +- eh/src/main.rs | 32 +- eh/src/nix_config.rs | 14 + eh/src/retry.rs | 365 ++++++++++++ eh/src/suggestions.rs | 58 ++ eh/src/util.rs | 1176 ------------------------------------- 13 files changed, 871 insertions(+), 2048 deletions(-) delete mode 100644 eh/src/config.rs create mode 100644 eh/src/eval.rs create mode 100644 eh/src/hash.rs create mode 100644 eh/src/nix_config.rs create mode 100644 eh/src/retry.rs create mode 100644 eh/src/suggestions.rs delete mode 100644 eh/src/util.rs diff --git a/eh/src/commands/info.rs b/eh/src/commands/info.rs index e0a34c3..64870a6 100644 --- a/eh/src/commands/info.rs +++ b/eh/src/commands/info.rs @@ -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 { - // 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 = 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 { + 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::>(); + 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 = 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!( diff --git a/eh/src/commands/mod.rs b/eh/src/commands/mod.rs index eeacf35..2d590db 100644 --- a/eh/src/commands/mod.rs +++ b/eh/src/commands/mod.rs @@ -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), - Stderr(Vec), - Error(io::Error), -} - -fn read_pipe( - mut reader: R, - tx: mpsc::Sender, - 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, - env: Vec<(String, String)>, - impure: bool, - print_build_logs: bool, - interactive: bool, -} - -impl NixCommand { - pub fn new>(subcommand: S) -> Self { - Self { - subcommand: subcommand.into(), - args: Vec::new(), - env: Vec::new(), - impure: false, - print_build_logs: true, - interactive: false, - } - } - - pub fn arg>(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, V: Into>( - 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( - &self, - mut interceptor: I, - ) -> Result { - 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 { - 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 { - 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 = 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 = 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 = 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 = 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 = 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 { - 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 = 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")); - } -} diff --git a/eh/src/commands/update.rs b/eh/src/commands/update.rs index bd2634c..44605bf 100644 --- a/eh/src/commands/update.rs +++ b/eh/src/commands/update.rs @@ -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> { /// Fetch flake input names by running `nix flake metadata --json`. fn fetch_flake_inputs() -> Result> { - 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> { /// Otherwise, fetch inputs interactively and prompt for selection. pub fn handle_update( args: &[String], - cfg: &crate::config::CommandConfig, + cfg: &eh_config::CommandConfig, ) -> Result { 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)) } diff --git a/eh/src/config.rs b/eh/src/config.rs deleted file mode 100644 index d30fb2e..0000000 --- a/eh/src/config.rs +++ /dev/null @@ -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, - #[serde(default)] - pub commands: HashMap, -} - -/// 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, - /// Additional environment variables to set for the Nix command. - #[serde(default)] - pub env: HashMap, -} - -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 { - 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 { - let home = env::var("HOME").ok()?; - Some( - PathBuf::from(home) - .join(".config") - .join("eh") - .join("config.toml"), - ) -} - -fn load_from_file(path: &Path) -> Option { - let content = fs::read_to_string(path).ok()?; - match toml::de::from_str::(&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::("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::( - r#" - [commands.build] - typo_key = true - "#, - ); - assert!( - result.is_err(), - "unknown per-command keys should be rejected" - ); - } -} diff --git a/eh/src/error.rs b/eh/src/error.rs index 478c49f..7ae86e0 100644 --- a/eh/src/error.rs +++ b/eh/src/error.rs @@ -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()); } diff --git a/eh/src/eval.rs b/eh/src/eval.rs new file mode 100644 index 0000000..a55514f --- /dev/null +++ b/eh/src/eval.rs @@ -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 { + 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) + )); + } +} diff --git a/eh/src/hash.rs b/eh/src/hash.rs new file mode 100644 index 0000000..fb841ee --- /dev/null +++ b/eh/src/hash.rs @@ -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 = 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; + fn extract_old_hash(&self, stderr: &str) -> Option; +} + +pub struct RegexHashExtractor; + +impl HashExtractor for RegexHashExtractor { + fn extract_hash(&self, stderr: &str) -> Option { + 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 { + 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; + fn find_nix_files(&self) -> Result>; + fn fix_hash_in_file( + &self, + file_path: &Path, + old_hash: Option<&str>, + new_hash: &str, + ) -> Result; +} + +pub struct DefaultNixFileFixer; + +impl NixFileFixer for DefaultNixFileFixer { + fn fix_hash_in_files( + &self, + old_hash: Option<&str>, + new_hash: &str, + ) -> Result { + 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> { + 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::>(); + + 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 { + 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 { + 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, +) -> 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); + } +} diff --git a/eh/src/lib.rs b/eh/src/lib.rs index 532625a..c048eaf 100644 --- a/eh/src/lib.rs +++ b/eh/src/lib.rs @@ -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, } diff --git a/eh/src/main.rs b/eh/src/main.rs index 38cf7c7..b1d9881 100644 --- a/eh/src/main.rs +++ b/eh/src/main.rs @@ -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 { - 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 { fn dispatch_multicall( app_name: &str, - args: std::env::Args, + args: impl IntoIterator, ) -> Option> { - let rest: Vec = 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 { } 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), diff --git a/eh/src/nix_config.rs b/eh/src/nix_config.rs new file mode 100644 index 0000000..afef962 --- /dev/null +++ b/eh/src/nix_config.rs @@ -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()))) + } +} diff --git a/eh/src/retry.rs b/eh/src/retry.rs new file mode 100644 index 0000000..d0dd9a8 --- /dev/null +++ b/eh/src/retry.rs @@ -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::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 { + 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 { + 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 { + 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::(&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 { + 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 { + let pkg = package_arg(args).unwrap_or(""); + 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, + new_hash: &str, +) -> Result> { + 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); + } +} diff --git a/eh/src/suggestions.rs b/eh/src/suggestions.rs new file mode 100644 index 0000000..0d31860 --- /dev/null +++ b/eh/src/suggestions.rs @@ -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 = + LazyLock::new(|| Regex::new(r#"Did you mean (?:one of )?(.+?)\?"#).unwrap()); + +fn parse_nix_suggestions(did_you_mean_line: &str) -> Vec { + 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::>() + .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"] + ); + } +} diff --git a/eh/src/util.rs b/eh/src/util.rs deleted file mode 100644 index 9076834..0000000 --- a/eh/src/util.rs +++ /dev/null @@ -1,1176 +0,0 @@ -use std::{ - io::{BufWriter, IsTerminal, Write}, - path::{Path, PathBuf}, - sync::LazyLock, -}; - -use eh_log::{log_info, log_warn}; -use regex::Regex; -use tempfile::NamedTempFile; -use walkdir::WalkDir; -use yansi::Paint; - -use crate::{ - commands::{NixCommand, StdIoInterceptor}, - error::{EhError, Result}, -}; - -/// Maximum directory depth when searching for .nix files. -const MAX_DIR_DEPTH: usize = 3; - -/// Compiled regex patterns for extracting the actual hash from nix stderr. -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(), - ] -}); - -/// Compiled regex pattern for extracting the old (specified) hash from nix -/// stderr. -static HASH_OLD_EXTRACT_PATTERN: LazyLock = LazyLock::new(|| { - Regex::new(r"specified:\s+(sha256-[a-zA-Z0-9+/=]+)").unwrap() -}); - -/// Compiled regex patterns for matching hash attributes in .nix files. -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(), - ] -}); - -/// Regex to extract suggestions from Nix's "Did you mean" error line. -/// Matches patterns like: -/// - "Did you mean one of hello, world, or foo?" -/// - "Did you mean lib.hello?" -static DID_YOU_MEAN_PATTERN: LazyLock = - LazyLock::new(|| Regex::new(r#"Did you mean (?:one of )?(.+?)\?"#).unwrap()); - -/// Trait for extracting store paths and hashes from nix output. -pub trait HashExtractor { - /// Extract the new store path/hash from nix output. - fn extract_hash(&self, stderr: &str) -> Option; - /// Extract the old store path/hash from nix output (for hash updates). - fn extract_old_hash(&self, stderr: &str) -> Option; -} - -/// Default implementation of [`HashExtractor`] using regex patterns. -pub struct RegexHashExtractor; - -impl HashExtractor for RegexHashExtractor { - fn extract_hash(&self, stderr: &str) -> Option { - for re in HASH_EXTRACT_PATTERNS.iter() { - if let Some(captures) = re.captures(stderr) - && let Some(hash) = captures.get(1) - { - return Some(hash.as_str().to_string()); - } - } - None - } - - fn extract_old_hash(&self, stderr: &str) -> Option { - HASH_OLD_EXTRACT_PATTERN - .captures(stderr) - .and_then(|c| c.get(1)) - .map(|m| m.as_str().to_string()) - } -} - -/// Trait for fixing hash mismatches in nix files. -pub trait NixFileFixer { - /// Attempt to fix hash in all nix files found in the current directory. - /// Returns `true` if at least one file was fixed. - fn fix_hash_in_files( - &self, - old_hash: Option<&str>, - new_hash: &str, - ) -> Result; - /// Find all .nix files in the current directory (respects MAX_DIR_DEPTH). - fn find_nix_files(&self) -> Result>; - /// Attempt to fix hash in a single file. - /// Returns `true` if the file was modified. - fn fix_hash_in_file( - &self, - file_path: &Path, - old_hash: Option<&str>, - new_hash: &str, - ) -> Result; -} - -/// Default implementation of [`NixFileFixer`] that walks the directory tree. -pub struct DefaultNixFileFixer; - -impl NixFileFixer for DefaultNixFileFixer { - fn fix_hash_in_files( - &self, - old_hash: Option<&str>, - new_hash: &str, - ) -> Result { - let nix_files = self.find_nix_files()?; - let mut fixed = false; - for file_path in 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> { - let should_skip = |entry: &walkdir::DirEntry| -> bool { - // Never skip the root entry, otherwise the entire walk is empty - 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") - }; - - let files: Vec = WalkDir::new(".") - .max_depth(MAX_DIR_DEPTH) - .into_iter() - .filter_entry(|e| !should_skip(e)) - .filter_map(std::result::Result::ok) - .filter(|entry| { - entry.file_type().is_file() - && entry - .path() - .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("nix")) - }) - .map(|entry| entry.path().to_path_buf()) - .collect(); - - 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 { - // Read the entire file content - let content = std::fs::read_to_string(file_path)?; - let mut replaced = false; - let mut result_content = content; - - if let Some(old) = old_hash { - // Only replace attributes whose value matches the - // old hash. Uses regexes to handle variable whitespace around `=`. - let old_escaped = regex::escape(old); - let targeted_patterns = [ - ( - Regex::new(&format!(r#"hash\s*=\s*"{old_escaped}""#)).unwrap(), - format!(r#"hash = "{new_hash}""#), - ), - ( - Regex::new(&format!(r#"sha256\s*=\s*"{old_escaped}""#)).unwrap(), - format!(r#"sha256 = "{new_hash}""#), - ), - ( - Regex::new(&format!(r#"outputHash\s*=\s*"{old_escaped}""#)).unwrap(), - format!(r#"outputHash = "{new_hash}""#), - ), - ]; - - for (re, replacement) in &targeted_patterns { - if re.is_match(&result_content) { - result_content = re - .replace_all(&result_content, replacement.as_str()) - .into_owned(); - replaced = true; - } - } - } else { - // Fallback: replace all hash attributes - let replacements = [ - format!(r#"hash = "{new_hash}""#), - format!(r#"sha256 = "{new_hash}""#), - format!(r#"outputHash = "{new_hash}""#), - ]; - - for (re, replacement) in HASH_FIX_PATTERNS.iter().zip(&replacements) { - if re.is_match(&result_content) { - result_content = re - .replace_all(&result_content, replacement.as_str()) - .into_owned(); - replaced = true; - } - } - } - - // Write back to file atomically - if replaced { - 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(|_e| { - EhError::HashFixFailed { - path: file_path.to_string_lossy().to_string(), - } - })?; - } - - Ok(replaced) - } -} - -/// Trait for classifying nix errors and determining if a retry with modified -/// environment is appropriate. -pub trait NixErrorClassifier { - /// Determine if the given stderr output should trigger a retry with modified - /// environment variables (e.g., NIXPKGS_ALLOW_UNFREE). - fn should_retry(&self, stderr: &str) -> bool; -} - -/// Classifies what retry action should be taken based on nix stderr output. -#[derive(Debug, PartialEq, Eq)] -pub enum RetryAction { - /// Package has an unfree license, retry with NIXPKGS_ALLOW_UNFREE=1 - AllowUnfree, - /// Package is marked insecure, retry with - /// NIXPKGS_ALLOW_INSECURE_DERIVATIONS=1 - AllowInsecure, - /// Package is marked broken, retry with NIXPKGS_ALLOW_BROKEN=1 - AllowBroken, - /// No retry needed - None, -} - -impl RetryAction { - /// # Returns - /// - /// `(env_var, reason)` for this retry action, or `None` if no retry is - /// needed. - 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, - } - } -} - -/// Extract the package/expression name from args (first non-flag argument). -fn package_name(args: &[String]) -> &str { - args - .iter() - .find(|a| !a.starts_with('-')) - .map_or("", String::as_str) -} - -/// Print a retry message with consistent formatting. -/// Format: ` -> : , setting =1` -fn print_retry_msg(pkg: &str, reason: &str, env_var: &str) { - log_warn!( - "{}: {}, setting {}", - pkg.bold(), - reason, - format!("{env_var}=1").bold() - ); -} - -/// Classify stderr into a retry action. -#[must_use] -pub fn classify_retry_action(stderr: &str) -> RetryAction { - if stderr.contains("has an unfree license") && stderr.contains("refusing") { - RetryAction::AllowUnfree - } else if stderr.contains("has been marked as insecure") - && stderr.contains("refusing") - { - RetryAction::AllowInsecure - } else if stderr.contains("has been marked as broken") - && stderr.contains("refusing") - { - RetryAction::AllowBroken - } else { - RetryAction::None - } -} - -/// Returns true if stderr looks like a genuine hash mismatch error -/// (not just any mention of "hash" or "sha256"). -fn is_hash_mismatch_error(stderr: &str) -> bool { - stderr.contains("hash mismatch") - || (stderr.contains("specified:") && stderr.contains("got:")) -} - -/// Construct the eval expression for a given argument. -/// Handles both plain package names and flake references. -pub fn make_eval_expr(eval_arg: &str) -> String { - // Handle . (current directory) as .# (default package of current flake) - // Nix treats `nix build .` and `nix build .#` as equivalent - let eval_arg = if eval_arg == "." { ".#" } else { eval_arg }; - - if eval_arg.contains('#') { - // Handle .# (current flake default package) case - // .# needs to become .#default for meta evaluation to work - // because .#.meta evaluates 'meta' on the flake itself, not the package - if eval_arg.ends_with('#') { - format!("{eval_arg}default.meta") - } else { - format!("{eval_arg}.meta") - } - } else { - format!("nixpkgs#{eval_arg}.meta") - } -} - -/// Check if a package has an unfree, insecure, or broken attribute set. -/// Returns the appropriate `RetryAction` if any of these are true. Makes a -/// single nix eval call to minimize overhead. -fn check_package_flags(args: &[String]) -> Result { - // Default to "." if no argument provided (like `nix build` without args) - let eval_arg = args - .iter() - .find(|arg| !arg.starts_with('-')) - .cloned() - .unwrap_or_else(|| ".".to_string()); - - let eval_expr = make_eval_expr(&eval_arg); - let eval_cmd = NixCommand::new("eval") - .arg("--json") - .arg(&eval_expr) - .print_build_logs(false); - - let output = match eval_cmd.output() { - Ok(o) if o.status.success() => o, - Ok(o) => { - let stderr = String::from_utf8_lossy(&o.stderr); - if stderr.contains("does not provide attribute") { - return Ok(RetryAction::None); - } - log_warn!( - "failed to check package flags for '{}': {}", - eval_arg, - stderr.trim() - ); - return Ok(RetryAction::None); - }, - - Err(e) => { - log_warn!("failed to check package flags for '{}': {}", eval_arg, e); - return Ok(RetryAction::None); - }, - }; - - let meta: serde_json::Value = match serde_json::from_slice(&output.stdout) { - Ok(v) => v, - Err(e) => { - log_warn!("failed to parse package metadata for '{}': {}", eval_arg, e); - return Ok(RetryAction::None); - }, - }; - - let flags = [ - ("unfree", RetryAction::AllowUnfree), - ("insecure", RetryAction::AllowInsecure), - ("broken", RetryAction::AllowBroken), - ]; - - for (key, action) in flags { - if meta - .get(key) - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) - { - return Ok(action); - } - } - - Ok(RetryAction::None) -} - -/// Pre-evaluate expression to catch errors early. -/// -/// Returns a `RetryAction` if the package has retryable flags -/// (unfree/insecure/broken), allowing the caller to retry with the right -/// environment variables. -fn pre_evaluate(args: &[String]) -> Result { - // First, check package meta flags directly to avoid error message parsing - let action = check_package_flags(args)?; - if action != RetryAction::None { - return Ok(action); - } - - // Find flake references or expressions to evaluate - // Only take the first non-flag argument (the package/expression) - // Default to "." if no argument provided (like `nix build` without args) - let eval_arg = args - .iter() - .find(|arg| !arg.starts_with('-')) - .cloned() - .unwrap_or_else(|| { - log_warn!("no package specified, defaulting to '.' (current directory)"); - ".".to_string() - }); - - let eval_arg_ref = &eval_arg; - let eval_cmd = NixCommand::new("eval") - .arg(eval_arg_ref) - .print_build_logs(false); - - let output = eval_cmd.output()?; - - if output.status.success() { - return Ok(RetryAction::None); - } - - let stderr = String::from_utf8_lossy(&output.stderr); - - // Classify whether this is a retryable error (unfree/insecure/broken) - // Fallback for errors that slip through (e.g., from dependencies) - let action = classify_retry_action(&stderr); - if action != RetryAction::None { - return Ok(action); - } - - // Non-retryable eval failure, we should fail fast with a clear message - // rather than running the full command and showing the same error again. - let stderr_clean = stderr - .trim() - .strip_prefix("error:") - .unwrap_or(stderr.trim()) - .trim(); - Err(EhError::PreEvalFailed { - expression: eval_arg.clone(), - stderr: stderr_clean.to_string(), - }) -} - -pub fn validate_nix_args(args: &[String]) -> Result<()> { - const DANGEROUS_PATTERNS: &[&str] = &[ - ";", "&&", "||", "|", "`", "$(", "${", ">", "<", ">>", "<<", "2>", "2>>", - ]; - - for arg in args { - for pattern in DANGEROUS_PATTERNS { - if arg.contains(pattern) { - return Err(EhError::InvalidInput { - input: arg.clone(), - reason: format!("contains potentially dangerous pattern: {pattern}"), - }); - } - } - } - Ok(()) -} - -/// Shared retry logic for nix commands (build/run/shell). -pub fn handle_nix_with_retry( - subcommand: &str, - args: &[String], - hash_extractor: &dyn HashExtractor, - fixer: &dyn NixFileFixer, - classifier: &dyn NixErrorClassifier, - interactive: bool, - cfg: &crate::config::CommandConfig, -) -> Result { - validate_nix_args(args)?; - - // Pre-evaluate to detect retryable errors (unfree/insecure/broken) before - // running the actual command. This avoids streaming verbose nix error output - // only to retry immediately after. - let pkg = package_name(args); - let pre_eval_action = pre_evaluate(args)?; - if let Some((env_var, reason)) = pre_eval_action.env_override() { - if cfg.impure == Some(false) { - return Err(EhError::ImpureRequired { - command: subcommand.to_string(), - reason: reason.to_string(), - }); - } - print_retry_msg(pkg, reason, env_var); - let mut retry_cmd = NixCommand::new(subcommand) - .print_build_logs(true) - .args_ref(args) - .with_config(cfg) - .env(env_var, "1") - .impure(true); - if interactive { - retry_cmd = retry_cmd.interactive(true); - } - let retry_status = retry_cmd.run_with_logs(StdIoInterceptor)?; - return Ok(retry_status.code().unwrap_or(1)); - } - - // For run/shell commands, try interactive mode now that pre-eval passed - if interactive { - let status = NixCommand::new(subcommand) - .print_build_logs(true) - .interactive(true) - .args_ref(args) - .with_config(cfg) - .run_with_logs(StdIoInterceptor)?; - if status.success() { - return Ok(0); - } - } - - // Capture output to check for errors that need retry (hash mismatches etc.) - let output_cmd = NixCommand::new(subcommand) - .print_build_logs(true) - .args_ref(args) - .with_config(cfg); - let output = output_cmd.output()?; - let stderr = String::from_utf8_lossy(&output.stderr); - - // Check for hash mismatch errors - if let Some(new_hash) = hash_extractor.extract_hash(&stderr) { - let old_hash = hash_extractor.extract_old_hash(&stderr); - - // Ask for confirmation before fixing hash (skip in non-interactive mode) - let should_fix = if std::io::stdin().is_terminal() { - dialoguer::Confirm::new() - .with_prompt(format!( - "Hash mismatch detected for {}. Update hash in local .nix files?", - pkg.bold() - )) - .default(true) - .interact() - .map_err(|e| EhError::Io(std::io::Error::other(e)))? - } else { - log_warn!( - "{}: hash mismatch detected in non-interactive mode, skipping auto-fix", - pkg.bold() - ); - false - }; - - if !should_fix { - log_warn!("{}: hash fix cancelled", pkg.bold()); - return Err(EhError::ProcessExit { code: 1 }); - } - - match fixer.fix_hash_in_files(old_hash.as_deref(), &new_hash) { - Ok(true) => { - log_info!( - "{}: hash mismatch corrected in local files, rebuilding", - pkg.bold() - ); - let mut retry_cmd = NixCommand::new(subcommand) - .print_build_logs(true) - .args_ref(args) - .with_config(cfg); - if interactive { - retry_cmd = retry_cmd.interactive(true); - } - let retry_status = retry_cmd.run_with_logs(StdIoInterceptor)?; - return Ok(retry_status.code().unwrap_or(1)); - }, - Ok(false) => { - // No files were fixed, continue with normal error handling - }, - Err(EhError::NoNixFilesFound) => { - log_warn!( - "{}: hash mismatch detected but no .nix files found to update", - pkg.bold() - ); - // Continue with normal error handling - }, - Err(e) => { - return Err(e); - }, - } - } else if is_hash_mismatch_error(&stderr) { - // There's a genuine hash mismatch but we couldn't extract the new hash - return Err(EhError::HashExtractionFailed { - stderr: stderr.to_string(), - }); - } - - // Fallback: check for unfree/insecure/broken in captured output - // (in case pre_evaluate didn't catch it, e.g. from a dependency) - if classifier.should_retry(&stderr) { - let action = classify_retry_action(&stderr); - if let Some((env_var, reason)) = action.env_override() { - if cfg.impure == Some(false) { - return Err(EhError::ImpureRequired { - command: subcommand.to_string(), - reason: reason.to_string(), - }); - } - print_retry_msg(pkg, reason, env_var); - let mut retry_cmd = NixCommand::new(subcommand) - .print_build_logs(true) - .args_ref(args) - .with_config(cfg) - .env(env_var, "1") - .impure(true); - if interactive { - retry_cmd = retry_cmd.interactive(true); - } - let retry_status = retry_cmd.run_with_logs(StdIoInterceptor)?; - return Ok(retry_status.code().unwrap_or(1)); - } - } - - // If the first attempt succeeded, we're done - if output.status.success() { - return Ok(0); - } - - // Otherwise, show the error and return error - std::io::stderr() - .write_all(&output.stderr) - .map_err(EhError::Io)?; - - // Print contextual suggestions for common errors - print_error_suggestions(&output.stderr); - - match output.status.code() { - Some(code) => Err(EhError::ProcessExit { code }), - // No exit code means the process was killed by a signal - None => { - Err(EhError::NixCommandFailed { - command: subcommand.to_string(), - }) - }, - } -} - -pub struct DefaultNixErrorClassifier; - -impl NixErrorClassifier for DefaultNixErrorClassifier { - fn should_retry(&self, stderr: &str) -> bool { - classify_retry_action(stderr) != RetryAction::None - } -} - -/// Parse suggestions from Nix's "Did you mean" error line. -/// Input: "Did you mean one of neovim, hevi, navi, neo or neo4j?" -/// Output: vec!["neovim", "hevi", "navi", "neo", "neo4j"] -fn parse_nix_suggestions(did_you_mean_line: &str) -> Vec { - 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(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect() - }) - .unwrap_or_default() -} - -/// Print contextual error suggestions when a command fails. -/// Parses Nix's own "Did you mean" suggestions from stderr and presents them -/// nicely to the user. -pub fn print_error_suggestions(stderr: &[u8]) { - let stderr_str = String::from_utf8_lossy(stderr); - - // Look for Nix's "Did you mean" line in the error output - if let Some(line) = stderr_str.lines().find(|l| l.contains("Did you mean")) { - let suggestions = parse_nix_suggestions(line); - - if !suggestions.is_empty() { - let formatted = suggestions - .iter() - .map(|s| s.bold().to_string()) - .collect::>() - .join(", "); - log_info!("Did you mean: {}?", formatted); - } - } -} - -#[cfg(test)] -mod tests { - use std::io::Write; - - use tempfile::NamedTempFile; - - use super::*; - - #[test] - fn test_streaming_hash_replacement() { - let temp_file = NamedTempFile::new().unwrap(); - let file_path = temp_file.path(); - - // Write test content with multiple hash patterns - let test_content = r#"stdenv.mkDerivation { - name = "test-package"; - src = fetchurl { - url = "https://example.com.tar.gz"; - hash = "sha256-oldhash123"; - sha256 = "sha256-oldhash456"; - outputHash = "sha256-oldhash789"; - }; -}"#; - - let mut file = std::fs::File::create(file_path).unwrap(); - file.write_all(test_content.as_bytes()).unwrap(); - file.flush().unwrap(); - - let fixer = DefaultNixFileFixer; - let result = fixer - .fix_hash_in_file(file_path, None, "sha256-newhash999") - .unwrap(); - - assert!(result, "Hash replacement should return true"); - - // Verify the content was updated - let updated_content = std::fs::read_to_string(file_path).unwrap(); - assert!(updated_content.contains("sha256-newhash999")); - assert!(!updated_content.contains("sha256-oldhash123")); - assert!(!updated_content.contains("sha256-oldhash456")); - assert!(!updated_content.contains("sha256-oldhash789")); - } - - #[test] - fn test_streaming_no_replacement_needed() { - let temp_file = NamedTempFile::new().unwrap(); - let file_path = temp_file.path().to_path_buf(); - - let test_content = r#"stdenv.mkDerivation { - name = "test-package"; - src = fetchurl { - url = "https://example.com.tar.gz"; - }; -}"#; - - { - let mut file = std::fs::File::create(&file_path).unwrap(); - file.write_all(test_content.as_bytes()).unwrap(); - file.flush().unwrap(); - } // File is closed here - - // Test hash replacement - let fixer = DefaultNixFileFixer; - let result = fixer - .fix_hash_in_file(&file_path, None, "sha256-newhash999") - .unwrap(); - - assert!( - !result, - "Hash replacement should return false when no patterns found" - ); - - // Verify the content was unchanged, ignoring trailing newline differences - let updated_content = std::fs::read_to_string(&file_path).unwrap(); - let normalized_original = test_content.trim_end(); - let normalized_updated = updated_content.trim_end(); - assert_eq!(normalized_updated, normalized_original); - } - - // FIXME: this is a little stupid, but it works - #[test] - fn test_streaming_large_file_handling() { - let temp_file = NamedTempFile::new().unwrap(); - let file_path = temp_file.path(); - let mut file = std::fs::File::create(file_path).unwrap(); - - // Write header with hash - file.write_all(b"stdenv.mkDerivation {\n name = \"large-package\";\n src = fetchurl {\n url = \"https://example.com/large.tar.gz\";\n hash = \"sha256-oldhash\";\n };\n").unwrap(); - - for i in 0..10000 { - writeln!(file, " # Large comment line {} to simulate file size", i) - .unwrap(); - } - - file.flush().unwrap(); - - // Test that streaming can handle large files without memory issues - let fixer = DefaultNixFileFixer; - let result = fixer - .fix_hash_in_file(file_path, None, "sha256-newhash999") - .unwrap(); - - assert!(result, "Hash replacement should work for large files"); - - // Verify the hash was replaced - let updated_content = std::fs::read_to_string(file_path).unwrap(); - assert!(updated_content.contains("sha256-newhash999")); - assert!(!updated_content.contains("sha256-oldhash")); - } - - #[test] - fn test_streaming_file_permissions_preserved() { - let temp_file = NamedTempFile::new().unwrap(); - let file_path = temp_file.path(); - - // Write test content - let test_content = r#"stdenv.mkDerivation { - name = "test"; - src = fetchurl { - url = "https://example.com"; - hash = "sha256-oldhash"; - }; -}"#; - - let mut file = std::fs::File::create(file_path).unwrap(); - file.write_all(test_content.as_bytes()).unwrap(); - file.flush().unwrap(); - - // Get original permissions - let original_metadata = std::fs::metadata(file_path).unwrap(); - let _original_permissions = original_metadata.permissions(); - - // Test hash replacement - let fixer = DefaultNixFileFixer; - let result = fixer - .fix_hash_in_file(file_path, None, "sha256-newhash") - .unwrap(); - - assert!(result, "Hash replacement should succeed"); - - // Verify file still exists and has reasonable permissions - let new_metadata = std::fs::metadata(file_path).unwrap(); - assert!( - new_metadata.is_file(), - "File should still exist after replacement" - ); - } - - #[test] - fn test_input_validation_blocks_dangerous_patterns() { - let dangerous_args = vec![ - "package; rm -rf /".to_string(), - "package && echo hacked".to_string(), - "package || echo hacked".to_string(), - "package | cat /etc/passwd".to_string(), - "package `whoami`".to_string(), - "package $(echo hacked lol!)".to_string(), - "package ${HOME}/file".to_string(), - ]; - - for arg in dangerous_args { - let result = validate_nix_args(std::slice::from_ref(&arg)); - assert!(result.is_err(), "Should reject dangerous argument: {}", arg); - - match result.unwrap_err() { - EhError::InvalidInput { input, reason } => { - assert_eq!(input, arg); - assert!(reason.contains("dangerous pattern")); - }, - _ => panic!("Expected InvalidInput error"), - } - } - } - - #[test] - fn test_input_validation_allows_safe_args() { - let safe_args = vec![ - "nixpkgs#hello".to_string(), - "--impure".to_string(), - "--print-build-logs".to_string(), - "/path/to/flake".to_string(), - ".#default".to_string(), - ]; - - let result = validate_nix_args(&safe_args); - assert!( - result.is_ok(), - "Should allow safe arguments: {:?}", - safe_args - ); - } - - #[test] - fn test_input_validation_empty_args() { - let result = validate_nix_args(&[]); - assert!(result.is_ok(), "Empty args should be accepted"); - } - - #[test] - fn test_hash_extraction_got_pattern() { - let stderr = "hash mismatch in fixed-output derivation\n specified: \ - sha256-AAAA\n got: \ - sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="; - let extractor = RegexHashExtractor; - let hash = extractor.extract_hash(stderr); - assert!(hash.is_some()); - assert!(hash.unwrap().starts_with("sha256-")); - } - - #[test] - fn test_hash_extraction_actual_pattern() { - let stderr = "hash mismatch\n actual: \ - sha256-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC="; - let extractor = RegexHashExtractor; - let hash = extractor.extract_hash(stderr); - assert!(hash.is_some()); - assert!(hash.unwrap().starts_with("sha256-")); - } - - #[test] - fn test_hash_extraction_have_pattern() { - let stderr = "hash mismatch\n have: \ - sha256-DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD="; - let extractor = RegexHashExtractor; - let hash = extractor.extract_hash(stderr); - assert!(hash.is_some()); - assert!(hash.unwrap().starts_with("sha256-")); - } - - #[test] - fn test_hash_extraction_no_match() { - let stderr = "error: some other nix error without hashes"; - let extractor = RegexHashExtractor; - assert!(extractor.extract_hash(stderr).is_none()); - } - - #[test] - fn test_hash_extraction_partial_match() { - // Contains "got:" but no sha256 hash - let stderr = "got: some-other-value"; - let extractor = RegexHashExtractor; - assert!(extractor.extract_hash(stderr).is_none()); - } - - #[test] - fn test_false_positive_hash_detection() { - // Normal nix output mentioning "hash" or "sha256" without being a mismatch - let cases = [ - "evaluating attribute 'sha256' of derivation 'hello'", - "building '/nix/store/hash-something.drv'", - "copying path '/nix/store/sha256-abcdef-hello'", - "this derivation has a hash attribute set", - ]; - for stderr in &cases { - assert!( - !is_hash_mismatch_error(stderr), - "Should not detect hash mismatch in: {stderr}" - ); - } - } - - #[test] - fn test_genuine_hash_mismatch_detection() { - assert!(is_hash_mismatch_error( - "hash mismatch in fixed-output derivation" - )); - assert!(is_hash_mismatch_error( - "specified: sha256-AAAA\n got: sha256-BBBB" - )); - } - - #[test] - fn test_classify_retry_action_unfree() { - let stderr = - "error: Package 'foo' has an unfree license, refusing to evaluate."; - assert_eq!(classify_retry_action(stderr), RetryAction::AllowUnfree); - } - - #[test] - fn test_classify_retry_action_insecure() { - let stderr = - "error: Package 'bar' has been marked as insecure, refusing to evaluate."; - assert_eq!(classify_retry_action(stderr), RetryAction::AllowInsecure); - } - - #[test] - fn test_classify_retry_action_broken() { - let stderr = - "error: Package 'baz' has been marked as broken, refusing to evaluate."; - assert_eq!(classify_retry_action(stderr), RetryAction::AllowBroken); - } - - #[test] - fn test_classify_retry_action_none() { - let stderr = "error: attribute 'nonexistent' not found"; - assert_eq!(classify_retry_action(stderr), RetryAction::None); - } - - #[test] - fn test_retry_action_env_overrides() { - let (var, reason) = RetryAction::AllowUnfree.env_override().unwrap(); - assert_eq!(var, "NIXPKGS_ALLOW_UNFREE"); - assert!(reason.contains("unfree")); - - let (var, reason) = RetryAction::AllowInsecure.env_override().unwrap(); - assert_eq!(var, "NIXPKGS_ALLOW_INSECURE"); - assert!(reason.contains("insecure")); - - let (var, reason) = RetryAction::AllowBroken.env_override().unwrap(); - assert_eq!(var, "NIXPKGS_ALLOW_BROKEN"); - assert!(reason.contains("broken")); - - assert_eq!(RetryAction::None.env_override(), None); - } - - #[test] - fn test_classifier_should_retry() { - let classifier = DefaultNixErrorClassifier; - assert!( - classifier.should_retry( - "Package 'x' has an unfree license, refusing to evaluate" - ) - ); - assert!(classifier.should_retry( - "Package 'x' has been marked as insecure, refusing to evaluate" - )); - assert!(classifier.should_retry( - "Package 'x' has been marked as broken, refusing to evaluate" - )); - assert!(!classifier.should_retry("error: attribute not found")); - } - - #[test] - fn test_old_hash_extraction() { - let stderr = - "hash mismatch in fixed-output derivation\n specified: \ - sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n got: \ - sha256-BBBB="; - let extractor = RegexHashExtractor; - let old = extractor.extract_old_hash(stderr); - assert!(old.is_some()); - assert_eq!( - old.unwrap(), - "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" - ); - } - - #[test] - fn test_old_hash_extraction_missing() { - let stderr = "hash mismatch\n got: sha256-BBBB="; - let extractor = RegexHashExtractor; - assert!(extractor.extract_old_hash(stderr).is_none()); - } - - #[test] - fn test_targeted_hash_replacement_only_matching() { - let temp_file = NamedTempFile::new().unwrap(); - let file_path = temp_file.path(); - - // File with two derivations, each with a different hash - let test_content = r#"{ pkgs }: -{ - a = pkgs.fetchurl { - url = "https://example.com/a.tar.gz"; - hash = "sha256-AAAA"; - }; - b = pkgs.fetchurl { - url = "https://example.com/b.tar.gz"; - hash = "sha256-BBBB"; - }; -}"#; - - let mut file = std::fs::File::create(file_path).unwrap(); - file.write_all(test_content.as_bytes()).unwrap(); - file.flush().unwrap(); - - let fixer = DefaultNixFileFixer; - // Only replace the hash matching "sha256-AAAA" - let result = fixer - .fix_hash_in_file(file_path, Some("sha256-AAAA"), "sha256-NEWW") - .unwrap(); - - assert!(result, "Targeted replacement should return true"); - - let updated = std::fs::read_to_string(file_path).unwrap(); - assert!( - updated.contains(r#"hash = "sha256-NEWW""#), - "Matching hash should be replaced" - ); - assert!( - updated.contains(r#"hash = "sha256-BBBB""#), - "Non-matching hash should be untouched" - ); - } - - #[test] - fn test_targeted_hash_replacement_no_match() { - let temp_file = NamedTempFile::new().unwrap(); - let file_path = temp_file.path(); - - let test_content = r#"{ hash = "sha256-XXXX"; }"#; - - let mut file = std::fs::File::create(file_path).unwrap(); - file.write_all(test_content.as_bytes()).unwrap(); - file.flush().unwrap(); - - let fixer = DefaultNixFileFixer; - // old_hash doesn't match anything in the file - let result = fixer - .fix_hash_in_file(file_path, Some("sha256-NOMATCH"), "sha256-NEWW") - .unwrap(); - - assert!(!result, "Should return false when old hash doesn't match"); - - let updated = std::fs::read_to_string(file_path).unwrap(); - assert!( - updated.contains("sha256-XXXX"), - "Original hash should be untouched" - ); - } - - #[test] - fn test_eval_expr_plain_package_name() { - assert_eq!( - make_eval_expr("vscode"), - "nixpkgs#vscode.meta", - "Plain package names should be prefixed with nixpkgs#" - ); - } - - #[test] - fn test_eval_expr_nixpkgs_prefixed() { - assert_eq!( - make_eval_expr("nixpkgs#vscode"), - "nixpkgs#vscode.meta", - "nixpkgs# prefix should not be duplicated" - ); - } - - #[test] - fn test_eval_expr_custom_flake() { - assert_eq!( - make_eval_expr("myflake#vscode"), - "myflake#vscode.meta", - "Custom flake references should be preserved" - ); - } - - #[test] - fn test_eval_expr_github_flake() { - assert_eq!( - make_eval_expr("github:owner/repo#vscode"), - "github:owner/repo#vscode.meta", - "GitHub flake references should be preserved" - ); - } - - #[test] - fn test_eval_expr_path_flake() { - assert_eq!( - make_eval_expr("./myflake#vscode"), - "./myflake#vscode.meta", - "Path-based flake references should be preserved" - ); - } - - #[test] - fn test_eval_expr_special_nixpkg_forms() { - // Test various nixpkgs forms that might be used - assert_eq!( - make_eval_expr("nixpkgs#legacyPackages.x86_64-linux.vscode"), - "nixpkgs#legacyPackages.x86_64-linux.vscode.meta", - "Complex nixpkgs references should be preserved" - ); - } -} From 43fdbe5ae66dcc7aa93e47ee1183b6863fc453cf Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 12 May 2026 17:43:36 +0300 Subject: [PATCH 5/5] chore: format rustfmt config with taplo Signed-off-by: NotAShelf Change-Id: I3450f16aeb183d33e98d4ddd6287d74a6a6a6964 --- .rustfmt.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/.rustfmt.toml b/.rustfmt.toml index ac283d5..324bf8b 100644 --- a/.rustfmt.toml +++ b/.rustfmt.toml @@ -24,4 +24,3 @@ unstable_features = true use_field_init_shorthand = true use_try_shorthand = true wrap_comments = true -