Compare commits

...

8 commits

Author SHA1 Message Date
7b3452ef18
chore: bump dependencies; tag 0.1.6
Some checks failed
Rust / build (push) Has been cancelled
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I48330b301a7e724e16d2cae248aa10636a6a6964
2026-04-23 17:48:01 +03:00
08c4048bd3
nix: bump nixpkgs
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I8933bcfc5eccf2cbd6e4cbfe5d235b866a6a6964
2026-04-23 17:48:00 +03:00
a53664be83
docs: describe new configuration options
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6365676d18d980b5727bc65d07cf47af6a6a6964
2026-04-23 17:47:59 +03:00
cd6a314bc8
util: block impure retries only when explicitly disabled
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I808c7976b97b3337c541f3bd4848eb486a6a6964
2026-04-23 17:47:58 +03:00
e385c74b57
config: add per-command and global impure knobs; bump deps
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Icadc94f1e1ca1c007feee7766c60847c6a6a6964
2026-04-23 17:47:57 +03:00
8836eacb95
eh: add info command; bump deps
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I85faac1cc3a48ed2622c1160ab954d8f6a6a6964
2026-04-23 17:47:56 +03:00
7e2338b017
xtask: create nd symlink
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I1e3a45059c5cdd443ace1c2f620a8a2a6a6a6964
2026-04-23 17:47:55 +03:00
7f9364eb88
eh: add develop alias nd; prompt before auto-fixing hashes
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I74835b683e247c86e4907d4fe0eccba06a6a6964
2026-04-23 17:47:44 +03:00
14 changed files with 1143 additions and 57 deletions

377
Cargo.lock generated
View file

@ -13,15 +13,21 @@ dependencies = [
[[package]]
name = "anstyle"
version = "1.0.13"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "bitflags"
version = "2.10.0"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "cfg-if"
@ -31,9 +37,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.6.0"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
"clap_derive",
@ -51,18 +57,18 @@ dependencies = [
[[package]]
name = "clap_complete"
version = "4.6.0"
version = "4.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb"
checksum = "3ff7a1dccbdd8b078c2bdebff47e404615151534d5043da397ec50286816f9cb"
dependencies = [
"clap",
]
[[package]]
name = "clap_derive"
version = "4.6.0"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
@ -78,13 +84,12 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "console"
version = "0.16.2"
version = "0.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4"
checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width",
"windows-sys",
]
@ -101,22 +106,25 @@ dependencies = [
[[package]]
name = "eh"
version = "0.1.7"
version = "0.2.0"
dependencies = [
"clap",
"dialoguer",
"eh-log",
"regex",
"serde",
"serde_json",
"tempfile",
"textwrap",
"thiserror",
"toml",
"walkdir",
"yansi",
]
[[package]]
name = "eh-log"
version = "0.1.7"
version = "0.2.0"
dependencies = [
"yansi",
]
@ -127,6 +135,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
@ -139,22 +153,44 @@ dependencies = [
[[package]]
name = "fastrand"
version = "2.3.0"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "getrandom"
version = "0.3.4"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
"wasip3",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]]
name = "heck"
version = "0.5.0"
@ -162,16 +198,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "itoa"
version = "1.0.17"
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.0",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.183"
version = "0.2.185"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
[[package]]
name = "linux-raw-sys"
@ -180,16 +240,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "memchr"
version = "2.7.6"
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "once_cell"
version = "1.21.3"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
@ -211,9 +287,9 @@ dependencies = [
[[package]]
name = "r-efi"
version = "5.3.0"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "regex"
@ -229,9 +305,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.13"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
@ -240,9 +316,9 @@ dependencies = [
[[package]]
name = "regex-syntax"
version = "0.8.8"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rustix"
@ -266,6 +342,12 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
@ -273,6 +355,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
@ -308,12 +391,27 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]]
name = "smawk"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
[[package]]
name = "syn"
version = "2.0.117"
@ -338,6 +436,17 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "textwrap"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
dependencies = [
"smawk",
"unicode-linebreak",
"unicode-width",
]
[[package]]
name = "thiserror"
version = "2.0.18"
@ -359,10 +468,47 @@ dependencies = [
]
[[package]]
name = "unicode-ident"
version = "1.0.22"
name = "toml"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-linebreak"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]]
name = "unicode-width"
@ -370,6 +516,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "walkdir"
version = "2.5.0"
@ -382,11 +534,54 @@ dependencies = [
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen",
"wit-bindgen 0.57.1",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen 0.51.0",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
@ -413,15 +608,109 @@ dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "xtask"
version = "0.1.7"
version = "0.2.0"
dependencies = [
"clap",
"clap_complete",
@ -436,6 +725,6 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "zmij"
version = "1.0.17"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View file

@ -9,17 +9,20 @@ description = "Ergonomic Nix CLI helper"
edition = "2024"
license = "MPL-2.0"
readme = true
rust-version = "1.91.0"
version = "0.1.7"
rust-version = "1.94.0"
version = "0.2.0"
[workspace.dependencies]
clap = { default-features = false, features = [ "std", "help", "derive" ], version = "4.6.0" }
clap_complete = "4.6.0"
dialoguer = { default-features = false, version = "0.12.0" }
regex = "1.12.3"
serde = { features = [ "derive" ], version = "1.0.149" }
serde_json = "1.0.149"
tempfile = "3.27.0"
textwrap = "0.16.2"
thiserror = "2.0.18"
toml = { default-features = false, features = [ "parse", "serde" ], version = "1.1.2" }
walkdir = "2.5.0"
yansi = "1.0.1"

View file

@ -27,6 +27,10 @@ of building the package. The following variables are supported:
- **Insecure packages**: Sets `NIXPKGS_ALLOW_INSECURE=1`
- **Broken packages**: Sets `NIXPKGS_ALLOW_BROKEN=1`
Auto-retry requires that `--impure` is not explicitly disabled for the relevant
command in the config file. By default retries are automatic. See
[Configuration](#configuration).
### Hash Auto-Fix
When a hash mismatch is detected in the underlying `nix build`, `eh` can
@ -55,6 +59,92 @@ nb .#myPackage # nix build .#myPackage
nu # nix flake update
```
## Configuration
`eh` reads configuration from the first `.eh.toml` found by walking up from the
current directory, falling back to `~/.config/eh/config.toml`. If no file
exists, all defaults apply and no extra flags are passed to Nix.
### Global settings
Top-level keys apply to every command unless overridden per-command:
```toml
# Explicitly enable --impure for all commands (also passes it on initial run).
impure = true
# Explicitly disable impure retries for all commands.
impure = false
```
When `impure` is absent (the default), auto-retry with `--impure` is
**automatic** — `eh` will add `--impure` and the appropriate `NIXPKGS_ALLOW_*`
variable whenever it detects an unfree, insecure, or broken package.
<!--markdownlint-disable MD013-->
| Key | Type | Default | Description |
| -------- | ---- | ------- | -------------------------------------------------------------- |
| `impure` | bool | - | `true` passes `--impure` always; `false` blocks impure retries |
<!--markdownlint-enable MD013-->
### Per-command settings
Each command can be configured independently under `[commands.<name>]`. A
per-command setting takes precedence over the global one; the global setting
applies to commands that do not have their own entry.
```toml
[commands.build]
impure = true
env = { NIXPKGS_ALLOW_UNFREE = "1" }
[commands.develop]
impure = false
[commands.develop.env]
MY_DEV_VAR = "1"
```
<!--markdownlint-disable MD013-->
| Key | Type | Default | Description |
| -------- | ----- | ------- | ------------------------------------------------------------------------------- |
| `impure` | bool | - | `true` passes `--impure` always; `false` blocks impure retries for this command |
| `env` | table | `{}` | Extra environment variables to set for the command |
<!--markdownlint-enable MD013-->
### Impure mode and unfree/insecure/broken packages
When `eh` detects that a package requires `--impure` (unfree, insecure, or
broken), it retries automatically with the appropriate `NIXPKGS_ALLOW_*`
variable and `--impure` by default.
If `impure = false` is set for the active command (or globally), the retry is
blocked and an error is shown instead:
```plaintext
! package has an unfree license but `--impure` is disabled for `build` in config
~ set `impure = true` for this command (or globally) in .eh.toml or
~/.config/eh/config.toml, or pass `--impure` manually
```
To explicitly enable `--impure` for a specific command (also adds it to the
initial run, not just retries):
```toml
[commands.build]
impure = true
```
To disable impure retries globally:
```toml
impure = false
```
## License
<!--markdownlint-disable MD059-->

View file

@ -42,6 +42,8 @@ enum Binary {
Nr,
Ns,
Nb,
Nd,
Ni,
Nu,
}
@ -51,6 +53,8 @@ impl Binary {
Self::Nr => "nr",
Self::Ns => "ns",
Self::Nb => "nb",
Self::Nd => "nd",
Self::Ni => "ni",
Self::Nu => "nu",
}
}
@ -92,7 +96,14 @@ fn create_multicall_binaries(
);
}
let multicall_binaries = [Binary::Nr, Binary::Ns, Binary::Nb, Binary::Nu];
let multicall_binaries = [
Binary::Nr,
Binary::Ns,
Binary::Nb,
Binary::Nd,
Binary::Ni,
Binary::Nu,
];
let bin_path = Path::new(bin_dir);
for binary in multicall_binaries {
@ -155,7 +166,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", "nd", "ni", "nr", "ns", "nu"];
for name in &multicall_names {
let symlink_path = output_dir.join(format!("{name}.{shell}"));
if symlink_path.exists() {

View file

@ -15,8 +15,11 @@ clap.workspace = true
dialoguer.workspace = true
eh-log.workspace = true
regex.workspace = true
serde.workspace = true
serde_json.workspace = true
tempfile.workspace = true
textwrap.workspace = true
thiserror.workspace = true
toml.workspace = true
walkdir.workspace = true
yansi.workspace = true

295
eh/src/commands/info.rs Normal file
View file

@ -0,0 +1,295 @@
use std::collections::HashMap;
use eh_log::{log_error, log_info};
use serde::Deserialize;
use yansi::Paint;
use crate::{
commands::NixCommand,
error::{EhError, Result},
util::{make_eval_expr, print_error_suggestions},
};
#[derive(Debug, Deserialize)]
struct PackageMeta {
name: String,
version: Option<String>,
description: Option<String>,
long_description: Option<String>,
license: Option<serde_json::Value>,
homepage: Option<String>,
platforms: Option<Vec<String>>,
broken: Option<bool>,
insecure: Option<bool>,
#[serde(rename = "unfree")]
unfree: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct PackageOutputs {
#[serde(flatten)]
outputs: HashMap<String, serde_json::Value>,
}
pub fn handle_info(
args: &[String],
cfg: &crate::config::CommandConfig,
) -> Result<i32> {
// Get the package argument (skip flags)
let pkg = args
.iter()
.find(|arg| !arg.starts_with('-'))
.cloned()
.unwrap_or_else(|| ".".to_string());
let eval_arg = make_eval_expr(&pkg);
let pkg_name: String = if eval_arg.contains("#") {
eval_arg
.split("#")
.last()
.unwrap_or(&eval_arg)
.trim_end_matches(".meta")
.to_string()
} else {
eval_arg.trim_end_matches(".meta").to_string()
};
// Handle .# case - show "default" as the package name
let pkg_name = if pkg_name.is_empty() {
"default".to_string()
} else {
pkg_name
};
log_info!("Fetching info for {}", pkg_name.bold());
// Fetch metadata
let meta_cmd = NixCommand::new("eval")
.arg("--json")
.arg(&eval_arg)
.print_build_logs(false)
.with_config(cfg);
let meta_output = meta_cmd.output()?;
if !meta_output.status.success() {
log_error!("Failed to fetch package info");
print_error_suggestions(&meta_output.stderr);
return Err(EhError::NixCommandFailed {
command: "eval".to_string(),
});
}
let meta: PackageMeta =
serde_json::from_slice(&meta_output.stdout).map_err(|e| {
EhError::Io(std::io::Error::other(format!(
"Failed to parse package metadata: {}",
e
)))
})?;
// Fetch outputs
let outputs_expr = eval_arg
.strip_suffix(".meta")
.unwrap_or(&eval_arg)
.to_string();
let outputs_cmd = NixCommand::new("eval")
.arg("--json")
.arg(format!("{}.outputs", outputs_expr))
.print_build_logs(false)
.with_config(cfg);
let outputs_output = outputs_cmd.output()?;
let outputs: Option<PackageOutputs> = if outputs_output.status.success() {
serde_json::from_slice(&outputs_output.stdout).ok()
} else {
None
};
// Print formatted info
print_package_info(&meta, outputs.as_ref(), &pkg);
Ok(0)
}
fn print_package_info(
meta: &PackageMeta,
outputs: Option<&PackageOutputs>,
pkg_ref: &str,
) {
println!();
// Header
println!(" {} {}", "Package:".bold(), meta.name);
if let Some(ref version) = meta.version {
println!(" {} {}", "Version:".bold(), version);
}
if let Some(ref desc) = meta.description {
println!(" {} {}", "Description:".bold(), desc);
}
// Show long description if available and different from short description
if let Some(ref long_desc) = meta.long_description {
let should_show = meta
.description
.as_ref()
.map(|d| d != long_desc)
.unwrap_or(true);
if should_show {
println!();
// Wrap long description to 70 chars for readability
let wrapped = textwrap::fill(long_desc, 70);
for line in wrapped.lines() {
println!(" {}", line);
}
}
}
// License
if let Some(ref license) = meta.license {
let license_str = match license {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Object(obj) => {
obj
.get("spdxId")
.and_then(|v| v.as_str())
.or_else(|| obj.get("shortName").and_then(|v| v.as_str()))
.unwrap_or("Unknown")
.to_string()
},
serde_json::Value::Array(licenses) => {
// Handle multiple licenses (e.g., neovim has Apache-2.0 AND Vim)
let license_names: Vec<String> = licenses
.iter()
.filter_map(|lic| {
match lic {
serde_json::Value::Object(obj) => {
obj
.get("spdxId")
.and_then(|v| v.as_str())
.or_else(|| obj.get("shortName").and_then(|v| v.as_str()))
.map(|s| s.to_string())
},
serde_json::Value::String(s) => Some(s.clone()),
_ => None,
}
})
.collect();
if license_names.is_empty() {
"Unknown".to_string()
} else {
license_names.join(", ")
}
},
_ => "Unknown".to_string(),
};
println!(" {} {}", "License:".bold(), license_str);
}
// Homepage
if let Some(ref homepage) = meta.homepage {
println!(" {} {}", "Homepage:".bold(), homepage);
}
// Meta section
println!();
println!(" {}", "Meta:".bold());
// Status indicators
let mut status_parts = Vec::new();
if meta.broken == Some(true) {
status_parts.push("Broken".red().to_string());
}
if meta.insecure == Some(true) {
status_parts.push("Insecure".red().to_string());
}
if meta.unfree == Some(true) {
status_parts.push("Unfree".yellow().to_string());
}
if status_parts.is_empty() {
println!(" {} {}", "Status:".bold(), "✓ Available".green());
} else {
println!(" {} {}", "Status:".bold(), status_parts.join(", "));
}
// Platforms
if let Some(ref platforms) = meta.platforms {
let platform_list: Vec<_> = platforms.iter().take(4).cloned().collect();
let platform_str = if platforms.len() > 4 {
format!(
"{} + {} more",
platform_list.join(", "),
platforms.len() - 4
)
} else {
platform_list.join(", ")
};
println!(" {} {}", "Platforms:".bold(), platform_str);
}
// Outputs section
if let Some(outputs) = outputs {
println!();
println!(" {}", "Outputs:".bold());
let output_names: Vec<_> = outputs.outputs.keys().cloned().collect();
for name in output_names {
let marker = if name == "out" { " (default)" } else { "" };
println!("{}{}", name, marker.dim());
}
}
// Usage section
println!();
println!(" {}", "Usage:".bold());
println!(
" {} {} {}",
"eh run".dim(),
pkg_ref,
"# Run the package".dim()
);
println!(
" {} {} {}",
"eh shell".dim(),
pkg_ref,
"# Enter shell with package".dim()
);
println!();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_package_meta_deserialization() {
let json = r#"{
"name": "hello",
"version": "2.12.1",
"description": "A greeting program",
"license": "GPL-3.0",
"homepage": "https://example.com",
"platforms": ["x86_64-linux"],
"broken": false,
"insecure": false,
"unfree": false
}"#;
let meta: PackageMeta = serde_json::from_str(json).unwrap();
assert_eq!(meta.name, "hello");
assert_eq!(meta.version, Some("2.12.1".to_string()));
}
#[test]
fn test_license_object_parsing() {
let json = r#"{
"name": "test",
"license": {"spdxId": "MIT", "fullName": "MIT License"}
}"#;
let meta: PackageMeta = serde_json::from_str(json).unwrap();
assert!(meta.license.is_some());
}
}

View file

@ -16,6 +16,7 @@ use crate::{
},
};
pub mod info;
pub mod update;
const DEFAULT_BUFFER_SIZE: usize = 4096;
@ -130,6 +131,21 @@ impl NixCommand {
self
}
/// Apply per-command configuration: sets `--impure` (when explicitly enabled)
/// and any extra environment variables declared in the config file. Call
/// this before any retry-specific overrides so that retry logic can still
/// force `impure(true)` afterwards.
#[must_use]
pub fn with_config(mut self, cfg: &crate::config::CommandConfig) -> Self {
if cfg.impure == Some(true) {
self = self.impure(true);
}
for (k, v) in &cfg.env {
self = self.env(k, v);
}
self
}
fn build_command(&self) -> Command {
let mut cmd = Command::new("nix");
cmd.arg(&self.subcommand);
@ -320,6 +336,7 @@ pub fn handle_nix_command(
hash_extractor: &dyn HashExtractor,
fixer: &dyn NixFileFixer,
classifier: &dyn NixErrorClassifier,
cfg: &crate::config::CommandConfig,
) -> Result<i32> {
let intercept_env = matches!(command, "run" | "shell");
handle_nix_with_retry(
@ -329,6 +346,7 @@ pub fn handle_nix_command(
fixer,
classifier,
intercept_env,
cfg,
)
}

View file

@ -55,7 +55,10 @@ fn prompt_input_selection(inputs: &[String]) -> Result<Vec<String>> {
///
/// 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> {
pub fn handle_update(
args: &[String],
cfg: &crate::config::CommandConfig,
) -> Result<i32> {
let selected = if args.is_empty() {
let inputs = fetch_flake_inputs()?;
if inputs.is_empty() {
@ -66,7 +69,7 @@ pub fn handle_update(args: &[String]) -> Result<i32> {
args.to_vec()
};
let mut cmd = NixCommand::new("flake").arg("lock");
let mut cmd = NixCommand::new("flake").arg("lock").with_config(cfg);
for name in &selected {
cmd = cmd.arg("--update-input").arg(name);
}

236
eh/src/config.rs Normal file
View file

@ -0,0 +1,236 @@
use std::{
collections::HashMap,
env,
fs,
path::{Path, PathBuf},
};
use serde::Deserialize;
#[derive(Debug, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Config {
/// When `Some(true)`, pass `--impure` to every Nix command.
/// When `Some(false)`, block automatic impure retries for every command.
/// When absent (`None`), retry behaviour is automatic (default).
#[serde(default)]
pub impure: Option<bool>,
#[serde(default)]
pub commands: HashMap<String, CommandConfig>,
}
/// Per-command configuration.
#[derive(Debug, Deserialize, Default, Clone)]
#[serde(deny_unknown_fields)]
pub struct CommandConfig {
/// When `Some(true)`, pass `--impure` to the underlying Nix command.
/// When `Some(false)`, block automatic impure retries for this command.
/// When absent (`None`), the global setting is used; if that is also absent,
/// retry behaviour is automatic (default).
#[serde(default)]
pub impure: Option<bool>,
/// Additional environment variables to set for the Nix command.
#[serde(default)]
pub env: HashMap<String, String>,
}
impl Config {
/// Return the [`CommandConfig`] for `command`.
///
/// Resolution order: per-command `impure` takes precedence over the global
/// `impure`. Neither being set means automatic retry behaviour.
pub fn for_command(&self, command: &str) -> CommandConfig {
let mut cmd = self.commands.get(command).cloned().unwrap_or_default();
// Per-command setting wins; fall back to global.
if cmd.impure.is_none() {
cmd.impure = self.impure;
}
cmd
}
}
/// Load configuration from the first `.eh.toml` found by walking up from the
/// current directory, or from `~/.config/eh/config.toml` as a global
/// fallback. Returns a default (empty) config if no file is found or if
/// parsing fails.
pub fn load() -> Config {
if let Some(path) = find_project_config()
&& let Some(cfg) = load_from_file(&path)
{
return cfg;
}
if let Some(path) = global_config_path()
&& let Some(cfg) = load_from_file(&path)
{
return cfg;
}
Config::default()
}
fn find_project_config() -> Option<PathBuf> {
let mut dir = env::current_dir().ok()?;
loop {
let candidate = dir.join(".eh.toml");
if candidate.exists() {
return Some(candidate);
}
if !dir.pop() {
return None;
}
}
}
fn global_config_path() -> Option<PathBuf> {
let home = env::var("HOME").ok()?;
Some(
PathBuf::from(home)
.join(".config")
.join("eh")
.join("config.toml"),
)
}
fn load_from_file(path: &Path) -> Option<Config> {
let content = fs::read_to_string(path).ok()?;
match toml::de::from_str::<Config>(&content) {
Ok(cfg) => Some(cfg),
Err(e) => {
eprintln!(
"eh: warning: failed to parse config file {}: {}",
path.display(),
e
);
None
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_config_defaults() {
let cfg: Config = toml::from_str("").unwrap();
assert!(cfg.impure.is_none());
assert!(cfg.commands.is_empty());
}
#[test]
fn test_command_config_impure_true() {
let cfg: Config = toml::from_str(
r#"
[commands.build]
impure = true
"#,
)
.unwrap();
assert_eq!(cfg.for_command("build").impure, Some(true));
assert_eq!(cfg.for_command("run").impure, None);
}
#[test]
fn test_command_config_impure_false() {
let cfg: Config = toml::from_str(
r#"
[commands.build]
impure = false
"#,
)
.unwrap();
assert_eq!(cfg.for_command("build").impure, Some(false));
assert_eq!(cfg.for_command("run").impure, None);
}
#[test]
fn test_global_impure_propagates_to_unconfigured_commands() {
let cfg: Config = toml::from_str("impure = true").unwrap();
// Commands with no per-command entry inherit global.
assert_eq!(cfg.for_command("build").impure, Some(true));
assert_eq!(cfg.for_command("nonexistent").impure, Some(true));
}
#[test]
fn test_global_impure_false_propagates_to_unconfigured_commands() {
let cfg: Config = toml::from_str("impure = false").unwrap();
assert_eq!(cfg.for_command("build").impure, Some(false));
}
#[test]
fn test_per_command_impure_overrides_global() {
// Per-command setting wins over global.
let cfg: Config = toml::from_str(
r#"
impure = false
[commands.build]
impure = true
"#,
)
.unwrap();
assert_eq!(cfg.for_command("build").impure, Some(true));
// Command without per-command entry falls back to global false.
assert_eq!(cfg.for_command("run").impure, Some(false));
}
#[test]
fn test_command_config_env() {
let cfg: Config = toml::from_str(
r#"
[commands.develop]
env = { FOO = "bar", BAZ = "1" }
"#,
)
.unwrap();
let dev = cfg.for_command("develop");
assert_eq!(dev.env.get("FOO").map(String::as_str), Some("bar"));
assert_eq!(dev.env.get("BAZ").map(String::as_str), Some("1"));
}
#[test]
fn test_command_config_env_table_syntax() {
let cfg: Config = toml::from_str(
r#"
[commands.shell]
impure = true
[commands.shell.env]
MY_VAR = "hello"
"#,
)
.unwrap();
let shell = cfg.for_command("shell");
assert_eq!(shell.impure, Some(true));
assert_eq!(shell.env.get("MY_VAR").map(String::as_str), Some("hello"));
}
#[test]
fn test_for_command_missing_returns_default() {
let cfg = Config::default();
let cc = cfg.for_command("nonexistent");
assert_eq!(cc.impure, None);
assert!(cc.env.is_empty());
}
#[test]
fn test_unknown_top_level_key_is_rejected() {
let result = toml::de::from_str::<Config>("unknown_key = true");
assert!(result.is_err(), "unknown top-level keys should be rejected");
}
#[test]
fn test_unknown_command_key_is_rejected() {
let result = toml::de::from_str::<Config>(
r#"
[commands.build]
typo_key = true
"#,
);
assert!(
result.is_err(),
"unknown per-command keys should be rejected"
);
}
}

View file

@ -54,6 +54,11 @@ pub enum EhError {
#[error("no inputs selected")]
UpdateCancelled,
#[error(
"package {reason} but `--impure` is disabled for `{command}` in config"
)]
ImpureRequired { command: String, reason: String },
}
pub type Result<T> = std::result::Result<T, EhError>;
@ -77,6 +82,7 @@ impl EhError {
Self::JsonParse { .. } => 13,
Self::NoFlakeInputs => 14,
Self::UpdateCancelled => 0,
Self::ImpureRequired { .. } => 15,
}
}
@ -110,6 +116,12 @@ impl EhError {
Self::NoFlakeInputs => {
Some("run this from a directory with a flake.lock that has inputs")
},
Self::ImpureRequired { .. } => {
Some(
"set `impure = true` for this command (or globally) in .eh.toml or \
~/.config/eh/config.toml, or pass `--impure` manually",
)
},
Self::Io(_)
| Self::Regex(_)
| Self::Utf8(_)

View file

@ -1,4 +1,5 @@
pub mod commands;
pub mod config;
pub mod error;
pub mod util;
@ -31,6 +32,16 @@ pub enum Command {
#[arg(trailing_var_arg = true)]
args: Vec<String>,
},
/// Enter a Nix development shell
Develop {
#[arg(trailing_var_arg = true)]
args: Vec<String>,
},
/// Show package information
Info {
#[arg(trailing_var_arg = true)]
args: Vec<String>,
},
/// Update flake inputs interactively
Update {
#[arg(trailing_var_arg = true)]

View file

@ -4,6 +4,7 @@ use eh::{Cli, Command, CommandFactory, Parser};
use yansi::Paint;
mod commands;
mod config;
mod error;
mod util;
@ -29,16 +30,21 @@ fn handle_command(command: &str, args: &[String]) -> error::Result<i32> {
let hash_extractor = util::RegexHashExtractor;
let fixer = util::DefaultNixFileFixer;
let classifier = util::DefaultNixErrorClassifier;
let cfg = config::load();
let cmd_cfg = cfg.for_command(command);
match command {
"update" => commands::update::handle_update(args),
"run" | "shell" | "build" => {
"info" => commands::info::handle_info(args, &cmd_cfg),
"update" => commands::update::handle_update(args, &cmd_cfg),
"run" | "shell" | "build" | "develop" => {
commands::handle_nix_command(
command,
args,
&hash_extractor,
&fixer,
&classifier,
&cmd_cfg,
)
},
_ => unreachable!(),
@ -55,6 +61,8 @@ fn dispatch_multicall(
"nr" => "run",
"ns" => "shell",
"nb" => "build",
"nd" => "develop",
"ni" => "info",
"nu" => "update",
_ => return None,
};
@ -104,6 +112,10 @@ fn run_app() -> error::Result<i32> {
Some(Command::Build { args }) => handle_command("build", &args),
Some(Command::Develop { args }) => handle_command("develop", &args),
Some(Command::Info { args }) => handle_command("info", &args),
Some(Command::Update { args }) => handle_command("update", &args),
None => {

View file

@ -1,5 +1,5 @@
use std::{
io::{BufWriter, Write},
io::{BufWriter, IsTerminal, Write},
path::{Path, PathBuf},
sync::LazyLock,
};
@ -42,6 +42,13 @@ static HASH_FIX_PATTERNS: LazyLock<[Regex; 3]> = LazyLock::new(|| {
]
});
/// Regex to extract suggestions from Nix's "Did you mean" error line.
/// Matches patterns like:
/// - "Did you mean one of hello, world, or foo?"
/// - "Did you mean lib.hello?"
static DID_YOU_MEAN_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"Did you mean (?:one of )?(.+?)\?"#).unwrap());
/// Trait for extracting store paths and hashes from nix output.
pub trait HashExtractor {
/// Extract the new store path/hash from nix output.
@ -311,9 +318,20 @@ fn is_hash_mismatch_error(stderr: &str) -> bool {
/// Construct the eval expression for a given argument.
/// Handles both plain package names and flake references.
fn make_eval_expr(eval_arg: &str) -> String {
pub fn make_eval_expr(eval_arg: &str) -> String {
// Handle . (current directory) as .# (default package of current flake)
// Nix treats `nix build .` and `nix build .#` as equivalent
let eval_arg = if eval_arg == "." { ".#" } else { eval_arg };
if eval_arg.contains('#') {
format!("{eval_arg}.meta")
// Handle .# (current flake default package) case
// .# needs to become .#default for meta evaluation to work
// because .#.meta evaluates 'meta' on the flake itself, not the package
if eval_arg.ends_with('#') {
format!("{eval_arg}default.meta")
} else {
format!("{eval_arg}.meta")
}
} else {
format!("nixpkgs#{eval_arg}.meta")
}
@ -467,6 +485,7 @@ pub fn handle_nix_with_retry(
fixer: &dyn NixFileFixer,
classifier: &dyn NixErrorClassifier,
interactive: bool,
cfg: &crate::config::CommandConfig,
) -> Result<i32> {
validate_nix_args(args)?;
@ -476,10 +495,17 @@ pub fn handle_nix_with_retry(
let pkg = package_name(args);
let pre_eval_action = pre_evaluate(args)?;
if let Some((env_var, reason)) = pre_eval_action.env_override() {
if cfg.impure == Some(false) {
return Err(EhError::ImpureRequired {
command: subcommand.to_string(),
reason: reason.to_string(),
});
}
print_retry_msg(pkg, reason, env_var);
let mut retry_cmd = NixCommand::new(subcommand)
.print_build_logs(true)
.args_ref(args)
.with_config(cfg)
.env(env_var, "1")
.impure(true);
if interactive {
@ -495,6 +521,7 @@ pub fn handle_nix_with_retry(
.print_build_logs(true)
.interactive(true)
.args_ref(args)
.with_config(cfg)
.run_with_logs(StdIoInterceptor)?;
if status.success() {
return Ok(0);
@ -504,13 +531,38 @@ pub fn handle_nix_with_retry(
// Capture output to check for errors that need retry (hash mismatches etc.)
let output_cmd = NixCommand::new(subcommand)
.print_build_logs(true)
.args_ref(args);
.args_ref(args)
.with_config(cfg);
let output = output_cmd.output()?;
let stderr = String::from_utf8_lossy(&output.stderr);
// Check for hash mismatch errors
if let Some(new_hash) = hash_extractor.extract_hash(&stderr) {
let old_hash = hash_extractor.extract_old_hash(&stderr);
// Ask for confirmation before fixing hash (skip in non-interactive mode)
let should_fix = if std::io::stdin().is_terminal() {
dialoguer::Confirm::new()
.with_prompt(format!(
"Hash mismatch detected for {}. Update hash in local .nix files?",
pkg.bold()
))
.default(true)
.interact()
.map_err(|e| EhError::Io(std::io::Error::other(e)))?
} else {
log_warn!(
"{}: hash mismatch detected in non-interactive mode, skipping auto-fix",
pkg.bold()
);
false
};
if !should_fix {
log_warn!("{}: hash fix cancelled", pkg.bold());
return Err(EhError::ProcessExit { code: 1 });
}
match fixer.fix_hash_in_files(old_hash.as_deref(), &new_hash) {
Ok(true) => {
log_info!(
@ -519,7 +571,8 @@ pub fn handle_nix_with_retry(
);
let mut retry_cmd = NixCommand::new(subcommand)
.print_build_logs(true)
.args_ref(args);
.args_ref(args)
.with_config(cfg);
if interactive {
retry_cmd = retry_cmd.interactive(true);
}
@ -552,10 +605,17 @@ pub fn handle_nix_with_retry(
if classifier.should_retry(&stderr) {
let action = classify_retry_action(&stderr);
if let Some((env_var, reason)) = action.env_override() {
if cfg.impure == Some(false) {
return Err(EhError::ImpureRequired {
command: subcommand.to_string(),
reason: reason.to_string(),
});
}
print_retry_msg(pkg, reason, env_var);
let mut retry_cmd = NixCommand::new(subcommand)
.print_build_logs(true)
.args_ref(args)
.with_config(cfg)
.env(env_var, "1")
.impure(true);
if interactive {
@ -576,6 +636,9 @@ pub fn handle_nix_with_retry(
.write_all(&output.stderr)
.map_err(EhError::Io)?;
// Print contextual suggestions for common errors
print_error_suggestions(&output.stderr);
match output.status.code() {
Some(code) => Err(EhError::ProcessExit { code }),
// No exit code means the process was killed by a signal
@ -595,6 +658,46 @@ impl NixErrorClassifier for DefaultNixErrorClassifier {
}
}
/// Parse suggestions from Nix's "Did you mean" error line.
/// Input: "Did you mean one of neovim, hevi, navi, neo or neo4j?"
/// Output: vec!["neovim", "hevi", "navi", "neo", "neo4j"]
fn parse_nix_suggestions(did_you_mean_line: &str) -> Vec<String> {
DID_YOU_MEAN_PATTERN
.captures(did_you_mean_line)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str())
.map(|suggestions| {
suggestions
.split(", ")
.flat_map(|part| part.split(" or "))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default()
}
/// Print contextual error suggestions when a command fails.
/// Parses Nix's own "Did you mean" suggestions from stderr and presents them
/// nicely to the user.
pub fn print_error_suggestions(stderr: &[u8]) {
let stderr_str = String::from_utf8_lossy(stderr);
// Look for Nix's "Did you mean" line in the error output
if let Some(line) = stderr_str.lines().find(|l| l.contains("Did you mean")) {
let suggestions = parse_nix_suggestions(line);
if !suggestions.is_empty() {
let formatted = suggestions
.iter()
.map(|s| s.bold().to_string())
.collect::<Vec<_>>()
.join(", ");
log_info!("Did you mean: {}?", formatted);
}
}
}
#[cfg(test)]
mod tests {
use std::io::Write;

6
flake.lock generated
View file

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