diff --git a/Cargo.lock b/Cargo.lock index 09dd3fa..d195e8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,18 +4,18 @@ version = 4 [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" [[package]] name = "bitflags" @@ -25,15 +25,15 @@ checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" [[package]] name = "clap" -version = "4.5.56" +version = "4.5.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" dependencies = [ "clap_builder", "clap_derive", @@ -41,9 +41,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.56" +version = "4.5.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" dependencies = [ "anstyle", "clap_lex", @@ -51,18 +51,18 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.65" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "430b4dc2b5e3861848de79627b2bedc9f3342c7da5173a14eaa5d0f8dc18ae5d" +checksum = "8e602857739c5a4291dfa33b5a298aeac9006185229a700e5810a3ef7272d971" dependencies = [ "clap", ] [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ "heck", "proc-macro2", @@ -72,61 +72,24 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.7" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" - -[[package]] -name = "console" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "unicode-width", - "windows-sys", -] - -[[package]] -name = "dialoguer" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" -dependencies = [ - "console", - "shell-words", -] +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" [[package]] name = "eh" -version = "0.1.4" +version = "0.1.2" dependencies = [ "clap", - "dialoguer", - "eh-log", "regex", - "serde_json", "tempfile", "thiserror", + "tracing", + "tracing-subscriber", "walkdir", "yansi", ] -[[package]] -name = "eh-log" -version = "0.1.4" -dependencies = [ - "yansi", -] - -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - [[package]] name = "errno" version = "0.3.14" @@ -162,16 +125,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "itoa" -version = "1.0.17" +name = "lazy_static" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "linux-raw-sys" @@ -180,10 +143,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] -name = "memchr" -version = "2.7.6" +name = "log" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] [[package]] name = "once_cell" @@ -192,19 +170,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "pin-project-lite" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.44" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] @@ -240,15 +224,15 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ "bitflags", "errno", @@ -267,58 +251,25 @@ dependencies = [ ] [[package]] -name = "serde" -version = "1.0.228" +name = "sharded-slab" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" dependencies = [ - "serde_core", + "lazy_static", ] [[package]] -name = "serde_core" -version = "1.0.228" +name = "smallvec" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "syn" -version = "2.0.114" +version = "2.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" dependencies = [ "proc-macro2", "quote", @@ -327,9 +278,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", "getrandom", @@ -340,18 +291,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", @@ -359,16 +310,82 @@ dependencies = [ ] [[package]] -name = "unicode-ident" -version = "1.0.22" +name = "thread_local" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] [[package]] -name = "unicode-width" -version = "0.2.2" +name = "tracing" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "walkdir" @@ -382,9 +399,9 @@ dependencies = [ [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ "wit-bindgen", ] @@ -415,13 +432,13 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "xtask" -version = "0.1.4" +version = "0.1.2" dependencies = [ "clap", "clap_complete", @@ -433,9 +450,3 @@ name = "yansi" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] -name = "zmij" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" diff --git a/Cargo.toml b/Cargo.toml index a0f139a..dda7e17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ [workspace] -default-members = [ "eh" ] -members = [ "eh", "crates/*" ] -resolver = "3" +members = [ "eh", "xtask" ] +resolver = "3" [workspace.package] authors = [ "NotAShelf " ] @@ -9,22 +8,19 @@ description = "Ergonomic Nix CLI helper" edition = "2024" license = "MPL-2.0" readme = true -rust-version = "1.90" -version = "0.1.4" +rust-version = "1.89" +version = "0.1.2" [workspace.dependencies] -clap = { default-features = false, features = [ "std", "help", "derive" ], version = "4.5.56" } -clap_complete = "4.5.65" -dialoguer = { default-features = false, version = "0.12.0" } -regex = "1.12.2" -serde_json = "1.0.149" -tempfile = "3.24.0" -thiserror = "2.0.18" -walkdir = "2.5.0" -yansi = "1.0.1" - -eh = { path = "./eh" } -eh-log = { path = "./crates/eh-log" } +clap = { default-features = false, features = [ "std", "help", "derive" ], version = "4.5.51" } +clap_complete = "4.5.60" +regex = "1.12.2" +tempfile = "3.23.0" +thiserror = "2.0.17" +tracing = "0.1.41" +tracing-subscriber = "0.3.20" +walkdir = "2.5.0" +yansi = "1.0.1" [profile.release] codegen-units = 1 diff --git a/crates/eh-log/Cargo.toml b/crates/eh-log/Cargo.toml deleted file mode 100644 index e3ef1f4..0000000 --- a/crates/eh-log/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "eh-log" -description = "Styled logging for eh" -version.workspace = true -edition.workspace = true -authors.workspace = true -rust-version.workspace = true - -[dependencies] -yansi.workspace = true diff --git a/crates/eh-log/src/lib.rs b/crates/eh-log/src/lib.rs deleted file mode 100644 index 365deff..0000000 --- a/crates/eh-log/src/lib.rs +++ /dev/null @@ -1,31 +0,0 @@ -use std::fmt; - -use yansi::Paint; - -pub fn info(args: fmt::Arguments) { - eprintln!(" {} {args}", "->".green().bold()); -} - -pub fn warn(args: fmt::Arguments) { - eprintln!(" {} {args}", "->".yellow().bold()); -} - -pub fn error(args: fmt::Arguments) { - eprintln!(" {} {args}", "!".red().bold()); -} - -pub fn hint(args: fmt::Arguments) { - eprintln!(" {} {args}", "~".yellow().dim()); -} - -#[macro_export] -macro_rules! log_info { ($($t:tt)*) => { $crate::info(format_args!($($t)*)) } } - -#[macro_export] -macro_rules! log_warn { ($($t:tt)*) => { $crate::warn(format_args!($($t)*)) } } - -#[macro_export] -macro_rules! log_error { ($($t:tt)*) => { $crate::error(format_args!($($t)*)) } } - -#[macro_export] -macro_rules! log_hint { ($($t:tt)*) => { $crate::hint(format_args!($($t)*)) } } diff --git a/eh/Cargo.toml b/eh/Cargo.toml index 92dd751..1e62b9b 100644 --- a/eh/Cargo.toml +++ b/eh/Cargo.toml @@ -11,12 +11,11 @@ crate-type = [ "lib" ] name = "eh" [dependencies] -clap.workspace = true -dialoguer.workspace = true -eh-log.workspace = true -regex.workspace = true -serde_json.workspace = true -tempfile.workspace = true -thiserror.workspace = true -walkdir.workspace = true -yansi.workspace = true +clap.workspace = true +regex.workspace = true +tempfile.workspace = true +thiserror.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +walkdir.workspace = true +yansi.workspace = true diff --git a/eh/src/command.rs b/eh/src/command.rs index a6ff260..74ca3f1 100644 --- a/eh/src/command.rs +++ b/eh/src/command.rs @@ -1,9 +1,7 @@ use std::{ + collections::VecDeque, io::{self, Read, Write}, process::{Command, ExitStatus, Output, Stdio}, - sync::mpsc, - thread, - time::{Duration, Instant}, }; use crate::error::{EhError, Result}; @@ -29,43 +27,6 @@ impl LogInterceptor for StdIoInterceptor { /// Default buffer size for reading command output const DEFAULT_BUFFER_SIZE: usize = 4096; -/// Default timeout for command execution -const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300); // 5 minutes - -enum PipeEvent { - Stdout(Vec), - Stderr(Vec), - Error(io::Error), -} - -/// Drain a pipe reader, sending chunks through the channel. -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; - }, - } - } -} - /// Builder and executor for Nix commands. pub struct NixCommand { subcommand: String, @@ -93,6 +54,16 @@ impl NixCommand { self } + #[allow(dead_code, reason = "FIXME")] + pub fn args(mut self, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.args.extend(args.into_iter().map(Into::into)); + self + } + #[must_use] pub fn args_ref(mut self, args: &[String]) -> Self { self.args.extend(args.iter().cloned()); @@ -126,9 +97,11 @@ impl NixCommand { self } - /// Build the underlying `std::process::Command` with all configured - /// arguments, environment variables, and flags. - fn build_command(&self) -> Command { + /// Run the command, streaming output to the provided interceptor. + pub fn run_with_logs( + &self, + mut interceptor: I, + ) -> Result { let mut cmd = Command::new("nix"); cmd.arg(&self.subcommand); @@ -144,18 +117,6 @@ impl NixCommand { cmd.env(k, v); } cmd.args(&self.args); - cmd - } - - /// Run the command, streaming output to the provided interceptor. - /// - /// Stdout and stderr are read concurrently using background threads - /// so that neither pipe blocks the other. - pub fn run_with_logs( - &self, - mut interceptor: I, - ) -> Result { - let mut cmd = self.build_command(); if self.interactive { cmd.stdout(Stdio::inherit()); @@ -168,152 +129,86 @@ impl NixCommand { cmd.stderr(Stdio::piped()); let mut child = cmd.spawn()?; - let stdout = child.stdout.take().ok_or_else(|| { + let child_stdout = child.stdout.take().ok_or_else(|| { EhError::CommandFailed { command: format!("nix {}", self.subcommand), } })?; - let stderr = child.stderr.take().ok_or_else(|| { + let child_stderr = child.stderr.take().ok_or_else(|| { EhError::CommandFailed { command: format!("nix {}", self.subcommand), } })?; + let mut stdout = child_stdout; + let mut stderr = child_stderr; - let (tx, rx) = mpsc::channel(); + let mut out_buf = [0u8; DEFAULT_BUFFER_SIZE]; + let mut err_buf = [0u8; DEFAULT_BUFFER_SIZE]; - 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(); + let mut out_queue = VecDeque::new(); + let mut err_queue = VecDeque::new(); 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, - }); + let mut did_something = false; + + match stdout.read(&mut out_buf) { + Ok(0) => {}, + Ok(n) => { + interceptor.on_stdout(&out_buf[..n]); + out_queue.push_back(Vec::from(&out_buf[..n])); + did_something = true; + }, + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {}, + Err(e) => return Err(EhError::Io(e)), } - 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)); + match stderr.read(&mut err_buf) { + Ok(0) => {}, + Ok(n) => { + interceptor.on_stderr(&err_buf[..n]); + err_queue.push_back(Vec::from(&err_buf[..n])); + did_something = true; }, - Err(mpsc::RecvTimeoutError::Timeout) => {}, - // All senders dropped — both reader threads finished - Err(mpsc::RecvTimeoutError::Disconnected) => break, + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {}, + Err(e) => return Err(EhError::Io(e)), + } + + if !did_something && child.try_wait()?.is_some() { + break; } } - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - let status = child.wait()?; Ok(status) } - /// Run the command and capture all output (with timeout). + /// Run the command and capture all output. pub fn output(&self) -> Result { - let mut cmd = self.build_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); if self.interactive { cmd.stdout(Stdio::inherit()); cmd.stderr(Stdio::inherit()); cmd.stdin(Stdio::inherit()); - return Ok(cmd.output()?); + } else { + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); } - 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, - }) + Ok(cmd.output()?) } } diff --git a/eh/src/error.rs b/eh/src/error.rs index 9ebc9b6..b9c2bfb 100644 --- a/eh/src/error.rs +++ b/eh/src/error.rs @@ -1,59 +1,36 @@ -use std::time::Duration; - use thiserror::Error; #[derive(Error, Debug)] pub enum EhError { - #[error("nix {command} failed")] - NixCommandFailed { command: String }, + #[error("Nix command failed: {0}")] + NixCommandFailed(String), - #[error("io: {0}")] + #[error("IO error: {0}")] Io(#[from] std::io::Error), - #[error("regex: {0}")] + #[error("Regex error: {0}")] Regex(#[from] regex::Error), - #[error("utf-8 conversion: {0}")] + #[error("UTF-8 conversion error: {0}")] Utf8(#[from] std::string::FromUtf8Error), - #[error("could not extract hash from nix output")] - HashExtractionFailed { stderr: String }, + #[error("Hash extraction failed")] + HashExtractionFailed, - #[error("no .nix files found in the current directory")] + #[error("No Nix files found")] NoNixFilesFound, - #[error("could not update hash in {path}")] + #[error("Failed to fix hash in file: {path}")] HashFixFailed { path: String }, - #[error("process exited with code {code}")] + #[error("Process exited with code: {code}")] ProcessExit { code: i32 }, - #[error("command '{command}' failed")] + #[error("Command execution failed: {command}")] 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}")] + #[error("Invalid input: {input} - {reason}")] InvalidInput { input: String, reason: String }, - - #[error("failed to parse JSON from nix output: {detail}")] - JsonParse { detail: String }, - - #[error("no flake inputs found in lock file")] - NoFlakeInputs, - - #[error("no inputs selected")] - UpdateCancelled, } pub type Result = std::result::Result; @@ -63,209 +40,9 @@ impl EhError { pub const fn exit_code(&self) -> i32 { match self { Self::ProcessExit { code } => *code, - Self::NixCommandFailed { .. } => 2, - Self::CommandFailed { .. } => 3, - Self::HashExtractionFailed { .. } => 4, - Self::NoNixFilesFound => 5, - Self::HashFixFailed { .. } => 6, - Self::InvalidInput { .. } => 7, - Self::Io(_) => 8, - Self::Regex(_) => 9, - Self::Utf8(_) => 10, - Self::Timeout { .. } => 11, - Self::PreEvalFailed { .. } => 12, - Self::JsonParse { .. } => 13, - Self::NoFlakeInputs => 14, - Self::UpdateCancelled => 0, - } - } - - #[must_use] - pub fn hint(&self) -> Option<&str> { - match self { - Self::NixCommandFailed { .. } => { - Some("run with --show-trace for more details") - }, - Self::PreEvalFailed { .. } => { - Some("check that the expression exists and is spelled correctly") - }, - Self::HashExtractionFailed { .. } => { - Some("nix reported a hash mismatch but the hash could not be parsed") - }, - 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") - }, - Self::NoFlakeInputs => { - Some("run this from a directory with a flake.lock that has inputs") - }, - Self::Io(_) - | Self::Regex(_) - | Self::Utf8(_) - | Self::HashFixFailed { .. } - | Self::ProcessExit { .. } - | Self::CommandFailed { .. } - | Self::UpdateCancelled => None, + Self::NixCommandFailed(_) => 1, + Self::CommandFailed { .. } => 1, + _ => 1, } } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_exit_codes() { - assert_eq!( - EhError::NixCommandFailed { - command: "build".into(), - } - .exit_code(), - 2 - ); - assert_eq!( - EhError::CommandFailed { - command: "x".into(), - } - .exit_code(), - 3 - ); - assert_eq!( - EhError::HashExtractionFailed { - stderr: String::new(), - } - .exit_code(), - 4 - ); - 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(), - stderr: "y".into(), - } - .exit_code(), - 12 - ); - assert_eq!(EhError::ProcessExit { code: 42 }.exit_code(), 42); - assert_eq!( - EhError::JsonParse { - detail: "x".into(), - } - .exit_code(), - 13 - ); - assert_eq!(EhError::NoFlakeInputs.exit_code(), 14); - assert_eq!(EhError::UpdateCancelled.exit_code(), 0); - } - - #[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(), - }; - assert!(err.to_string().contains("nixpkgs#hello")); - assert!(err.to_string().contains("attribute not found")); - - let err = EhError::HashExtractionFailed { - stderr: "some output".into(), - }; - assert!(err.to_string().contains("could not extract hash")); - } - - #[test] - fn test_hints() { - assert!( - EhError::PreEvalFailed { - expression: "x".into(), - stderr: "y".into(), - } - .hint() - .is_some() - ); - assert!( - EhError::HashExtractionFailed { - stderr: String::new(), - } - .hint() - .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 { - command: "build".into(), - } - .hint() - .is_some() - ); - 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/lib.rs b/eh/src/lib.rs index 23c5e78..5dd2c7b 100644 --- a/eh/src/lib.rs +++ b/eh/src/lib.rs @@ -3,7 +3,6 @@ pub mod command; pub mod error; pub mod run; pub mod shell; -pub mod update; pub mod util; pub use clap::{CommandFactory, Parser, Subcommand}; @@ -35,9 +34,4 @@ pub enum Command { #[arg(trailing_var_arg = true)] args: Vec, }, - /// Update flake inputs interactively - Update { - #[arg(trailing_var_arg = true)] - args: Vec, - }, } diff --git a/eh/src/main.rs b/eh/src/main.rs index 8ce8b6b..13c29f4 100644 --- a/eh/src/main.rs +++ b/eh/src/main.rs @@ -2,30 +2,30 @@ use std::{env, path::Path}; use eh::{Cli, Command, CommandFactory, Parser}; use error::Result; -use yansi::Paint; mod build; mod command; mod error; mod run; mod shell; -mod update; mod util; fn main() { + let format = tracing_subscriber::fmt::format() + .with_level(true) // don't include levels in formatted output + .with_target(true) // don't include targets + .with_thread_ids(false) // include the thread ID of the current thread + .with_thread_names(false) // include the name of the current thread + .compact(); // use the `Compact` formatting style. + tracing_subscriber::fmt().event_format(format).init(); + let result = run_app(); match result { Ok(code) => std::process::exit(code), Err(e) => { - let code = e.exit_code(); - if code != 0 { - eh_log::log_error!("{e}"); - if let Some(hint) = e.hint() { - eh_log::log_hint!("{hint}"); - } - } - std::process::exit(code); + eprintln!("Error: {e}"); + std::process::exit(e.exit_code()); }, } } @@ -36,55 +36,43 @@ fn dispatch_multicall( args: std::env::Args, ) -> Option> { let rest: Vec = args.collect(); - - let subcommand = match app_name { - "nr" => "run", - "ns" => "shell", - "nb" => "build", - "nu" => "update", - _ => return None, - }; - - // Handle --help/-h/--version before forwarding to nix - if rest.iter().any(|a| a == "--help" || a == "-h") { - eprintln!( - "{}: shorthand for '{}'", - app_name.bold(), - format!("eh {subcommand}").bold() - ); - eprintln!(" {} {app_name} [args...]", "usage:".green().bold()); - eprintln!( - " All arguments are forwarded to '{}'.", - format!("nix {subcommand}").dim() - ); - return Some(Ok(0)); + + // Validate arguments before processing + if let Err(e) = util::validate_nix_args(&rest) { + return Some(Err(e)); } - - if rest.iter().any(|a| a == "--version") { - eprintln!("{} (eh {})", app_name.bold(), env!("CARGO_PKG_VERSION")); - return Some(Ok(0)); - } - - if subcommand == "update" { - return Some(update::handle_update(&rest)); - } - + let hash_extractor = util::RegexHashExtractor; let fixer = util::DefaultNixFileFixer; let classifier = util::DefaultNixErrorClassifier; - Some(match subcommand { - "run" => run::handle_nix_run(&rest, &hash_extractor, &fixer, &classifier), - "shell" => { - shell::handle_nix_shell(&rest, &hash_extractor, &fixer, &classifier) + match app_name { + "nr" => { + Some(run::handle_nix_run( + &rest, + &hash_extractor, + &fixer, + &classifier, + )) }, - "build" => { - build::handle_nix_build(&rest, &hash_extractor, &fixer, &classifier) + "ns" => { + Some(shell::handle_nix_shell( + &rest, + &hash_extractor, + &fixer, + &classifier, + )) }, - // subcommand is assigned from the match on app_name above; - // only "run"/"shell"/"build" are possible values. - _ => unreachable!(), - }) + "nb" => { + Some(build::handle_nix_build( + &rest, + &hash_extractor, + &fixer, + &classifier, + )) + }, + _ => None, + } } fn run_app() -> Result { @@ -119,9 +107,7 @@ fn run_app() -> Result { build::handle_nix_build(&args, &hash_extractor, &fixer, &classifier) }, - Some(Command::Update { args }) => update::handle_update(&args), - - None => { + _ => { Cli::command().print_help()?; println!(); Ok(0) diff --git a/eh/src/update.rs b/eh/src/update.rs deleted file mode 100644 index df4184e..0000000 --- a/eh/src/update.rs +++ /dev/null @@ -1,115 +0,0 @@ -use crate::{ - command::{NixCommand, StdIoInterceptor}, - error::{EhError, Result}, -}; - -/// Parse flake input names from `nix flake metadata --json` output. -pub fn parse_flake_inputs(stdout: &str) -> Result> { - let value: serde_json::Value = - serde_json::from_str(stdout).map_err(|e| EhError::JsonParse { - detail: e.to_string(), - })?; - - let inputs = value - .get("locks") - .and_then(|l| l.get("nodes")) - .and_then(|n| n.get("root")) - .and_then(|r| r.get("inputs")) - .and_then(|i| i.as_object()) - .ok_or(EhError::NoFlakeInputs)?; - - let mut names: Vec = inputs.keys().cloned().collect(); - names.sort(); - Ok(names) -} - -/// Fetch flake input names by running `nix flake metadata --json`. -fn fetch_flake_inputs() -> Result> { - let output = NixCommand::new("flake") - .arg("metadata") - .arg("--json") - .print_build_logs(false) - .output()?; - - let stdout = String::from_utf8(output.stdout)?; - parse_flake_inputs(&stdout) -} - -/// Prompt the user to select inputs via a multi-select dialog. -fn prompt_input_selection(inputs: &[String]) -> Result> { - let selections = dialoguer::MultiSelect::new() - .with_prompt("Select inputs to update") - .items(inputs) - .interact() - .map_err(|e| EhError::Io(std::io::Error::other(e)))?; - - if selections.is_empty() { - return Err(EhError::UpdateCancelled); - } - - Ok(selections.iter().map(|&i| inputs[i].clone()).collect()) -} - -/// Entry point for the `update` subcommand. -/// -/// If `args` is non-empty, use them as explicit input names. -/// Otherwise, fetch inputs interactively and prompt for selection. -pub fn handle_update(args: &[String]) -> Result { - let selected = if args.is_empty() { - let inputs = fetch_flake_inputs()?; - if inputs.is_empty() { - return Err(EhError::NoFlakeInputs); - } - prompt_input_selection(&inputs)? - } else { - args.to_vec() - }; - - let mut cmd = NixCommand::new("flake").arg("lock"); - for name in &selected { - cmd = cmd.arg("--update-input").arg(name); - } - - eh_log::log_info!("updating inputs: {}", selected.join(", ")); - - let status = cmd.run_with_logs(StdIoInterceptor)?; - Ok(status.code().unwrap_or(1)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_flake_inputs() { - let json = r#"{ - "locks": { - "nodes": { - "root": { - "inputs": { - "nixpkgs": "nixpkgs_2", - "home-manager": "home-manager_2", - "flake-utils": "flake-utils_2" - } - } - } - } - }"#; - - let inputs = parse_flake_inputs(json).unwrap(); - assert_eq!(inputs, vec!["flake-utils", "home-manager", "nixpkgs"]); - } - - #[test] - fn test_parse_flake_inputs_invalid_json() { - let result = parse_flake_inputs("not json"); - assert!(result.is_err()); - } - - #[test] - fn test_parse_flake_inputs_no_inputs() { - let json = r#"{"locks": {"nodes": {"root": {}}}}"#; - let result = parse_flake_inputs(json); - assert!(matches!(result, Err(EhError::NoFlakeInputs))); - } -} diff --git a/eh/src/util.rs b/eh/src/util.rs index 10227de..f452296 100644 --- a/eh/src/util.rs +++ b/eh/src/util.rs @@ -1,12 +1,11 @@ use std::{ io::{BufWriter, Write}, path::{Path, PathBuf}, - sync::LazyLock, }; -use eh_log::{log_info, log_warn}; use regex::Regex; use tempfile::NamedTempFile; +use tracing::{info, warn}; use walkdir::WalkDir; use yansi::Paint; @@ -15,41 +14,22 @@ use crate::{ error::{EhError, Result}, }; -/// 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(), - ] -}); - 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 { - for re in HASH_EXTRACT_PATTERNS.iter() { - if let Some(captures) = re.captures(stderr) + let patterns = [ + r"got:\s+(sha256-[a-zA-Z0-9+/=]+)", + r"actual:\s+(sha256-[a-zA-Z0-9+/=]+)", + r"have:\s+(sha256-[a-zA-Z0-9+/=]+)", + ]; + for pattern in &patterns { + if let Ok(re) = Regex::new(pattern) + && let Some(captures) = re.captures(stderr) && let Some(hash) = captures.get(1) { return Some(hash.as_str().to_string()); @@ -57,43 +37,23 @@ impl HashExtractor for RegexHashExtractor { } 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()) - } } pub trait NixFileFixer { - fn fix_hash_in_files( - &self, - old_hash: Option<&str>, - new_hash: &str, - ) -> Result; + fn fix_hash_in_files(&self, 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; + fn fix_hash_in_file(&self, file_path: &Path, 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 { + fn fix_hash_in_files(&self, 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()); + if self.fix_hash_in_file(&file_path, new_hash)? { + println!("Updated hash in {}", file_path.display()); fixed = true; } } @@ -101,20 +61,8 @@ impl NixFileFixer for DefaultNixFileFixer { } 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(3) .into_iter() - .filter_entry(|e| !should_skip(e)) .filter_map(std::result::Result::ok) .filter(|entry| { entry.file_type().is_file() @@ -132,59 +80,39 @@ impl NixFileFixer for DefaultNixFileFixer { Ok(files) } - fn fix_hash_in_file( - &self, - file_path: &Path, - old_hash: Option<&str>, - new_hash: &str, - ) -> Result { + fn fix_hash_in_file(&self, file_path: &Path, new_hash: &str) -> Result { + // Pre-compile regex patterns once to avoid repeated compilation + let patterns: Vec<(Regex, String)> = [ + (r#"hash\s*=\s*"[^"]*""#, format!(r#"hash = "{new_hash}""#)), + ( + r#"sha256\s*=\s*"[^"]*""#, + format!(r#"sha256 = "{new_hash}""#), + ), + ( + r#"outputHash\s*=\s*"[^"]*""#, + format!(r#"outputHash = "{new_hash}""#), + ), + ] + .into_iter() + .map(|(pattern, replacement)| { + Regex::new(pattern) + .map(|re| (re, replacement)) + .map_err(EhError::Regex) + }) + .collect::>>()?; + // 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 { - // Targeted replacement: 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 (original behavior) - 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; - } + // Apply replacements + for (re, replacement) in &patterns { + if re.is_match(&result_content) { + result_content = re + .replace_all(&result_content, replacement.as_str()) + .into_owned(); + replaced = true; } } @@ -212,121 +140,38 @@ pub trait NixErrorClassifier { 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 { - AllowUnfree, - AllowInsecure, - AllowBroken, - None, -} - -impl RetryAction { - /// Returns `(env_var, reason)` for this retry action, - /// or `None` if no retry is needed. - 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(String::as_str) - .unwrap_or("") -} - -/// 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. -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:")) -} - -/// Pre-evaluate expression to catch errors early. -/// -/// Returns a `RetryAction` if the evaluation fails with a retryable error -/// (unfree/insecure/broken), allowing the caller to retry with the right -/// environment variables without ever streaming the verbose nix error output. -fn pre_evaluate(args: &[String]) -> Result { +/// Pre-evaluate expression to catch errors early +fn pre_evaluate(_subcommand: &str, args: &[String]) -> Result { // Find flake references or expressions to evaluate // Only take the first non-flag argument (the package/expression) let eval_arg = args.iter().find(|arg| !arg.starts_with('-')); let Some(eval_arg) = eval_arg else { - return Ok(RetryAction::None); // No expression to evaluate + return Ok(true); // No expression to evaluate }; - let eval_cmd = NixCommand::new("eval") - .arg(eval_arg) - .print_build_logs(false); + let eval_cmd = NixCommand::new("eval").arg(eval_arg).arg("--raw"); let output = eval_cmd.output()?; if output.status.success() { - return Ok(RetryAction::None); + return Ok(true); } let stderr = String::from_utf8_lossy(&output.stderr); - // Classify whether this is a retryable error (unfree/insecure/broken) - let action = classify_retry_action(&stderr); - if action != RetryAction::None { - return Ok(action); + // If eval fails due to unfree/insecure/broken, don't fail pre-evaluation + // Let the main command handle it with retry logic + if stderr.contains("has an unfree license") + || stderr.contains("refusing to evaluate") + || stderr.contains("has been marked as insecure") + || stderr.contains("has been marked as broken") + { + return Ok(true); } - // Non-retryable eval failure — 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(), - }) + // For other eval failures, fail early + Ok(false) } pub fn validate_nix_args(args: &[String]) -> Result<()> { @@ -357,28 +202,15 @@ pub fn handle_nix_with_retry( interactive: bool, ) -> 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() { - print_retry_msg(pkg, reason, env_var); - let mut retry_cmd = NixCommand::new(subcommand) - .print_build_logs(true) - .args_ref(args) - .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)); + // Pre-evaluate for build commands to catch errors early + if !pre_evaluate(subcommand, args)? { + return Err(EhError::NixCommandFailed( + "Expression evaluation failed".to_string(), + )); } - // For run/shell commands, try interactive mode now that pre-eval passed - if interactive { + // For run commands, try interactive first to avoid breaking terminal + if subcommand == "run" && interactive { let status = NixCommand::new(subcommand) .print_build_logs(true) .interactive(true) @@ -389,22 +221,18 @@ pub fn handle_nix_with_retry( } } - // Capture output to check for errors that need retry (hash mismatches etc.) + // First, always capture output to check for errors that need retry let output_cmd = NixCommand::new(subcommand) .print_build_logs(true) .args_ref(args); let output = output_cmd.output()?; let stderr = String::from_utf8_lossy(&output.stderr); - // Check for hash mismatch errors + // Check if we need to retry with special flags if let Some(new_hash) = hash_extractor.extract_hash(&stderr) { - let old_hash = hash_extractor.extract_old_hash(&stderr); - match fixer.fix_hash_in_files(old_hash.as_deref(), &new_hash) { + match fixer.fix_hash_in_files(&new_hash) { Ok(true) => { - log_info!( - "{}: hash mismatch corrected in local files, rebuilding", - pkg.bold() - ); + info!("{}", Paint::green("✔ Fixed hash mismatch, retrying...")); let mut retry_cmd = NixCommand::new(subcommand) .print_build_logs(true) .args_ref(args); @@ -418,33 +246,72 @@ pub fn handle_nix_with_retry( // 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() - ); + warn!("No .nix files found to fix hash in"); // 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(), - }); + } else if stderr.contains("hash") || stderr.contains("sha256") { + // If there's a hash-related error but we couldn't extract it, that's a + // failure + return Err(EhError::HashExtractionFailed); } - // 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() { - print_retry_msg(pkg, reason, env_var); + if stderr.contains("has an unfree license") && stderr.contains("refusing") { + warn!( + "{}", + Paint::yellow( + "⚠ Unfree package detected, retrying with NIXPKGS_ALLOW_UNFREE=1..." + ) + ); let mut retry_cmd = NixCommand::new(subcommand) .print_build_logs(true) .args_ref(args) - .env(env_var, "1") + .env("NIXPKGS_ALLOW_UNFREE", "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 stderr.contains("has been marked as insecure") + && stderr.contains("refusing") + { + warn!( + "{}", + Paint::yellow( + "⚠ Insecure package detected, retrying with \ + NIXPKGS_ALLOW_INSECURE=1..." + ) + ); + let mut retry_cmd = NixCommand::new(subcommand) + .print_build_logs(true) + .args_ref(args) + .env("NIXPKGS_ALLOW_INSECURE", "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 stderr.contains("has been marked as broken") + && stderr.contains("refusing") + { + warn!( + "{}", + Paint::yellow( + "⚠ Broken package detected, retrying with NIXPKGS_ALLOW_BROKEN=1..." + ) + ); + let mut retry_cmd = NixCommand::new(subcommand) + .print_build_logs(true) + .args_ref(args) + .env("NIXPKGS_ALLOW_BROKEN", "1") .impure(true); if interactive { retry_cmd = retry_cmd.interactive(true); @@ -463,23 +330,22 @@ pub fn handle_nix_with_retry( std::io::stderr() .write_all(&output.stderr) .map_err(EhError::Io)?; - - 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(), - }) - }, - } + Err(EhError::ProcessExit { + code: output.status.code().unwrap_or(1), + }) } pub struct DefaultNixErrorClassifier; impl NixErrorClassifier for DefaultNixErrorClassifier { fn should_retry(&self, stderr: &str) -> bool { - classify_retry_action(stderr) != RetryAction::None + RegexHashExtractor.extract_hash(stderr).is_some() + || (stderr.contains("has an unfree license") + && stderr.contains("refusing")) + || (stderr.contains("has been marked as insecure") + && stderr.contains("refusing")) + || (stderr.contains("has been marked as broken") + && stderr.contains("refusing")) } } @@ -513,7 +379,7 @@ mod tests { let fixer = DefaultNixFileFixer; let result = fixer - .fix_hash_in_file(file_path, None, "sha256-newhash999") + .fix_hash_in_file(file_path, "sha256-newhash999") .unwrap(); assert!(result, "Hash replacement should return true"); @@ -547,7 +413,7 @@ mod tests { // Test hash replacement let fixer = DefaultNixFileFixer; let result = fixer - .fix_hash_in_file(&file_path, None, "sha256-newhash999") + .fix_hash_in_file(&file_path, "sha256-newhash999") .unwrap(); assert!( @@ -582,7 +448,7 @@ mod tests { // 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") + .fix_hash_in_file(file_path, "sha256-newhash999") .unwrap(); assert!(result, "Hash replacement should work for large files"); @@ -617,9 +483,7 @@ mod tests { // Test hash replacement let fixer = DefaultNixFileFixer; - let result = fixer - .fix_hash_in_file(file_path, None, "sha256-newhash") - .unwrap(); + let result = fixer.fix_hash_in_file(file_path, "sha256-newhash").unwrap(); assert!(result, "Hash replacement should succeed"); @@ -674,233 +538,4 @@ mod tests { 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" - ); - } } diff --git a/flake.lock b/flake.lock index 6f6bfa6..6012087 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1769461804, - "narHash": "sha256-msG8SU5WsBUfVVa/9RPLaymvi5bI8edTavbIq3vRlhI=", + "lastModified": 1762844143, + "narHash": "sha256-SlybxLZ1/e4T2lb1czEtWVzDCVSTvk9WLwGhmxFmBxI=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "bfc1b8a4574108ceef22f02bafcf6611380c100d", + "rev": "9da7f1cf7f8a6e2a7cb3001b048546c92a8258b4", "type": "github" }, "original": { diff --git a/crates/xtask/Cargo.toml b/xtask/Cargo.toml similarity index 87% rename from crates/xtask/Cargo.toml rename to xtask/Cargo.toml index 4343ed0..ece4292 100644 --- a/crates/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -11,4 +11,4 @@ publish = false [dependencies] clap.workspace = true clap_complete.workspace = true -eh.workspace = true +eh = { path = "../eh" } diff --git a/crates/xtask/src/main.rs b/xtask/src/main.rs similarity index 96% rename from crates/xtask/src/main.rs rename to xtask/src/main.rs index a580cda..ad449c4 100644 --- a/crates/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -42,7 +42,6 @@ enum Binary { Nr, Ns, Nb, - Nu, } impl Binary { @@ -51,7 +50,6 @@ impl Binary { Self::Nr => "nr", Self::Ns => "ns", Self::Nb => "nb", - Self::Nu => "nu", } } } @@ -92,7 +90,7 @@ fn create_multicall_binaries( ); } - let multicall_binaries = [Binary::Nr, Binary::Ns, Binary::Nb, Binary::Nu]; + let multicall_binaries = [Binary::Nr, Binary::Ns, Binary::Nb]; let bin_path = Path::new(bin_dir); for binary in multicall_binaries { @@ -155,7 +153,7 @@ fn generate_completions( println!("completion file generated: {}", completion_file.display()); // Create symlinks for multicall binaries - let multicall_names = ["nb", "nr", "ns", "nu"]; + let multicall_names = ["nb", "nr", "ns"]; for name in &multicall_names { let symlink_path = output_dir.join(format!("{name}.{shell}")); if symlink_path.exists() {