Compare commits

..

10 commits

Author SHA1 Message Date
6f9c6893e1
chore: bump deps; set MSRV to 1.90
Some checks are pending
Rust / build (push) Waiting to run
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I5947bb6da4c5ab6b7c02222a2b0a4ac36a6a6964
2026-01-30 22:04:08 +03:00
5dc7b1dcd4
eh: add eh update or nu in symlink
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Iee1d7c2ed2c4b2cd5520c68ceb2b5e6d6a6a6964
2026-01-30 22:04:07 +03:00
045d1632cb
nix: bump flake inputs
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Iec3db09400f92453b5ffb52e364852936a6a6964
2026-01-30 22:04:06 +03:00
e6d1b90b97
eh: improve error and warning glyphs; move logger to new crate
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I563b37a9f38f8dcec6dda7693ae45e826a6a6964
2026-01-30 22:04:05 +03:00
be3226bc3a
eh: improve error handling
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I13d7d14ed4de1ee472aae9fb4ec7ffe46a6a6964
2026-01-30 22:04:04 +03:00
9b632788c2
eh: rewrite command exec with thread-based pipe reading
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Id0e34e109a6423820e24676968e08dc66a6a6964
2026-01-30 22:04:03 +03:00
304a7e1a1a
eh: remove unused tracing dep
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Idd2818fa3d590b192c1bdecefb25da066a6a6964
2026-01-30 22:04:02 +03:00
4355f1d2c7
meta: move xtask to crates/
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I8080e8d293726fad569f3f8dd79b22ea6a6a6964
2026-01-30 22:04:01 +03:00
237bfec0d4
chore: bump crate version
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I3787eb5f42c471dda268e1f3ccffd0296a6a6964
2026-01-30 22:04:00 +03:00
6224f2f2d6
eh: attempt to prevent resource leaks
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I28d716bd37d17dd96731c7863b3383416a6a6964
2026-01-30 22:03:59 +03:00
16 changed files with 1482 additions and 617 deletions

281
Cargo.lock generated
View file

@ -4,18 +4,18 @@ version = 4
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.3" version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
[[package]] [[package]]
name = "anstyle" name = "anstyle"
version = "1.0.11" version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
@ -25,15 +25,15 @@ checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.1" version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.51" version = "4.5.56"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@ -41,9 +41,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.5.51" version = "4.5.56"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0"
dependencies = [ dependencies = [
"anstyle", "anstyle",
"clap_lex", "clap_lex",
@ -51,18 +51,18 @@ dependencies = [
[[package]] [[package]]
name = "clap_complete" name = "clap_complete"
version = "4.5.60" version = "4.5.65"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e602857739c5a4291dfa33b5a298aeac9006185229a700e5810a3ef7272d971" checksum = "430b4dc2b5e3861848de79627b2bedc9f3342c7da5173a14eaa5d0f8dc18ae5d"
dependencies = [ dependencies = [
"clap", "clap",
] ]
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.5.49" version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
@ -72,24 +72,61 @@ dependencies = [
[[package]] [[package]]
name = "clap_lex" name = "clap_lex"
version = "0.7.5" version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" 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",
]
[[package]] [[package]]
name = "eh" name = "eh"
version = "0.1.2" version = "0.1.4"
dependencies = [ dependencies = [
"clap", "clap",
"dialoguer",
"eh-log",
"regex", "regex",
"serde_json",
"tempfile", "tempfile",
"thiserror", "thiserror",
"tracing",
"tracing-subscriber",
"walkdir", "walkdir",
"yansi", "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]] [[package]]
name = "errno" name = "errno"
version = "0.3.14" version = "0.3.14"
@ -125,16 +162,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "lazy_static" name = "itoa"
version = "1.5.0" version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.177" version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
@ -142,26 +179,11 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "log"
version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.7.5" version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[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]] [[package]]
name = "once_cell" name = "once_cell"
@ -169,26 +191,20 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "pin-project-lite"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.95" version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.40" version = "1.0.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@ -224,15 +240,15 @@ dependencies = [
[[package]] [[package]]
name = "regex-syntax" name = "regex-syntax"
version = "0.8.5" version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.2" version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"errno", "errno",
@ -251,25 +267,58 @@ dependencies = [
] ]
[[package]] [[package]]
name = "sharded-slab" name = "serde"
version = "0.1.7" version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [ dependencies = [
"lazy_static", "serde_core",
] ]
[[package]] [[package]]
name = "smallvec" name = "serde_core"
version = "1.15.1" version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 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"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.104" version = "2.0.114"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -278,9 +327,9 @@ dependencies = [
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.23.0" version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom", "getrandom",
@ -291,101 +340,35 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.17" version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl",
] ]
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "2.0.17" version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn",
] ]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "tracing"
version = "0.1.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.18" version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]] [[package]]
name = "valuable" name = "unicode-width"
version = "0.1.1" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]] [[package]]
name = "walkdir" name = "walkdir"
@ -399,9 +382,9 @@ dependencies = [
[[package]] [[package]]
name = "wasip2" name = "wasip2"
version = "1.0.1+wasi-0.2.4" version = "1.0.2+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [ dependencies = [
"wit-bindgen", "wit-bindgen",
] ]
@ -432,13 +415,13 @@ dependencies = [
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.46.0" version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
[[package]] [[package]]
name = "xtask" name = "xtask"
version = "0.1.2" version = "0.1.4"
dependencies = [ dependencies = [
"clap", "clap",
"clap_complete", "clap_complete",
@ -450,3 +433,9 @@ name = "yansi"
version = "1.0.1" version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "zmij"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439"

View file

@ -1,5 +1,6 @@
[workspace] [workspace]
members = [ "eh", "xtask" ] default-members = [ "eh" ]
members = [ "eh", "crates/*" ]
resolver = "3" resolver = "3"
[workspace.package] [workspace.package]
@ -8,20 +9,23 @@ description = "Ergonomic Nix CLI helper"
edition = "2024" edition = "2024"
license = "MPL-2.0" license = "MPL-2.0"
readme = true readme = true
rust-version = "1.89" rust-version = "1.90"
version = "0.1.2" version = "0.1.4"
[workspace.dependencies] [workspace.dependencies]
clap = { default-features = false, features = [ "std", "help", "derive" ], version = "4.5.51" } clap = { default-features = false, features = [ "std", "help", "derive" ], version = "4.5.56" }
clap_complete = "4.5.60" clap_complete = "4.5.65"
dialoguer = { default-features = false, version = "0.12.0" }
regex = "1.12.2" regex = "1.12.2"
tempfile = "3.23.0" serde_json = "1.0.149"
thiserror = "2.0.17" tempfile = "3.24.0"
tracing = "0.1.41" thiserror = "2.0.18"
tracing-subscriber = "0.3.20"
walkdir = "2.5.0" walkdir = "2.5.0"
yansi = "1.0.1" yansi = "1.0.1"
eh = { path = "./eh" }
eh-log = { path = "./crates/eh-log" }
[profile.release] [profile.release]
codegen-units = 1 codegen-units = 1
lto = true lto = true

10
crates/eh-log/Cargo.toml Normal file
View file

@ -0,0 +1,10 @@
[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

31
crates/eh-log/src/lib.rs Normal file
View file

@ -0,0 +1,31 @@
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)*)) } }

View file

@ -11,4 +11,4 @@ publish = false
[dependencies] [dependencies]
clap.workspace = true clap.workspace = true
clap_complete.workspace = true clap_complete.workspace = true
eh = { path = "../eh" } eh.workspace = true

View file

@ -42,6 +42,7 @@ enum Binary {
Nr, Nr,
Ns, Ns,
Nb, Nb,
Nu,
} }
impl Binary { impl Binary {
@ -50,6 +51,7 @@ impl Binary {
Self::Nr => "nr", Self::Nr => "nr",
Self::Ns => "ns", Self::Ns => "ns",
Self::Nb => "nb", Self::Nb => "nb",
Self::Nu => "nu",
} }
} }
} }
@ -90,7 +92,7 @@ fn create_multicall_binaries(
); );
} }
let multicall_binaries = [Binary::Nr, Binary::Ns, Binary::Nb]; let multicall_binaries = [Binary::Nr, Binary::Ns, Binary::Nb, Binary::Nu];
let bin_path = Path::new(bin_dir); let bin_path = Path::new(bin_dir);
for binary in multicall_binaries { for binary in multicall_binaries {
@ -153,7 +155,7 @@ fn generate_completions(
println!("completion file generated: {}", completion_file.display()); println!("completion file generated: {}", completion_file.display());
// Create symlinks for multicall binaries // Create symlinks for multicall binaries
let multicall_names = ["nb", "nr", "ns"]; let multicall_names = ["nb", "nr", "ns", "nu"];
for name in &multicall_names { for name in &multicall_names {
let symlink_path = output_dir.join(format!("{name}.{shell}")); let symlink_path = output_dir.join(format!("{name}.{shell}"));
if symlink_path.exists() { if symlink_path.exists() {

View file

@ -12,10 +12,11 @@ name = "eh"
[dependencies] [dependencies]
clap.workspace = true clap.workspace = true
dialoguer.workspace = true
eh-log.workspace = true
regex.workspace = true regex.workspace = true
serde_json.workspace = true
tempfile.workspace = true tempfile.workspace = true
thiserror.workspace = true thiserror.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
walkdir.workspace = true walkdir.workspace = true
yansi.workspace = true yansi.workspace = true

View file

@ -1,7 +1,9 @@
use std::{ use std::{
collections::VecDeque,
io::{self, Read, Write}, io::{self, Read, Write},
process::{Command, ExitStatus, Output, Stdio}, process::{Command, ExitStatus, Output, Stdio},
sync::mpsc,
thread,
time::{Duration, Instant},
}; };
use crate::error::{EhError, Result}; use crate::error::{EhError, Result};
@ -27,6 +29,43 @@ impl LogInterceptor for StdIoInterceptor {
/// Default buffer size for reading command output /// Default buffer size for reading command output
const DEFAULT_BUFFER_SIZE: usize = 4096; 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<u8>),
Stderr(Vec<u8>),
Error(io::Error),
}
/// Drain a pipe reader, sending chunks through the channel.
fn read_pipe<R: Read>(
mut reader: R,
tx: mpsc::Sender<PipeEvent>,
is_stderr: bool,
) {
let mut buf = [0u8; DEFAULT_BUFFER_SIZE];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let event = if is_stderr {
PipeEvent::Stderr(buf[..n].to_vec())
} else {
PipeEvent::Stdout(buf[..n].to_vec())
};
if tx.send(event).is_err() {
break;
}
},
Err(e) => {
let _ = tx.send(PipeEvent::Error(e));
break;
},
}
}
}
/// Builder and executor for Nix commands. /// Builder and executor for Nix commands.
pub struct NixCommand { pub struct NixCommand {
subcommand: String, subcommand: String,
@ -54,16 +93,6 @@ impl NixCommand {
self self
} }
#[allow(dead_code, reason = "FIXME")]
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.args.extend(args.into_iter().map(Into::into));
self
}
#[must_use] #[must_use]
pub fn args_ref(mut self, args: &[String]) -> Self { pub fn args_ref(mut self, args: &[String]) -> Self {
self.args.extend(args.iter().cloned()); self.args.extend(args.iter().cloned());
@ -97,11 +126,9 @@ impl NixCommand {
self self
} }
/// Run the command, streaming output to the provided interceptor. /// Build the underlying `std::process::Command` with all configured
pub fn run_with_logs<I: LogInterceptor + 'static>( /// arguments, environment variables, and flags.
&self, fn build_command(&self) -> Command {
mut interceptor: I,
) -> Result<ExitStatus> {
let mut cmd = Command::new("nix"); let mut cmd = Command::new("nix");
cmd.arg(&self.subcommand); cmd.arg(&self.subcommand);
@ -117,6 +144,18 @@ impl NixCommand {
cmd.env(k, v); cmd.env(k, v);
} }
cmd.args(&self.args); 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<I: LogInterceptor + 'static>(
&self,
mut interceptor: I,
) -> Result<ExitStatus> {
let mut cmd = self.build_command();
if self.interactive { if self.interactive {
cmd.stdout(Stdio::inherit()); cmd.stdout(Stdio::inherit());
@ -129,86 +168,152 @@ impl NixCommand {
cmd.stderr(Stdio::piped()); cmd.stderr(Stdio::piped());
let mut child = cmd.spawn()?; let mut child = cmd.spawn()?;
let child_stdout = child.stdout.take().ok_or_else(|| { let stdout = child.stdout.take().ok_or_else(|| {
EhError::CommandFailed { EhError::CommandFailed {
command: format!("nix {}", self.subcommand), command: format!("nix {}", self.subcommand),
} }
})?; })?;
let child_stderr = child.stderr.take().ok_or_else(|| { let stderr = child.stderr.take().ok_or_else(|| {
EhError::CommandFailed { EhError::CommandFailed {
command: format!("nix {}", self.subcommand), command: format!("nix {}", self.subcommand),
} }
})?; })?;
let mut stdout = child_stdout;
let mut stderr = child_stderr;
let mut out_buf = [0u8; DEFAULT_BUFFER_SIZE]; let (tx, rx) = mpsc::channel();
let mut err_buf = [0u8; DEFAULT_BUFFER_SIZE];
let mut out_queue = VecDeque::new(); let tx_out = tx.clone();
let mut err_queue = VecDeque::new(); 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 { loop {
let mut did_something = false; 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 stdout.read(&mut out_buf) { match rx.recv_timeout(Duration::from_millis(100)) {
Ok(0) => {}, Ok(PipeEvent::Stdout(data)) => interceptor.on_stdout(&data),
Ok(n) => { Ok(PipeEvent::Stderr(data)) => interceptor.on_stderr(&data),
interceptor.on_stdout(&out_buf[..n]); Ok(PipeEvent::Error(e)) => {
out_queue.push_back(Vec::from(&out_buf[..n])); let _ = child.kill();
did_something = true; let _ = stdout_thread.join();
let _ = stderr_thread.join();
let _ = child.wait();
return Err(EhError::Io(e));
}, },
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {}, Err(mpsc::RecvTimeoutError::Timeout) => {},
Err(e) => return Err(EhError::Io(e)), // All senders dropped — both reader threads finished
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
} }
match stderr.read(&mut err_buf) { let _ = stdout_thread.join();
Ok(0) => {}, let _ = stderr_thread.join();
Ok(n) => {
interceptor.on_stderr(&err_buf[..n]);
err_queue.push_back(Vec::from(&err_buf[..n]));
did_something = true;
},
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 status = child.wait()?; let status = child.wait()?;
Ok(status) Ok(status)
} }
/// Run the command and capture all output. /// Run the command and capture all output (with timeout).
pub fn output(&self) -> Result<Output> { pub fn output(&self) -> Result<Output> {
let mut cmd = Command::new("nix"); let mut cmd = self.build_command();
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 { if self.interactive {
cmd.stdout(Stdio::inherit()); cmd.stdout(Stdio::inherit());
cmd.stderr(Stdio::inherit()); cmd.stderr(Stdio::inherit());
cmd.stdin(Stdio::inherit()); cmd.stdin(Stdio::inherit());
} else { return Ok(cmd.output()?);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
} }
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,
})
} }
} }

View file

@ -1,36 +1,59 @@
use std::time::Duration;
use thiserror::Error; use thiserror::Error;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum EhError { pub enum EhError {
#[error("Nix command failed: {0}")] #[error("nix {command} failed")]
NixCommandFailed(String), NixCommandFailed { command: String },
#[error("IO error: {0}")] #[error("io: {0}")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[error("Regex error: {0}")] #[error("regex: {0}")]
Regex(#[from] regex::Error), Regex(#[from] regex::Error),
#[error("UTF-8 conversion error: {0}")] #[error("utf-8 conversion: {0}")]
Utf8(#[from] std::string::FromUtf8Error), Utf8(#[from] std::string::FromUtf8Error),
#[error("Hash extraction failed")] #[error("could not extract hash from nix output")]
HashExtractionFailed, HashExtractionFailed { stderr: String },
#[error("No Nix files found")] #[error("no .nix files found in the current directory")]
NoNixFilesFound, NoNixFilesFound,
#[error("Failed to fix hash in file: {path}")] #[error("could not update hash in {path}")]
HashFixFailed { path: String }, HashFixFailed { path: String },
#[error("Process exited with code: {code}")] #[error("process exited with code {code}")]
ProcessExit { code: i32 }, ProcessExit { code: i32 },
#[error("Command execution failed: {command}")] #[error("command '{command}' failed")]
CommandFailed { command: String }, CommandFailed { command: String },
#[error("Invalid input: {input} - {reason}")] #[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 }, 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<T> = std::result::Result<T, EhError>; pub type Result<T> = std::result::Result<T, EhError>;
@ -40,9 +63,209 @@ impl EhError {
pub const fn exit_code(&self) -> i32 { pub const fn exit_code(&self) -> i32 {
match self { match self {
Self::ProcessExit { code } => *code, Self::ProcessExit { code } => *code,
Self::NixCommandFailed(_) => 1, Self::NixCommandFailed { .. } => 2,
Self::CommandFailed { .. } => 1, Self::CommandFailed { .. } => 3,
_ => 1, 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,
} }
} }
} }
#[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());
}
}

View file

@ -3,6 +3,7 @@ pub mod command;
pub mod error; pub mod error;
pub mod run; pub mod run;
pub mod shell; pub mod shell;
pub mod update;
pub mod util; pub mod util;
pub use clap::{CommandFactory, Parser, Subcommand}; pub use clap::{CommandFactory, Parser, Subcommand};
@ -34,4 +35,9 @@ pub enum Command {
#[arg(trailing_var_arg = true)] #[arg(trailing_var_arg = true)]
args: Vec<String>, args: Vec<String>,
}, },
/// Update flake inputs interactively
Update {
#[arg(trailing_var_arg = true)]
args: Vec<String>,
},
} }

View file

@ -2,30 +2,30 @@ use std::{env, path::Path};
use eh::{Cli, Command, CommandFactory, Parser}; use eh::{Cli, Command, CommandFactory, Parser};
use error::Result; use error::Result;
use yansi::Paint;
mod build; mod build;
mod command; mod command;
mod error; mod error;
mod run; mod run;
mod shell; mod shell;
mod update;
mod util; mod util;
fn main() { 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(); let result = run_app();
match result { match result {
Ok(code) => std::process::exit(code), Ok(code) => std::process::exit(code),
Err(e) => { Err(e) => {
eprintln!("Error: {e}"); let code = e.exit_code();
std::process::exit(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);
}, },
} }
} }
@ -37,42 +37,54 @@ fn dispatch_multicall(
) -> Option<Result<i32>> { ) -> Option<Result<i32>> {
let rest: Vec<String> = args.collect(); let rest: Vec<String> = args.collect();
// Validate arguments before processing let subcommand = match app_name {
if let Err(e) = util::validate_nix_args(&rest) { "nr" => "run",
return Some(Err(e)); "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));
}
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 hash_extractor = util::RegexHashExtractor;
let fixer = util::DefaultNixFileFixer; let fixer = util::DefaultNixFileFixer;
let classifier = util::DefaultNixErrorClassifier; let classifier = util::DefaultNixErrorClassifier;
match app_name { Some(match subcommand {
"nr" => { "run" => run::handle_nix_run(&rest, &hash_extractor, &fixer, &classifier),
Some(run::handle_nix_run( "shell" => {
&rest, shell::handle_nix_shell(&rest, &hash_extractor, &fixer, &classifier)
&hash_extractor,
&fixer,
&classifier,
))
}, },
"ns" => { "build" => {
Some(shell::handle_nix_shell( build::handle_nix_build(&rest, &hash_extractor, &fixer, &classifier)
&rest,
&hash_extractor,
&fixer,
&classifier,
))
}, },
"nb" => { // subcommand is assigned from the match on app_name above;
Some(build::handle_nix_build( // only "run"/"shell"/"build" are possible values.
&rest, _ => unreachable!(),
&hash_extractor, })
&fixer,
&classifier,
))
},
_ => None,
}
} }
fn run_app() -> Result<i32> { fn run_app() -> Result<i32> {
@ -107,7 +119,9 @@ fn run_app() -> Result<i32> {
build::handle_nix_build(&args, &hash_extractor, &fixer, &classifier) build::handle_nix_build(&args, &hash_extractor, &fixer, &classifier)
}, },
_ => { Some(Command::Update { args }) => update::handle_update(&args),
None => {
Cli::command().print_help()?; Cli::command().print_help()?;
println!(); println!();
Ok(0) Ok(0)

115
eh/src/update.rs Normal file
View file

@ -0,0 +1,115 @@
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<Vec<String>> {
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<String> = inputs.keys().cloned().collect();
names.sort();
Ok(names)
}
/// Fetch flake input names by running `nix flake metadata --json`.
fn fetch_flake_inputs() -> Result<Vec<String>> {
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<Vec<String>> {
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<i32> {
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)));
}
}

View file

@ -1,11 +1,12 @@
use std::{ use std::{
io::{BufWriter, Write}, io::{BufWriter, Write},
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::LazyLock,
}; };
use eh_log::{log_info, log_warn};
use regex::Regex; use regex::Regex;
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
use tracing::{info, warn};
use walkdir::WalkDir; use walkdir::WalkDir;
use yansi::Paint; use yansi::Paint;
@ -14,22 +15,41 @@ use crate::{
error::{EhError, Result}, 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<Regex> = 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 { pub trait HashExtractor {
fn extract_hash(&self, stderr: &str) -> Option<String>; fn extract_hash(&self, stderr: &str) -> Option<String>;
fn extract_old_hash(&self, stderr: &str) -> Option<String>;
} }
pub struct RegexHashExtractor; pub struct RegexHashExtractor;
impl HashExtractor for RegexHashExtractor { impl HashExtractor for RegexHashExtractor {
fn extract_hash(&self, stderr: &str) -> Option<String> { fn extract_hash(&self, stderr: &str) -> Option<String> {
let patterns = [ for re in HASH_EXTRACT_PATTERNS.iter() {
r"got:\s+(sha256-[a-zA-Z0-9+/=]+)", if let Some(captures) = re.captures(stderr)
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) && let Some(hash) = captures.get(1)
{ {
return Some(hash.as_str().to_string()); return Some(hash.as_str().to_string());
@ -37,23 +57,43 @@ impl HashExtractor for RegexHashExtractor {
} }
None None
} }
fn extract_old_hash(&self, stderr: &str) -> Option<String> {
HASH_OLD_EXTRACT_PATTERN
.captures(stderr)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
}
} }
pub trait NixFileFixer { pub trait NixFileFixer {
fn fix_hash_in_files(&self, new_hash: &str) -> Result<bool>; fn fix_hash_in_files(
&self,
old_hash: Option<&str>,
new_hash: &str,
) -> Result<bool>;
fn find_nix_files(&self) -> Result<Vec<PathBuf>>; fn find_nix_files(&self) -> Result<Vec<PathBuf>>;
fn fix_hash_in_file(&self, file_path: &Path, new_hash: &str) -> Result<bool>; fn fix_hash_in_file(
&self,
file_path: &Path,
old_hash: Option<&str>,
new_hash: &str,
) -> Result<bool>;
} }
pub struct DefaultNixFileFixer; pub struct DefaultNixFileFixer;
impl NixFileFixer for DefaultNixFileFixer { impl NixFileFixer for DefaultNixFileFixer {
fn fix_hash_in_files(&self, new_hash: &str) -> Result<bool> { fn fix_hash_in_files(
&self,
old_hash: Option<&str>,
new_hash: &str,
) -> Result<bool> {
let nix_files = self.find_nix_files()?; let nix_files = self.find_nix_files()?;
let mut fixed = false; let mut fixed = false;
for file_path in nix_files { for file_path in nix_files {
if self.fix_hash_in_file(&file_path, new_hash)? { if self.fix_hash_in_file(&file_path, old_hash, new_hash)? {
println!("Updated hash in {}", file_path.display()); log_info!("updated hash in {}", file_path.display().bold());
fixed = true; fixed = true;
} }
} }
@ -61,8 +101,20 @@ impl NixFileFixer for DefaultNixFileFixer {
} }
fn find_nix_files(&self) -> Result<Vec<PathBuf>> { fn find_nix_files(&self) -> Result<Vec<PathBuf>> {
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<PathBuf> = WalkDir::new(".") let files: Vec<PathBuf> = WalkDir::new(".")
.max_depth(3)
.into_iter() .into_iter()
.filter_entry(|e| !should_skip(e))
.filter_map(std::result::Result::ok) .filter_map(std::result::Result::ok)
.filter(|entry| { .filter(|entry| {
entry.file_type().is_file() entry.file_type().is_file()
@ -80,34 +132,37 @@ impl NixFileFixer for DefaultNixFileFixer {
Ok(files) Ok(files)
} }
fn fix_hash_in_file(&self, file_path: &Path, new_hash: &str) -> Result<bool> { fn fix_hash_in_file(
// Pre-compile regex patterns once to avoid repeated compilation &self,
let patterns: Vec<(Regex, String)> = [ file_path: &Path,
(r#"hash\s*=\s*"[^"]*""#, format!(r#"hash = "{new_hash}""#)), old_hash: Option<&str>,
( new_hash: &str,
r#"sha256\s*=\s*"[^"]*""#, ) -> Result<bool> {
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::<Result<Vec<_>>>()?;
// Read the entire file content // Read the entire file content
let content = std::fs::read_to_string(file_path)?; let content = std::fs::read_to_string(file_path)?;
let mut replaced = false; let mut replaced = false;
let mut result_content = content; let mut result_content = content;
// Apply replacements if let Some(old) = old_hash {
for (re, replacement) in &patterns { // 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) { if re.is_match(&result_content) {
result_content = re result_content = re
.replace_all(&result_content, replacement.as_str()) .replace_all(&result_content, replacement.as_str())
@ -115,6 +170,23 @@ impl NixFileFixer for DefaultNixFileFixer {
replaced = true; 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;
}
}
}
// Write back to file atomically // Write back to file atomically
if replaced { if replaced {
@ -140,38 +212,121 @@ pub trait NixErrorClassifier {
fn should_retry(&self, stderr: &str) -> bool; fn should_retry(&self, stderr: &str) -> bool;
} }
/// Pre-evaluate expression to catch errors early /// Classifies what retry action should be taken based on nix stderr output.
fn pre_evaluate(_subcommand: &str, args: &[String]) -> Result<bool> { #[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("<unknown>")
}
/// Print a retry message with consistent formatting.
/// Format: ` -> <pkg>: <reason>, setting <ENV>=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<RetryAction> {
// Find flake references or expressions to evaluate // Find flake references or expressions to evaluate
// Only take the first non-flag argument (the package/expression) // Only take the first non-flag argument (the package/expression)
let eval_arg = args.iter().find(|arg| !arg.starts_with('-')); let eval_arg = args.iter().find(|arg| !arg.starts_with('-'));
let Some(eval_arg) = eval_arg else { let Some(eval_arg) = eval_arg else {
return Ok(true); // No expression to evaluate return Ok(RetryAction::None); // No expression to evaluate
}; };
let eval_cmd = NixCommand::new("eval").arg(eval_arg).arg("--raw"); let eval_cmd = NixCommand::new("eval")
.arg(eval_arg)
.print_build_logs(false);
let output = eval_cmd.output()?; let output = eval_cmd.output()?;
if output.status.success() { if output.status.success() {
return Ok(true); return Ok(RetryAction::None);
} }
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
// If eval fails due to unfree/insecure/broken, don't fail pre-evaluation // Classify whether this is a retryable error (unfree/insecure/broken)
// Let the main command handle it with retry logic let action = classify_retry_action(&stderr);
if stderr.contains("has an unfree license") if action != RetryAction::None {
|| stderr.contains("refusing to evaluate") return Ok(action);
|| stderr.contains("has been marked as insecure")
|| stderr.contains("has been marked as broken")
{
return Ok(true);
} }
// For other eval failures, fail early // Non-retryable eval failure — fail fast with a clear message
Ok(false) // 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<()> { pub fn validate_nix_args(args: &[String]) -> Result<()> {
@ -202,15 +357,28 @@ pub fn handle_nix_with_retry(
interactive: bool, interactive: bool,
) -> Result<i32> { ) -> Result<i32> {
validate_nix_args(args)?; validate_nix_args(args)?;
// Pre-evaluate for build commands to catch errors early
if !pre_evaluate(subcommand, args)? { // Pre-evaluate to detect retryable errors (unfree/insecure/broken) before
return Err(EhError::NixCommandFailed( // running the actual command. This avoids streaming verbose nix error output
"Expression evaluation failed".to_string(), // 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));
} }
// For run commands, try interactive first to avoid breaking terminal // For run/shell commands, try interactive mode now that pre-eval passed
if subcommand == "run" && interactive { if interactive {
let status = NixCommand::new(subcommand) let status = NixCommand::new(subcommand)
.print_build_logs(true) .print_build_logs(true)
.interactive(true) .interactive(true)
@ -221,18 +389,22 @@ pub fn handle_nix_with_retry(
} }
} }
// First, always capture output to check for errors that need retry // Capture output to check for errors that need retry (hash mismatches etc.)
let output_cmd = NixCommand::new(subcommand) let output_cmd = NixCommand::new(subcommand)
.print_build_logs(true) .print_build_logs(true)
.args_ref(args); .args_ref(args);
let output = output_cmd.output()?; let output = output_cmd.output()?;
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
// Check if we need to retry with special flags // Check for hash mismatch errors
if let Some(new_hash) = hash_extractor.extract_hash(&stderr) { if let Some(new_hash) = hash_extractor.extract_hash(&stderr) {
match fixer.fix_hash_in_files(&new_hash) { let old_hash = hash_extractor.extract_old_hash(&stderr);
match fixer.fix_hash_in_files(old_hash.as_deref(), &new_hash) {
Ok(true) => { Ok(true) => {
info!("{}", Paint::green("✔ Fixed hash mismatch, retrying...")); log_info!(
"{}: hash mismatch corrected in local files, rebuilding",
pkg.bold()
);
let mut retry_cmd = NixCommand::new(subcommand) let mut retry_cmd = NixCommand::new(subcommand)
.print_build_logs(true) .print_build_logs(true)
.args_ref(args); .args_ref(args);
@ -246,72 +418,33 @@ pub fn handle_nix_with_retry(
// No files were fixed, continue with normal error handling // No files were fixed, continue with normal error handling
}, },
Err(EhError::NoNixFilesFound) => { Err(EhError::NoNixFilesFound) => {
warn!("No .nix files found to fix hash in"); log_warn!(
"{}: hash mismatch detected but no .nix files found to update",
pkg.bold()
);
// Continue with normal error handling // Continue with normal error handling
}, },
Err(e) => { Err(e) => {
return Err(e); return Err(e);
}, },
} }
} else if stderr.contains("hash") || stderr.contains("sha256") { } else if is_hash_mismatch_error(&stderr) {
// If there's a hash-related error but we couldn't extract it, that's a // There's a genuine hash mismatch but we couldn't extract the new hash
// failure return Err(EhError::HashExtractionFailed {
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) { if classifier.should_retry(&stderr) {
if stderr.contains("has an unfree license") && stderr.contains("refusing") { let action = classify_retry_action(&stderr);
warn!( if let Some((env_var, reason)) = action.env_override() {
"{}", print_retry_msg(pkg, reason, env_var);
Paint::yellow(
"⚠ Unfree package detected, retrying with NIXPKGS_ALLOW_UNFREE=1..."
)
);
let mut retry_cmd = NixCommand::new(subcommand) let mut retry_cmd = NixCommand::new(subcommand)
.print_build_logs(true) .print_build_logs(true)
.args_ref(args) .args_ref(args)
.env("NIXPKGS_ALLOW_UNFREE", "1") .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 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); .impure(true);
if interactive { if interactive {
retry_cmd = retry_cmd.interactive(true); retry_cmd = retry_cmd.interactive(true);
@ -330,22 +463,23 @@ pub fn handle_nix_with_retry(
std::io::stderr() std::io::stderr()
.write_all(&output.stderr) .write_all(&output.stderr)
.map_err(EhError::Io)?; .map_err(EhError::Io)?;
Err(EhError::ProcessExit {
code: output.status.code().unwrap_or(1), 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; pub struct DefaultNixErrorClassifier;
impl NixErrorClassifier for DefaultNixErrorClassifier { impl NixErrorClassifier for DefaultNixErrorClassifier {
fn should_retry(&self, stderr: &str) -> bool { fn should_retry(&self, stderr: &str) -> bool {
RegexHashExtractor.extract_hash(stderr).is_some() classify_retry_action(stderr) != RetryAction::None
|| (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"))
} }
} }
@ -379,7 +513,7 @@ mod tests {
let fixer = DefaultNixFileFixer; let fixer = DefaultNixFileFixer;
let result = fixer let result = fixer
.fix_hash_in_file(file_path, "sha256-newhash999") .fix_hash_in_file(file_path, None, "sha256-newhash999")
.unwrap(); .unwrap();
assert!(result, "Hash replacement should return true"); assert!(result, "Hash replacement should return true");
@ -413,7 +547,7 @@ mod tests {
// Test hash replacement // Test hash replacement
let fixer = DefaultNixFileFixer; let fixer = DefaultNixFileFixer;
let result = fixer let result = fixer
.fix_hash_in_file(&file_path, "sha256-newhash999") .fix_hash_in_file(&file_path, None, "sha256-newhash999")
.unwrap(); .unwrap();
assert!( assert!(
@ -448,7 +582,7 @@ mod tests {
// Test that streaming can handle large files without memory issues // Test that streaming can handle large files without memory issues
let fixer = DefaultNixFileFixer; let fixer = DefaultNixFileFixer;
let result = fixer let result = fixer
.fix_hash_in_file(file_path, "sha256-newhash999") .fix_hash_in_file(file_path, None, "sha256-newhash999")
.unwrap(); .unwrap();
assert!(result, "Hash replacement should work for large files"); assert!(result, "Hash replacement should work for large files");
@ -483,7 +617,9 @@ mod tests {
// Test hash replacement // Test hash replacement
let fixer = DefaultNixFileFixer; let fixer = DefaultNixFileFixer;
let result = fixer.fix_hash_in_file(file_path, "sha256-newhash").unwrap(); let result = fixer
.fix_hash_in_file(file_path, None, "sha256-newhash")
.unwrap();
assert!(result, "Hash replacement should succeed"); assert!(result, "Hash replacement should succeed");
@ -538,4 +674,233 @@ mod tests {
safe_args 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"
);
}
} }

6
flake.lock generated
View file

@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1762844143, "lastModified": 1769461804,
"narHash": "sha256-SlybxLZ1/e4T2lb1czEtWVzDCVSTvk9WLwGhmxFmBxI=", "narHash": "sha256-msG8SU5WsBUfVVa/9RPLaymvi5bI8edTavbIq3vRlhI=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "9da7f1cf7f8a6e2a7cb3001b048546c92a8258b4", "rev": "bfc1b8a4574108ceef22f02bafcf6611380c100d",
"type": "github" "type": "github"
}, },
"original": { "original": {