diff --git a/.rustfmt.toml b/.rustfmt.toml index 324bf8b..ac283d5 100644 --- a/.rustfmt.toml +++ b/.rustfmt.toml @@ -24,3 +24,4 @@ unstable_features = true use_field_init_shorthand = true use_try_shorthand = true wrap_comments = true + diff --git a/Cargo.lock b/Cargo.lock index 9758723..dd5c19e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,56 +104,24 @@ 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" @@ -195,17 +163,6 @@ 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" @@ -276,15 +233,6 @@ 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" @@ -303,25 +251,12 @@ 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" @@ -356,17 +291,6 @@ 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" @@ -506,7 +430,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom", "once_cell", "rustix", "windows-sys", @@ -608,12 +532,6 @@ 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 cb7de60..931f336 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,15 +13,9 @@ 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" @@ -32,6 +26,9 @@ 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 deleted file mode 100644 index 28ec0e1..0000000 --- a/crates/eh-config/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[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 deleted file mode 100644 index 5b15dde..0000000 --- a/crates/eh-config/src/lib.rs +++ /dev/null @@ -1,134 +0,0 @@ -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/eh-log/Cargo.toml b/crates/eh-log/Cargo.toml index 7433995..e3ef1f4 100644 --- a/crates/eh-log/Cargo.toml +++ b/crates/eh-log/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "eh-log" -description = "Tiny, styled logging crate for eh" +description = "Styled logging 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 92d903d..365deff 100644 --- a/crates/eh-log/src/lib.rs +++ b/crates/eh-log/src/lib.rs @@ -1,64 +1,26 @@ -use std::{ - fmt, - sync::atomic::{AtomicI8, Ordering}, -}; +use std::fmt; 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) { - if enabled(Level::Info) { - eprintln!(" {} {args}", "->".green().bold()); - } -} - -pub fn debug(args: fmt::Arguments) { - if enabled(Level::Debug) { - eprintln!(" {} {args}", "*".blue().dim()); - } + eprintln!(" {} {args}", "->".green().bold()); } pub fn warn(args: fmt::Arguments) { - if enabled(Level::Warn) { - eprintln!(" {} {args}", "->".yellow().bold()); - } + eprintln!(" {} {args}", "->".yellow().bold()); } pub fn error(args: fmt::Arguments) { - if enabled(Level::Error) { - eprintln!(" {} {args}", "!".red().bold()); - } + eprintln!(" {} {args}", "!".red().bold()); } pub fn hint(args: fmt::Arguments) { - if enabled(Level::Info) { - eprintln!(" {} {args}", "~".yellow().dim()); - } + 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)*)) } } diff --git a/crates/nix-command/Cargo.toml b/crates/nix-command/Cargo.toml deleted file mode 100644 index b18b531..0000000 --- a/crates/nix-command/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[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 deleted file mode 100644 index c3ef054..0000000 --- a/crates/nix-command/src/lib.rs +++ /dev/null @@ -1,416 +0,0 @@ -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 0fba45b..f3f4e51 100644 --- a/eh/Cargo.toml +++ b/eh/Cargo.toml @@ -11,16 +11,15 @@ crate-type = [ "lib" ] name = "eh" [dependencies] -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 +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 diff --git a/eh/src/commands/info.rs b/eh/src/commands/info.rs index 64870a6..e0a34c3 100644 --- a/eh/src/commands/info.rs +++ b/eh/src/commands/info.rs @@ -1,19 +1,15 @@ 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}, - eval::make_eval_expr, - nix_config::ApplyCommandConfig, - suggestions::print_error_suggestions, + util::{make_eval_expr, print_error_suggestions}, }; -const UNKNOWN_LICENSE: &str = "Unknown"; - #[derive(Debug, Deserialize)] struct PackageMeta { name: String, @@ -37,24 +33,41 @@ struct PackageOutputs { pub fn handle_info( args: &[String], - cfg: &eh_config::CommandConfig, + cfg: &crate::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 = package_name_from_eval_expr(&eval_arg); + 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 + }; log_info!("Fetching info for {}", pkg_name.bold()); - let meta_cmd = NixCommand::new(CommandKind::Eval) + // Fetch metadata + let meta_cmd = NixCommand::new("eval") .arg("--json") .arg(&eval_arg) .print_build_logs(false) - .apply_config(cfg); + .with_config(cfg); let meta_output = meta_cmd.output()?; @@ -74,15 +87,16 @@ 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(CommandKind::Eval) + let outputs_cmd = NixCommand::new("eval") .arg("--json") .arg(format!("{}.outputs", outputs_expr)) .print_build_logs(false) - .apply_config(cfg); + .with_config(cfg); let outputs_output = outputs_cmd.output()?; let outputs: Option = if outputs_output.status.success() { @@ -91,49 +105,12 @@ 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>, @@ -141,6 +118,7 @@ fn print_package_info( ) { println!(); + // Header println!(" {} {}", "Package:".bold(), meta.name); if let Some(ref version) = meta.version { @@ -151,6 +129,7 @@ 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 @@ -159,6 +138,7 @@ 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); @@ -166,17 +146,58 @@ fn print_package_info( } } + // License if let Some(ref license) = meta.license { - println!(" {} {}", "License:".bold(), format_license(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); } + // 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()); @@ -194,6 +215,7 @@ 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 { @@ -208,6 +230,7 @@ fn print_package_info( println!(" {} {}", "Platforms:".bold(), platform_str); } + // Outputs section if let Some(outputs) = outputs { println!(); println!(" {}", "Outputs:".bold()); @@ -218,6 +241,7 @@ 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 2d590db..eeacf35 100644 --- a/eh/src/commands/mod.rs +++ b/eh/src/commands/mod.rs @@ -1,27 +1,486 @@ +use std::{ + io::{self, Read, Write}, + process::{Command, ExitStatus, Output, Stdio}, + sync::mpsc, + thread, + time::{Duration, Instant}, +}; + use crate::{ - error::Result, - hash::{HashExtractor, NixFileFixer}, - retry::{NixErrorClassifier, handle_nix_with_retry}, + error::{EhError, Result}, + util::{ + HashExtractor, + NixErrorClassifier, + NixFileFixer, + 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: &eh_config::CommandConfig, + cfg: &crate::config::CommandConfig, ) -> Result { + let intercept_env = matches!(command, "run" | "shell"); handle_nix_with_retry( command, args, hash_extractor, fixer, classifier, - matches!(command, "run" | "shell" | "develop"), + intercept_env, 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 44605bf..bd2634c 100644 --- a/eh/src/commands/update.rs +++ b/eh/src/commands/update.rs @@ -1,8 +1,6 @@ -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. @@ -28,7 +26,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(CommandKind::Flake) + let output = NixCommand::new("flake") .arg("metadata") .arg("--json") .print_build_logs(false) @@ -59,10 +57,9 @@ fn prompt_input_selection(inputs: &[String]) -> Result> { /// Otherwise, fetch inputs interactively and prompt for selection. pub fn handle_update( args: &[String], - cfg: &eh_config::CommandConfig, + cfg: &crate::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); @@ -72,16 +69,14 @@ pub fn handle_update( args.to_vec() }; - let mut cmd = NixCommand::new(CommandKind::Flake) - .arg("lock") - .apply_config(cfg); + let mut cmd = NixCommand::new("flake").arg("lock").with_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(StdIo)?; + let status = cmd.run_with_logs(StdIoInterceptor)?; Ok(status.code().unwrap_or(1)) } diff --git a/eh/src/config.rs b/eh/src/config.rs new file mode 100644 index 0000000..d30fb2e --- /dev/null +++ b/eh/src/config.rs @@ -0,0 +1,236 @@ +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 7ae86e0..478c49f 100644 --- a/eh/src/error.rs +++ b/eh/src/error.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use thiserror::Error; #[derive(Error, Debug)] @@ -8,9 +10,6 @@ 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), @@ -29,12 +28,24 @@ 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 }, @@ -44,9 +55,6 @@ pub enum EhError { #[error("no inputs selected")] UpdateCancelled, - #[error("empty nix expression")] - InvalidEvalInput, - #[error( "package {reason} but `--impure` is disabled for `{command}` in config" )] @@ -61,18 +69,20 @@ impl EhError { match self { Self::ProcessExit { code } => *code, Self::NixCommandFailed { .. } => 2, + Self::CommandFailed { .. } => 3, Self::HashExtractionFailed { .. } => 4, Self::NoNixFilesFound => 5, Self::HashFixFailed { .. } => 6, - Self::Io(_) | Self::NixCommand(_) => 8, + Self::InvalidInput { .. } => 7, + Self::Io(_) => 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, } } @@ -91,6 +101,15 @@ 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") }, @@ -103,15 +122,12 @@ 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, } } @@ -130,6 +146,13 @@ mod tests { .exit_code(), 2 ); + assert_eq!( + EhError::CommandFailed { + command: "x".into(), + } + .exit_code(), + 3 + ); assert_eq!( EhError::HashExtractionFailed { stderr: String::new(), @@ -139,6 +162,22 @@ 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(), @@ -155,6 +194,12 @@ 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(), @@ -186,6 +231,22 @@ 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 { @@ -197,6 +258,13 @@ 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 deleted file mode 100644 index a55514f..0000000 --- a/eh/src/eval.rs +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index fb841ee..0000000 --- a/eh/src/hash.rs +++ /dev/null @@ -1,275 +0,0 @@ -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 c048eaf..532625a 100644 --- a/eh/src/lib.rs +++ b/eh/src/lib.rs @@ -1,4 +1,7 @@ +pub mod commands; +pub mod config; pub mod error; +pub mod util; pub use clap::{CommandFactory, Parser, Subcommand}; pub use error::{EhError, Result}; @@ -8,14 +11,6 @@ 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 b1d9881..38cf7c7 100644 --- a/eh/src/main.rs +++ b/eh/src/main.rs @@ -4,12 +4,9 @@ use eh::{Cli, Command, CommandFactory, Parser}; use yansi::Paint; mod commands; +mod config; mod error; -mod eval; -mod hash; -mod nix_config; -mod retry; -mod suggestions; +mod util; fn main() { let result = run_app(); @@ -30,10 +27,10 @@ fn main() { } fn handle_command(command: &str, args: &[String]) -> error::Result { - let hash_extractor = hash::RegexHashExtractor; - let fixer = hash::DefaultNixFileFixer; - let classifier = retry::DefaultNixErrorClassifier; - let cfg = eh_config::load(); + let hash_extractor = util::RegexHashExtractor; + let fixer = util::DefaultNixFileFixer; + let classifier = util::DefaultNixErrorClassifier; + let cfg = config::load(); let cmd_cfg = cfg.for_command(command); match command { @@ -56,21 +53,9 @@ fn handle_command(command: &str, args: &[String]) -> error::Result { fn dispatch_multicall( app_name: &str, - args: impl IntoIterator, + args: std::env::Args, ) -> Option> { - 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 rest: Vec = args.collect(); let subcommand = match app_name { "nr" => "run", @@ -119,7 +104,6 @@ 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 deleted file mode 100644 index afef962..0000000 --- a/eh/src/nix_config.rs +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index d0dd9a8..0000000 --- a/eh/src/retry.rs +++ /dev/null @@ -1,365 +0,0 @@ -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 deleted file mode 100644 index 0d31860..0000000 --- a/eh/src/suggestions.rs +++ /dev/null @@ -1,58 +0,0 @@ -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 new file mode 100644 index 0000000..9076834 --- /dev/null +++ b/eh/src/util.rs @@ -0,0 +1,1176 @@ +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" + ); + } +} diff --git a/nix/shell.nix b/nix/shell.nix index e71bf12..4584b59 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -5,14 +5,13 @@ rustfmt, clippy, taplo, - rust-analyzer, - cargo-nextest, + rust-analyzer-unwrapped, + rustPlatform, }: mkShell { name = "rust"; - strictDeps = true; - nativeBuildInputs = [ + packages = [ rustc cargo @@ -20,8 +19,8 @@ mkShell { clippy cargo taplo - rust-analyzer - - cargo-nextest + rust-analyzer-unwrapped ]; + + env.RUST_SRC_PATH = "${rustPlatform.rustLibSrc}"; }