From 7f9364eb884e67341ce0a43df3c07b5f44b93e69 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 20 Mar 2026 14:47:46 +0300 Subject: [PATCH 1/8] eh: add `develop` alias `nd`; prompt before auto-fixing hashes Signed-off-by: NotAShelf Change-Id: I74835b683e247c86e4907d4fe0eccba06a6a6964 --- eh/src/lib.rs | 5 +++++ eh/src/main.rs | 5 ++++- eh/src/util.rs | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/eh/src/lib.rs b/eh/src/lib.rs index f76e705..6cb4f31 100644 --- a/eh/src/lib.rs +++ b/eh/src/lib.rs @@ -31,6 +31,11 @@ pub enum Command { #[arg(trailing_var_arg = true)] args: Vec, }, + /// Enter a Nix development shell + Develop { + #[arg(trailing_var_arg = true)] + args: Vec, + }, /// Update flake inputs interactively Update { #[arg(trailing_var_arg = true)] diff --git a/eh/src/main.rs b/eh/src/main.rs index 074952f..e350510 100644 --- a/eh/src/main.rs +++ b/eh/src/main.rs @@ -32,7 +32,7 @@ fn handle_command(command: &str, args: &[String]) -> error::Result { match command { "update" => commands::update::handle_update(args), - "run" | "shell" | "build" => { + "run" | "shell" | "build" | "develop" => { commands::handle_nix_command( command, args, @@ -55,6 +55,7 @@ fn dispatch_multicall( "nr" => "run", "ns" => "shell", "nb" => "build", + "nd" => "develop", "nu" => "update", _ => return None, }; @@ -104,6 +105,8 @@ fn run_app() -> error::Result { Some(Command::Build { args }) => handle_command("build", &args), + Some(Command::Develop { args }) => handle_command("develop", &args), + Some(Command::Update { args }) => handle_command("update", &args), None => { diff --git a/eh/src/util.rs b/eh/src/util.rs index d29dd67..229e303 100644 --- a/eh/src/util.rs +++ b/eh/src/util.rs @@ -511,6 +511,22 @@ pub fn handle_nix_with_retry( // 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 + let should_fix = 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)))?; + + if !should_fix { + log_warn!("{}: hash fix cancelled by user", pkg.bold()); + return Err(EhError::ProcessExit { code: 1 }); + } + match fixer.fix_hash_in_files(old_hash.as_deref(), &new_hash) { Ok(true) => { log_info!( From 7e2338b0173d25cdd8b597f212880dc3949a3a30 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 20 Mar 2026 15:10:54 +0300 Subject: [PATCH 2/8] xtask: create `nd` symlink Signed-off-by: NotAShelf Change-Id: I1e3a45059c5cdd443ace1c2f620a8a2a6a6a6964 --- crates/xtask/src/main.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index a580cda..4141510 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -42,6 +42,7 @@ enum Binary { Nr, Ns, Nb, + Nd, Nu, } @@ -51,6 +52,7 @@ impl Binary { Self::Nr => "nr", Self::Ns => "ns", Self::Nb => "nb", + Self::Nd => "nd", Self::Nu => "nu", } } @@ -92,7 +94,8 @@ 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::Nu]; let bin_path = Path::new(bin_dir); for binary in multicall_binaries { @@ -155,7 +158,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", "nr", "ns", "nu"]; for name in &multicall_names { let symlink_path = output_dir.join(format!("{name}.{shell}")); if symlink_path.exists() { From 8836eacb950dd9f8fc78f0e43bac54f69900f7a1 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 20 Mar 2026 15:11:10 +0300 Subject: [PATCH 3/8] eh: add info command; bump deps Signed-off-by: NotAShelf Change-Id: I85faac1cc3a48ed2622c1160ab954d8f6a6a6964 --- Cargo.lock | 296 +++++++++++++++++++++++++++++++++++---- Cargo.toml | 2 + crates/xtask/src/main.rs | 14 +- eh/Cargo.toml | 2 + eh/src/commands/info.rs | 290 ++++++++++++++++++++++++++++++++++++++ eh/src/commands/mod.rs | 1 + eh/src/lib.rs | 5 + eh/src/main.rs | 5 + eh/src/util.rs | 93 ++++++++++-- 9 files changed, 663 insertions(+), 45 deletions(-) create mode 100644 eh/src/commands/info.rs diff --git a/Cargo.lock b/Cargo.lock index 56d355d..30e8551 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "cfg-if" @@ -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", ] @@ -107,8 +112,10 @@ dependencies = [ "dialoguer", "eh-log", "regex", + "serde", "serde_json", "tempfile", + "textwrap", "thiserror", "walkdir", "yansi", @@ -127,6 +134,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" @@ -144,17 +157,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] -name = "getrandom" -version = "0.3.4" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +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.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "heck" version = "0.5.0" @@ -162,16 +197,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.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "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.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "linux-raw-sys" @@ -180,16 +239,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 +286,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 +304,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 +315,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 +341,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -273,6 +354,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -314,6 +396,12 @@ 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 +426,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" @@ -360,9 +459,15 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +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 +475,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" @@ -389,6 +500,49 @@ dependencies = [ "wit-bindgen", ] +[[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", +] + +[[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]] name = "winapi-util" version = "0.1.11" @@ -418,6 +572,88 @@ 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-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" @@ -436,6 +672,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" diff --git a/Cargo.toml b/Cargo.toml index f4f3cf7..816d198 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,8 +17,10 @@ clap = { default-features = false, features = [ "std", "help", "derive" 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" walkdir = "2.5.0" yansi = "1.0.1" diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index 4141510..0254d57 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -43,6 +43,7 @@ enum Binary { Ns, Nb, Nd, + Ni, Nu, } @@ -53,6 +54,7 @@ impl Binary { Self::Ns => "ns", Self::Nb => "nb", Self::Nd => "nd", + Self::Ni => "ni", Self::Nu => "nu", } } @@ -94,8 +96,14 @@ fn create_multicall_binaries( ); } - let multicall_binaries = - [Binary::Nr, Binary::Ns, Binary::Nb, Binary::Nd, 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 { @@ -158,7 +166,7 @@ fn generate_completions( println!("completion file generated: {}", completion_file.display()); // Create symlinks for multicall binaries - let multicall_names = ["nb", "nd", "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() { diff --git a/eh/Cargo.toml b/eh/Cargo.toml index 92dd751..98c219b 100644 --- a/eh/Cargo.toml +++ b/eh/Cargo.toml @@ -15,8 +15,10 @@ 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 walkdir.workspace = true yansi.workspace = true diff --git a/eh/src/commands/info.rs b/eh/src/commands/info.rs new file mode 100644 index 0000000..03b487a --- /dev/null +++ b/eh/src/commands/info.rs @@ -0,0 +1,290 @@ +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, + description: Option, + long_description: Option, + license: Option, + homepage: Option, + platforms: Option>, + broken: Option, + insecure: Option, + #[serde(rename = "unfree")] + unfree: Option, +} + +#[derive(Debug, Deserialize)] +struct PackageOutputs { + #[serde(flatten)] + outputs: HashMap, +} + +pub fn handle_info(args: &[String]) -> Result { + // Get the package argument (skip flags) + let pkg = args + .iter() + .find(|arg| !arg.starts_with('-')) + .cloned() + .unwrap_or_else(|| ".".to_string()); + + let eval_arg = make_eval_expr(&pkg); + let pkg_name: String = if eval_arg.contains("#") { + eval_arg + .split("#") + .last() + .unwrap_or(&eval_arg) + .trim_end_matches(".meta") + .to_string() + } else { + eval_arg.trim_end_matches(".meta").to_string() + }; + // Handle .# case - show "default" as the package name + let pkg_name = if pkg_name.is_empty() { + "default".to_string() + } else { + pkg_name + }; + + 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); + + 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); + + let outputs_output = outputs_cmd.output()?; + let outputs: Option = 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 = 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()); + } +} diff --git a/eh/src/commands/mod.rs b/eh/src/commands/mod.rs index 6cc417d..843b886 100644 --- a/eh/src/commands/mod.rs +++ b/eh/src/commands/mod.rs @@ -16,6 +16,7 @@ use crate::{ }, }; +pub mod info; pub mod update; const DEFAULT_BUFFER_SIZE: usize = 4096; diff --git a/eh/src/lib.rs b/eh/src/lib.rs index 6cb4f31..d0ddfe6 100644 --- a/eh/src/lib.rs +++ b/eh/src/lib.rs @@ -36,6 +36,11 @@ pub enum Command { #[arg(trailing_var_arg = true)] args: Vec, }, + /// Show package information + Info { + #[arg(trailing_var_arg = true)] + args: Vec, + }, /// Update flake inputs interactively Update { #[arg(trailing_var_arg = true)] diff --git a/eh/src/main.rs b/eh/src/main.rs index e350510..a9133ac 100644 --- a/eh/src/main.rs +++ b/eh/src/main.rs @@ -31,6 +31,8 @@ fn handle_command(command: &str, args: &[String]) -> error::Result { let classifier = util::DefaultNixErrorClassifier; match command { + "info" => commands::info::handle_info(args), + "update" => commands::update::handle_update(args), "run" | "shell" | "build" | "develop" => { commands::handle_nix_command( @@ -56,6 +58,7 @@ fn dispatch_multicall( "ns" => "shell", "nb" => "build", "nd" => "develop", + "ni" => "info", "nu" => "update", _ => return None, }; @@ -107,6 +110,8 @@ fn run_app() -> error::Result { 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 => { diff --git a/eh/src/util.rs b/eh/src/util.rs index 229e303..fce099b 100644 --- a/eh/src/util.rs +++ b/eh/src/util.rs @@ -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 = + 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") } @@ -512,18 +530,26 @@ pub fn handle_nix_with_retry( 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 - let should_fix = dialoguer::Confirm::new() - .with_prompt(format!( - "Hash mismatch detected for {}. Update hash in local .nix files?", + // 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() - )) - .default(true) - .interact() - .map_err(|e| EhError::Io(std::io::Error::other(e)))?; + ); + false + }; if !should_fix { - log_warn!("{}: hash fix cancelled by user", pkg.bold()); + log_warn!("{}: hash fix cancelled", pkg.bold()); return Err(EhError::ProcessExit { code: 1 }); } @@ -592,6 +618,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 @@ -611,6 +640,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 { + DID_YOU_MEAN_PATTERN + .captures(did_you_mean_line) + .and_then(|caps| caps.get(1)) + .map(|m| m.as_str()) + .map(|suggestions| { + suggestions + .split(", ") + .flat_map(|part| part.split(" or ")) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }) + .unwrap_or_default() +} + +/// Print contextual error suggestions when a command fails. +/// Parses Nix's own "Did you mean" suggestions from stderr and presents them +/// nicely to the user. +pub fn print_error_suggestions(stderr: &[u8]) { + let stderr_str = String::from_utf8_lossy(stderr); + + // Look for Nix's "Did you mean" line in the error output + if let Some(line) = stderr_str.lines().find(|l| l.contains("Did you mean")) { + let suggestions = parse_nix_suggestions(line); + + if !suggestions.is_empty() { + let formatted = suggestions + .iter() + .map(|s| s.bold().to_string()) + .collect::>() + .join(", "); + log_info!("Did you mean: {}?", formatted); + } + } +} + #[cfg(test)] mod tests { use std::io::Write; From e385c74b57fd794bced35016d8774e9839e22dd0 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 2 Apr 2026 23:46:03 +0300 Subject: [PATCH 4/8] config: add per-command and global impure knobs; bump deps Signed-off-by: NotAShelf Change-Id: Icadc94f1e1ca1c007feee7766c60847c6a6a6964 --- Cargo.lock | 99 +++++++++++++++----- Cargo.toml | 1 + eh/Cargo.toml | 1 + eh/src/config.rs | 236 +++++++++++++++++++++++++++++++++++++++++++++++ eh/src/lib.rs | 1 + eh/src/main.rs | 8 +- 6 files changed, 321 insertions(+), 25 deletions(-) create mode 100644 eh/src/config.rs diff --git a/Cargo.lock b/Cargo.lock index 30e8551..244e843 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,9 +25,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "cfg-if" @@ -37,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", @@ -57,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", @@ -117,6 +117,7 @@ dependencies = [ "tempfile", "textwrap", "thiserror", + "toml", "walkdir", "yansi", ] @@ -152,9 +153,9 @@ 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" @@ -186,9 +187,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "heck" @@ -204,12 +205,12 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -228,9 +229,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.184" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "linux-raw-sys" @@ -343,9 +344,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -390,6 +391,15 @@ 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" @@ -457,6 +467,37 @@ dependencies = [ "syn", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +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" @@ -493,11 +534,11 @@ 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]] @@ -506,7 +547,7 @@ 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", + "wit-bindgen 0.51.0", ] [[package]] @@ -567,6 +608,12 @@ 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" @@ -576,6 +623,12 @@ 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" diff --git a/Cargo.toml b/Cargo.toml index 816d198..c4a72db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ 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" diff --git a/eh/Cargo.toml b/eh/Cargo.toml index 98c219b..f3f4e51 100644 --- a/eh/Cargo.toml +++ b/eh/Cargo.toml @@ -20,5 +20,6 @@ serde_json.workspace = true tempfile.workspace = true textwrap.workspace = true thiserror.workspace = true +toml.workspace = true walkdir.workspace = true yansi.workspace = true diff --git a/eh/src/config.rs b/eh/src/config.rs new file mode 100644 index 0000000..d30fb2e --- /dev/null +++ b/eh/src/config.rs @@ -0,0 +1,236 @@ +use std::{ + collections::HashMap, + env, + fs, + path::{Path, PathBuf}, +}; + +use serde::Deserialize; + +#[derive(Debug, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct Config { + /// When `Some(true)`, pass `--impure` to every Nix command. + /// When `Some(false)`, block automatic impure retries for every command. + /// When absent (`None`), retry behaviour is automatic (default). + #[serde(default)] + pub impure: Option, + #[serde(default)] + pub commands: HashMap, +} + +/// Per-command configuration. +#[derive(Debug, Deserialize, Default, Clone)] +#[serde(deny_unknown_fields)] +pub struct CommandConfig { + /// When `Some(true)`, pass `--impure` to the underlying Nix command. + /// When `Some(false)`, block automatic impure retries for this command. + /// When absent (`None`), the global setting is used; if that is also absent, + /// retry behaviour is automatic (default). + #[serde(default)] + pub impure: Option, + /// Additional environment variables to set for the Nix command. + #[serde(default)] + pub env: HashMap, +} + +impl Config { + /// Return the [`CommandConfig`] for `command`. + /// + /// Resolution order: per-command `impure` takes precedence over the global + /// `impure`. Neither being set means automatic retry behaviour. + pub fn for_command(&self, command: &str) -> CommandConfig { + let mut cmd = self.commands.get(command).cloned().unwrap_or_default(); + // Per-command setting wins; fall back to global. + if cmd.impure.is_none() { + cmd.impure = self.impure; + } + cmd + } +} + +/// Load configuration from the first `.eh.toml` found by walking up from the +/// current directory, or from `~/.config/eh/config.toml` as a global +/// fallback. Returns a default (empty) config if no file is found or if +/// parsing fails. +pub fn load() -> Config { + if let Some(path) = find_project_config() + && let Some(cfg) = load_from_file(&path) + { + return cfg; + } + + if let Some(path) = global_config_path() + && let Some(cfg) = load_from_file(&path) + { + return cfg; + } + + Config::default() +} + +fn find_project_config() -> Option { + let mut dir = env::current_dir().ok()?; + loop { + let candidate = dir.join(".eh.toml"); + if candidate.exists() { + return Some(candidate); + } + if !dir.pop() { + return None; + } + } +} + +fn global_config_path() -> Option { + let home = env::var("HOME").ok()?; + Some( + PathBuf::from(home) + .join(".config") + .join("eh") + .join("config.toml"), + ) +} + +fn load_from_file(path: &Path) -> Option { + let content = fs::read_to_string(path).ok()?; + match toml::de::from_str::(&content) { + Ok(cfg) => Some(cfg), + Err(e) => { + eprintln!( + "eh: warning: failed to parse config file {}: {}", + path.display(), + e + ); + None + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_config_defaults() { + let cfg: Config = toml::from_str("").unwrap(); + assert!(cfg.impure.is_none()); + assert!(cfg.commands.is_empty()); + } + + #[test] + fn test_command_config_impure_true() { + let cfg: Config = toml::from_str( + r#" + [commands.build] + impure = true + "#, + ) + .unwrap(); + assert_eq!(cfg.for_command("build").impure, Some(true)); + assert_eq!(cfg.for_command("run").impure, None); + } + + #[test] + fn test_command_config_impure_false() { + let cfg: Config = toml::from_str( + r#" + [commands.build] + impure = false + "#, + ) + .unwrap(); + assert_eq!(cfg.for_command("build").impure, Some(false)); + assert_eq!(cfg.for_command("run").impure, None); + } + + #[test] + fn test_global_impure_propagates_to_unconfigured_commands() { + let cfg: Config = toml::from_str("impure = true").unwrap(); + // Commands with no per-command entry inherit global. + assert_eq!(cfg.for_command("build").impure, Some(true)); + assert_eq!(cfg.for_command("nonexistent").impure, Some(true)); + } + + #[test] + fn test_global_impure_false_propagates_to_unconfigured_commands() { + let cfg: Config = toml::from_str("impure = false").unwrap(); + assert_eq!(cfg.for_command("build").impure, Some(false)); + } + + #[test] + fn test_per_command_impure_overrides_global() { + // Per-command setting wins over global. + let cfg: Config = toml::from_str( + r#" + impure = false + + [commands.build] + impure = true + "#, + ) + .unwrap(); + assert_eq!(cfg.for_command("build").impure, Some(true)); + // Command without per-command entry falls back to global false. + assert_eq!(cfg.for_command("run").impure, Some(false)); + } + + #[test] + fn test_command_config_env() { + let cfg: Config = toml::from_str( + r#" + [commands.develop] + env = { FOO = "bar", BAZ = "1" } + "#, + ) + .unwrap(); + let dev = cfg.for_command("develop"); + assert_eq!(dev.env.get("FOO").map(String::as_str), Some("bar")); + assert_eq!(dev.env.get("BAZ").map(String::as_str), Some("1")); + } + + #[test] + fn test_command_config_env_table_syntax() { + let cfg: Config = toml::from_str( + r#" + [commands.shell] + impure = true + + [commands.shell.env] + MY_VAR = "hello" + "#, + ) + .unwrap(); + let shell = cfg.for_command("shell"); + assert_eq!(shell.impure, Some(true)); + assert_eq!(shell.env.get("MY_VAR").map(String::as_str), Some("hello")); + } + + #[test] + fn test_for_command_missing_returns_default() { + let cfg = Config::default(); + let cc = cfg.for_command("nonexistent"); + assert_eq!(cc.impure, None); + assert!(cc.env.is_empty()); + } + + #[test] + fn test_unknown_top_level_key_is_rejected() { + let result = toml::de::from_str::("unknown_key = true"); + assert!(result.is_err(), "unknown top-level keys should be rejected"); + } + + #[test] + fn test_unknown_command_key_is_rejected() { + let result = toml::de::from_str::( + r#" + [commands.build] + typo_key = true + "#, + ); + assert!( + result.is_err(), + "unknown per-command keys should be rejected" + ); + } +} diff --git a/eh/src/lib.rs b/eh/src/lib.rs index d0ddfe6..532625a 100644 --- a/eh/src/lib.rs +++ b/eh/src/lib.rs @@ -1,4 +1,5 @@ pub mod commands; +pub mod config; pub mod error; pub mod util; diff --git a/eh/src/main.rs b/eh/src/main.rs index a9133ac..38cf7c7 100644 --- a/eh/src/main.rs +++ b/eh/src/main.rs @@ -4,6 +4,7 @@ use eh::{Cli, Command, CommandFactory, Parser}; use yansi::Paint; mod commands; +mod config; mod error; mod util; @@ -29,11 +30,13 @@ fn handle_command(command: &str, args: &[String]) -> error::Result { let hash_extractor = util::RegexHashExtractor; let fixer = util::DefaultNixFileFixer; let classifier = util::DefaultNixErrorClassifier; + let cfg = config::load(); + let cmd_cfg = cfg.for_command(command); match command { - "info" => commands::info::handle_info(args), + "info" => commands::info::handle_info(args, &cmd_cfg), - "update" => commands::update::handle_update(args), + "update" => commands::update::handle_update(args, &cmd_cfg), "run" | "shell" | "build" | "develop" => { commands::handle_nix_command( command, @@ -41,6 +44,7 @@ fn handle_command(command: &str, args: &[String]) -> error::Result { &hash_extractor, &fixer, &classifier, + &cmd_cfg, ) }, _ => unreachable!(), From cd6a314bc882a6242ddf6129e726117267a32437 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 23 Apr 2026 17:43:35 +0300 Subject: [PATCH 5/8] util: block impure retries only when explicitly disabled Signed-off-by: NotAShelf Change-Id: I808c7976b97b3337c541f3bd4848eb486a6a6964 --- eh/src/commands/info.rs | 11 ++++++++--- eh/src/commands/mod.rs | 17 +++++++++++++++++ eh/src/commands/update.rs | 7 +++++-- eh/src/error.rs | 12 ++++++++++++ eh/src/util.rs | 22 ++++++++++++++++++++-- 5 files changed, 62 insertions(+), 7 deletions(-) diff --git a/eh/src/commands/info.rs b/eh/src/commands/info.rs index 03b487a..e0a34c3 100644 --- a/eh/src/commands/info.rs +++ b/eh/src/commands/info.rs @@ -31,7 +31,10 @@ struct PackageOutputs { outputs: HashMap, } -pub fn handle_info(args: &[String]) -> Result { +pub fn handle_info( + args: &[String], + cfg: &crate::config::CommandConfig, +) -> Result { // Get the package argument (skip flags) let pkg = args .iter() @@ -63,7 +66,8 @@ pub fn handle_info(args: &[String]) -> Result { let meta_cmd = NixCommand::new("eval") .arg("--json") .arg(&eval_arg) - .print_build_logs(false); + .print_build_logs(false) + .with_config(cfg); let meta_output = meta_cmd.output()?; @@ -91,7 +95,8 @@ pub fn handle_info(args: &[String]) -> Result { let outputs_cmd = NixCommand::new("eval") .arg("--json") .arg(format!("{}.outputs", outputs_expr)) - .print_build_logs(false); + .print_build_logs(false) + .with_config(cfg); let outputs_output = outputs_cmd.output()?; let outputs: Option = if outputs_output.status.success() { diff --git a/eh/src/commands/mod.rs b/eh/src/commands/mod.rs index 843b886..eeacf35 100644 --- a/eh/src/commands/mod.rs +++ b/eh/src/commands/mod.rs @@ -131,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); @@ -321,6 +336,7 @@ pub fn handle_nix_command( hash_extractor: &dyn HashExtractor, fixer: &dyn NixFileFixer, classifier: &dyn NixErrorClassifier, + cfg: &crate::config::CommandConfig, ) -> Result { let intercept_env = matches!(command, "run" | "shell"); handle_nix_with_retry( @@ -330,6 +346,7 @@ pub fn handle_nix_command( fixer, classifier, intercept_env, + cfg, ) } diff --git a/eh/src/commands/update.rs b/eh/src/commands/update.rs index 4640b13..bd2634c 100644 --- a/eh/src/commands/update.rs +++ b/eh/src/commands/update.rs @@ -55,7 +55,10 @@ fn prompt_input_selection(inputs: &[String]) -> Result> { /// /// 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 { +pub fn handle_update( + args: &[String], + cfg: &crate::config::CommandConfig, +) -> Result { 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 { 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); } diff --git a/eh/src/error.rs b/eh/src/error.rs index 2846bb9..478c49f 100644 --- a/eh/src/error.rs +++ b/eh/src/error.rs @@ -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 = std::result::Result; @@ -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(_) diff --git a/eh/src/util.rs b/eh/src/util.rs index fce099b..9076834 100644 --- a/eh/src/util.rs +++ b/eh/src/util.rs @@ -485,6 +485,7 @@ pub fn handle_nix_with_retry( fixer: &dyn NixFileFixer, classifier: &dyn NixErrorClassifier, interactive: bool, + cfg: &crate::config::CommandConfig, ) -> Result { validate_nix_args(args)?; @@ -494,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 { @@ -513,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); @@ -522,7 +531,8 @@ 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); @@ -561,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); } @@ -594,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 { From a53664be835bb272b92342bea543199e99da91ef Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 23 Apr 2026 17:43:45 +0300 Subject: [PATCH 6/8] docs: describe new configuration options Signed-off-by: NotAShelf Change-Id: I6365676d18d980b5727bc65d07cf47af6a6a6964 --- README.md | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/README.md b/README.md index 4689ebe..1c4a320 100644 --- a/README.md +++ b/README.md @@ -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. + + + +| Key | Type | Default | Description | +| -------- | ---- | ------- | -------------------------------------------------------------- | +| `impure` | bool | - | `true` passes `--impure` always; `false` blocks impure retries | + + + +### Per-command settings + +Each command can be configured independently under `[commands.]`. 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" +``` + + + +| 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 | + + + +### 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 From 08c4048bd33306a3b596d5a43a119225211edcaf Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 23 Apr 2026 17:46:08 +0300 Subject: [PATCH 7/8] nix: bump nixpkgs Signed-off-by: NotAShelf Change-Id: I8933bcfc5eccf2cbd6e4cbfe5d235b866a6a6964 --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 6f6bfa6..dfdfdf9 100644 --- a/flake.lock +++ b/flake.lock @@ -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": { From 7b3452ef18bad72203a600a869fd9543b9e5c8f4 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 23 Apr 2026 17:47:06 +0300 Subject: [PATCH 8/8] chore: bump dependencies; tag 0.1.6 Signed-off-by: NotAShelf Change-Id: I48330b301a7e724e16d2cae248aa10636a6a6964 --- Cargo.lock | 6 +++--- Cargo.toml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 244e843..dd5c19e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,7 +106,7 @@ dependencies = [ [[package]] name = "eh" -version = "0.1.7" +version = "0.2.0" dependencies = [ "clap", "dialoguer", @@ -124,7 +124,7 @@ dependencies = [ [[package]] name = "eh-log" -version = "0.1.7" +version = "0.2.0" dependencies = [ "yansi", ] @@ -710,7 +710,7 @@ dependencies = [ [[package]] name = "xtask" -version = "0.1.7" +version = "0.2.0" dependencies = [ "clap", "clap_complete", diff --git a/Cargo.toml b/Cargo.toml index c4a72db..931f336 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,8 +9,8 @@ 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" }