Compare commits
10 commits
0d50b374bd
...
6f9c6893e1
| Author | SHA1 | Date | |
|---|---|---|---|
|
6f9c6893e1 |
|||
|
5dc7b1dcd4 |
|||
|
045d1632cb |
|||
|
e6d1b90b97 |
|||
|
be3226bc3a |
|||
|
9b632788c2 |
|||
|
304a7e1a1a |
|||
|
4355f1d2c7 |
|||
|
237bfec0d4 |
|||
|
6224f2f2d6 |
16 changed files with 1482 additions and 617 deletions
281
Cargo.lock
generated
281
Cargo.lock
generated
|
|
@ -4,18 +4,18 @@ version = 4
|
|||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.3"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.11"
|
||||
version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd"
|
||||
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
|
|
@ -25,15 +25,15 @@ checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
|||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.1"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.51"
|
||||
version = "4.5.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5"
|
||||
checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
|
|
@ -41,9 +41,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.51"
|
||||
version = "4.5.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a"
|
||||
checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
|
|
@ -51,18 +51,18 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "clap_complete"
|
||||
version = "4.5.60"
|
||||
version = "4.5.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e602857739c5a4291dfa33b5a298aeac9006185229a700e5810a3ef7272d971"
|
||||
checksum = "430b4dc2b5e3861848de79627b2bedc9f3342c7da5173a14eaa5d0f8dc18ae5d"
|
||||
dependencies = [
|
||||
"clap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.49"
|
||||
version = "4.5.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
|
||||
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
|
|
@ -72,24 +72,61 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "0.7.5"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
|
||||
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
|
||||
|
||||
[[package]]
|
||||
name = "console"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4"
|
||||
dependencies = [
|
||||
"encode_unicode",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"unicode-width",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dialoguer"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96"
|
||||
dependencies = [
|
||||
"console",
|
||||
"shell-words",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "eh"
|
||||
version = "0.1.2"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"dialoguer",
|
||||
"eh-log",
|
||||
"regex",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"walkdir",
|
||||
"yansi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "eh-log"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"yansi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encode_unicode"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
|
|
@ -125,16 +162,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.177"
|
||||
version = "0.2.180"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
|
||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
|
|
@ -142,26 +179,11 @@ version = "0.11.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.5"
|
||||
version = "2.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
|
|
@ -169,26 +191,20 @@ version = "1.21.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.95"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.40"
|
||||
version = "1.0.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
|
||||
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
|
@ -224,15 +240,15 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.5"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.2"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
|
||||
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
|
|
@ -251,25 +267,58 @@ dependencies = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shell-words"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.104"
|
||||
version = "2.0.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40"
|
||||
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -278,9 +327,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.23.0"
|
||||
version = "3.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
|
||||
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
|
|
@ -291,101 +340,35 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.17"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.17"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
||||
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
|
||||
dependencies = [
|
||||
"nu-ansi-term",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.18"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
|
||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
|
|
@ -399,9 +382,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.1+wasi-0.2.4"
|
||||
version = "1.0.2+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
|
||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
|
@ -432,13 +415,13 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.46.0"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.2"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
|
|
@ -450,3 +433,9 @@ name = "yansi"
|
|||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439"
|
||||
|
|
|
|||
30
Cargo.toml
30
Cargo.toml
|
|
@ -1,6 +1,7 @@
|
|||
[workspace]
|
||||
members = [ "eh", "xtask" ]
|
||||
resolver = "3"
|
||||
default-members = [ "eh" ]
|
||||
members = [ "eh", "crates/*" ]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
authors = [ "NotAShelf <raf@notashelf.dev>" ]
|
||||
|
|
@ -8,19 +9,22 @@ description = "Ergonomic Nix CLI helper"
|
|||
edition = "2024"
|
||||
license = "MPL-2.0"
|
||||
readme = true
|
||||
rust-version = "1.89"
|
||||
version = "0.1.2"
|
||||
rust-version = "1.90"
|
||||
version = "0.1.4"
|
||||
|
||||
[workspace.dependencies]
|
||||
clap = { default-features = false, features = [ "std", "help", "derive" ], version = "4.5.51" }
|
||||
clap_complete = "4.5.60"
|
||||
regex = "1.12.2"
|
||||
tempfile = "3.23.0"
|
||||
thiserror = "2.0.17"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.20"
|
||||
walkdir = "2.5.0"
|
||||
yansi = "1.0.1"
|
||||
clap = { default-features = false, features = [ "std", "help", "derive" ], version = "4.5.56" }
|
||||
clap_complete = "4.5.65"
|
||||
dialoguer = { default-features = false, version = "0.12.0" }
|
||||
regex = "1.12.2"
|
||||
serde_json = "1.0.149"
|
||||
tempfile = "3.24.0"
|
||||
thiserror = "2.0.18"
|
||||
walkdir = "2.5.0"
|
||||
yansi = "1.0.1"
|
||||
|
||||
eh = { path = "./eh" }
|
||||
eh-log = { path = "./crates/eh-log" }
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
|
|
|||
10
crates/eh-log/Cargo.toml
Normal file
10
crates/eh-log/Cargo.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[package]
|
||||
name = "eh-log"
|
||||
description = "Styled logging for eh"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
yansi.workspace = true
|
||||
31
crates/eh-log/src/lib.rs
Normal file
31
crates/eh-log/src/lib.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
use std::fmt;
|
||||
|
||||
use yansi::Paint;
|
||||
|
||||
pub fn info(args: fmt::Arguments) {
|
||||
eprintln!(" {} {args}", "->".green().bold());
|
||||
}
|
||||
|
||||
pub fn warn(args: fmt::Arguments) {
|
||||
eprintln!(" {} {args}", "->".yellow().bold());
|
||||
}
|
||||
|
||||
pub fn error(args: fmt::Arguments) {
|
||||
eprintln!(" {} {args}", "!".red().bold());
|
||||
}
|
||||
|
||||
pub fn hint(args: fmt::Arguments) {
|
||||
eprintln!(" {} {args}", "~".yellow().dim());
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! log_info { ($($t:tt)*) => { $crate::info(format_args!($($t)*)) } }
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! log_warn { ($($t:tt)*) => { $crate::warn(format_args!($($t)*)) } }
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! log_error { ($($t:tt)*) => { $crate::error(format_args!($($t)*)) } }
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! log_hint { ($($t:tt)*) => { $crate::hint(format_args!($($t)*)) } }
|
||||
|
|
@ -11,4 +11,4 @@ publish = false
|
|||
[dependencies]
|
||||
clap.workspace = true
|
||||
clap_complete.workspace = true
|
||||
eh = { path = "../eh" }
|
||||
eh.workspace = true
|
||||
|
|
@ -42,6 +42,7 @@ enum Binary {
|
|||
Nr,
|
||||
Ns,
|
||||
Nb,
|
||||
Nu,
|
||||
}
|
||||
|
||||
impl Binary {
|
||||
|
|
@ -50,6 +51,7 @@ impl Binary {
|
|||
Self::Nr => "nr",
|
||||
Self::Ns => "ns",
|
||||
Self::Nb => "nb",
|
||||
Self::Nu => "nu",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -90,7 +92,7 @@ fn create_multicall_binaries(
|
|||
);
|
||||
}
|
||||
|
||||
let multicall_binaries = [Binary::Nr, Binary::Ns, Binary::Nb];
|
||||
let multicall_binaries = [Binary::Nr, Binary::Ns, Binary::Nb, Binary::Nu];
|
||||
let bin_path = Path::new(bin_dir);
|
||||
|
||||
for binary in multicall_binaries {
|
||||
|
|
@ -153,7 +155,7 @@ fn generate_completions(
|
|||
println!("completion file generated: {}", completion_file.display());
|
||||
|
||||
// Create symlinks for multicall binaries
|
||||
let multicall_names = ["nb", "nr", "ns"];
|
||||
let multicall_names = ["nb", "nr", "ns", "nu"];
|
||||
for name in &multicall_names {
|
||||
let symlink_path = output_dir.join(format!("{name}.{shell}"));
|
||||
if symlink_path.exists() {
|
||||
|
|
@ -11,11 +11,12 @@ crate-type = [ "lib" ]
|
|||
name = "eh"
|
||||
|
||||
[dependencies]
|
||||
clap.workspace = true
|
||||
regex.workspace = true
|
||||
tempfile.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
walkdir.workspace = true
|
||||
yansi.workspace = true
|
||||
clap.workspace = true
|
||||
dialoguer.workspace = true
|
||||
eh-log.workspace = true
|
||||
regex.workspace = true
|
||||
serde_json.workspace = true
|
||||
tempfile.workspace = true
|
||||
thiserror.workspace = true
|
||||
walkdir.workspace = true
|
||||
yansi.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
use std::{
|
||||
collections::VecDeque,
|
||||
io::{self, Read, Write},
|
||||
process::{Command, ExitStatus, Output, Stdio},
|
||||
sync::mpsc,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::error::{EhError, Result};
|
||||
|
|
@ -27,6 +29,43 @@ impl LogInterceptor for StdIoInterceptor {
|
|||
/// Default buffer size for reading command output
|
||||
const DEFAULT_BUFFER_SIZE: usize = 4096;
|
||||
|
||||
/// Default timeout for command execution
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300); // 5 minutes
|
||||
|
||||
enum PipeEvent {
|
||||
Stdout(Vec<u8>),
|
||||
Stderr(Vec<u8>),
|
||||
Error(io::Error),
|
||||
}
|
||||
|
||||
/// Drain a pipe reader, sending chunks through the channel.
|
||||
fn read_pipe<R: Read>(
|
||||
mut reader: R,
|
||||
tx: mpsc::Sender<PipeEvent>,
|
||||
is_stderr: bool,
|
||||
) {
|
||||
let mut buf = [0u8; DEFAULT_BUFFER_SIZE];
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
let event = if is_stderr {
|
||||
PipeEvent::Stderr(buf[..n].to_vec())
|
||||
} else {
|
||||
PipeEvent::Stdout(buf[..n].to_vec())
|
||||
};
|
||||
if tx.send(event).is_err() {
|
||||
break;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let _ = tx.send(PipeEvent::Error(e));
|
||||
break;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder and executor for Nix commands.
|
||||
pub struct NixCommand {
|
||||
subcommand: String,
|
||||
|
|
@ -54,16 +93,6 @@ impl NixCommand {
|
|||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "FIXME")]
|
||||
pub fn args<I, S>(mut self, args: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
self.args.extend(args.into_iter().map(Into::into));
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn args_ref(mut self, args: &[String]) -> Self {
|
||||
self.args.extend(args.iter().cloned());
|
||||
|
|
@ -97,11 +126,9 @@ impl NixCommand {
|
|||
self
|
||||
}
|
||||
|
||||
/// Run the command, streaming output to the provided interceptor.
|
||||
pub fn run_with_logs<I: LogInterceptor + 'static>(
|
||||
&self,
|
||||
mut interceptor: I,
|
||||
) -> Result<ExitStatus> {
|
||||
/// Build the underlying `std::process::Command` with all configured
|
||||
/// arguments, environment variables, and flags.
|
||||
fn build_command(&self) -> Command {
|
||||
let mut cmd = Command::new("nix");
|
||||
cmd.arg(&self.subcommand);
|
||||
|
||||
|
|
@ -117,6 +144,18 @@ impl NixCommand {
|
|||
cmd.env(k, v);
|
||||
}
|
||||
cmd.args(&self.args);
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Run the command, streaming output to the provided interceptor.
|
||||
///
|
||||
/// Stdout and stderr are read concurrently using background threads
|
||||
/// so that neither pipe blocks the other.
|
||||
pub fn run_with_logs<I: LogInterceptor + 'static>(
|
||||
&self,
|
||||
mut interceptor: I,
|
||||
) -> Result<ExitStatus> {
|
||||
let mut cmd = self.build_command();
|
||||
|
||||
if self.interactive {
|
||||
cmd.stdout(Stdio::inherit());
|
||||
|
|
@ -129,86 +168,152 @@ impl NixCommand {
|
|||
cmd.stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn()?;
|
||||
let child_stdout = child.stdout.take().ok_or_else(|| {
|
||||
let stdout = child.stdout.take().ok_or_else(|| {
|
||||
EhError::CommandFailed {
|
||||
command: format!("nix {}", self.subcommand),
|
||||
}
|
||||
})?;
|
||||
let child_stderr = child.stderr.take().ok_or_else(|| {
|
||||
let stderr = child.stderr.take().ok_or_else(|| {
|
||||
EhError::CommandFailed {
|
||||
command: format!("nix {}", self.subcommand),
|
||||
}
|
||||
})?;
|
||||
let mut stdout = child_stdout;
|
||||
let mut stderr = child_stderr;
|
||||
|
||||
let mut out_buf = [0u8; DEFAULT_BUFFER_SIZE];
|
||||
let mut err_buf = [0u8; DEFAULT_BUFFER_SIZE];
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let mut out_queue = VecDeque::new();
|
||||
let mut err_queue = VecDeque::new();
|
||||
let tx_out = tx.clone();
|
||||
let stdout_thread = thread::spawn(move || read_pipe(stdout, tx_out, false));
|
||||
|
||||
let tx_err = tx;
|
||||
let stderr_thread = thread::spawn(move || read_pipe(stderr, tx_err, true));
|
||||
|
||||
let start_time = Instant::now();
|
||||
|
||||
loop {
|
||||
let mut did_something = false;
|
||||
|
||||
match stdout.read(&mut out_buf) {
|
||||
Ok(0) => {},
|
||||
Ok(n) => {
|
||||
interceptor.on_stdout(&out_buf[..n]);
|
||||
out_queue.push_back(Vec::from(&out_buf[..n]));
|
||||
did_something = true;
|
||||
},
|
||||
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {},
|
||||
Err(e) => return Err(EhError::Io(e)),
|
||||
if start_time.elapsed() > DEFAULT_TIMEOUT {
|
||||
let _ = child.kill();
|
||||
let _ = stdout_thread.join();
|
||||
let _ = stderr_thread.join();
|
||||
let _ = child.wait();
|
||||
return Err(EhError::Timeout {
|
||||
command: format!("nix {}", self.subcommand),
|
||||
duration: DEFAULT_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
match stderr.read(&mut err_buf) {
|
||||
Ok(0) => {},
|
||||
Ok(n) => {
|
||||
interceptor.on_stderr(&err_buf[..n]);
|
||||
err_queue.push_back(Vec::from(&err_buf[..n]));
|
||||
did_something = true;
|
||||
match rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(PipeEvent::Stdout(data)) => interceptor.on_stdout(&data),
|
||||
Ok(PipeEvent::Stderr(data)) => interceptor.on_stderr(&data),
|
||||
Ok(PipeEvent::Error(e)) => {
|
||||
let _ = child.kill();
|
||||
let _ = stdout_thread.join();
|
||||
let _ = stderr_thread.join();
|
||||
let _ = child.wait();
|
||||
return Err(EhError::Io(e));
|
||||
},
|
||||
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {},
|
||||
Err(e) => return Err(EhError::Io(e)),
|
||||
}
|
||||
|
||||
if !did_something && child.try_wait()?.is_some() {
|
||||
break;
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {},
|
||||
// All senders dropped — both reader threads finished
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
|
||||
let _ = stdout_thread.join();
|
||||
let _ = stderr_thread.join();
|
||||
|
||||
let status = child.wait()?;
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
/// Run the command and capture all output.
|
||||
/// Run the command and capture all output (with timeout).
|
||||
pub fn output(&self) -> Result<Output> {
|
||||
let mut cmd = Command::new("nix");
|
||||
cmd.arg(&self.subcommand);
|
||||
|
||||
if self.print_build_logs
|
||||
&& !self.args.iter().any(|a| a == "--no-build-output")
|
||||
{
|
||||
cmd.arg("--print-build-logs");
|
||||
}
|
||||
if self.impure {
|
||||
cmd.arg("--impure");
|
||||
}
|
||||
for (k, v) in &self.env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
cmd.args(&self.args);
|
||||
let mut cmd = self.build_command();
|
||||
|
||||
if self.interactive {
|
||||
cmd.stdout(Stdio::inherit());
|
||||
cmd.stderr(Stdio::inherit());
|
||||
cmd.stdin(Stdio::inherit());
|
||||
} else {
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
return Ok(cmd.output()?);
|
||||
}
|
||||
|
||||
Ok(cmd.output()?)
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn()?;
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let tx_out = tx.clone();
|
||||
let stdout_thread = thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
if let Some(mut r) = stdout {
|
||||
let _ = r.read_to_end(&mut buf);
|
||||
}
|
||||
let _ = tx_out.send((false, buf));
|
||||
});
|
||||
|
||||
let tx_err = tx;
|
||||
let stderr_thread = thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
if let Some(mut r) = stderr {
|
||||
let _ = r.read_to_end(&mut buf);
|
||||
}
|
||||
let _ = tx_err.send((true, buf));
|
||||
});
|
||||
|
||||
let start_time = Instant::now();
|
||||
let mut stdout_buf = Vec::new();
|
||||
let mut stderr_buf = Vec::new();
|
||||
let mut received = 0;
|
||||
|
||||
while received < 2 {
|
||||
let remaining = DEFAULT_TIMEOUT
|
||||
.checked_sub(start_time.elapsed())
|
||||
.unwrap_or(Duration::ZERO);
|
||||
|
||||
if remaining.is_zero() {
|
||||
let _ = child.kill();
|
||||
let _ = stdout_thread.join();
|
||||
let _ = stderr_thread.join();
|
||||
let _ = child.wait();
|
||||
return Err(EhError::Timeout {
|
||||
command: format!("nix {}", self.subcommand),
|
||||
duration: DEFAULT_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
match rx.recv_timeout(remaining) {
|
||||
Ok((true, buf)) => {
|
||||
stderr_buf = buf;
|
||||
received += 1;
|
||||
},
|
||||
Ok((false, buf)) => {
|
||||
stdout_buf = buf;
|
||||
received += 1;
|
||||
},
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
let _ = child.kill();
|
||||
let _ = stdout_thread.join();
|
||||
let _ = stderr_thread.join();
|
||||
let _ = child.wait();
|
||||
return Err(EhError::Timeout {
|
||||
command: format!("nix {}", self.subcommand),
|
||||
duration: DEFAULT_TIMEOUT,
|
||||
});
|
||||
},
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
|
||||
let _ = stdout_thread.join();
|
||||
let _ = stderr_thread.join();
|
||||
|
||||
let status = child.wait()?;
|
||||
Ok(Output {
|
||||
status,
|
||||
stdout: stdout_buf,
|
||||
stderr: stderr_buf,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
253
eh/src/error.rs
253
eh/src/error.rs
|
|
@ -1,36 +1,59 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum EhError {
|
||||
#[error("Nix command failed: {0}")]
|
||||
NixCommandFailed(String),
|
||||
#[error("nix {command} failed")]
|
||||
NixCommandFailed { command: String },
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Regex error: {0}")]
|
||||
#[error("regex: {0}")]
|
||||
Regex(#[from] regex::Error),
|
||||
|
||||
#[error("UTF-8 conversion error: {0}")]
|
||||
#[error("utf-8 conversion: {0}")]
|
||||
Utf8(#[from] std::string::FromUtf8Error),
|
||||
|
||||
#[error("Hash extraction failed")]
|
||||
HashExtractionFailed,
|
||||
#[error("could not extract hash from nix output")]
|
||||
HashExtractionFailed { stderr: String },
|
||||
|
||||
#[error("No Nix files found")]
|
||||
#[error("no .nix files found in the current directory")]
|
||||
NoNixFilesFound,
|
||||
|
||||
#[error("Failed to fix hash in file: {path}")]
|
||||
#[error("could not update hash in {path}")]
|
||||
HashFixFailed { path: String },
|
||||
|
||||
#[error("Process exited with code: {code}")]
|
||||
#[error("process exited with code {code}")]
|
||||
ProcessExit { code: i32 },
|
||||
|
||||
#[error("Command execution failed: {command}")]
|
||||
#[error("command '{command}' failed")]
|
||||
CommandFailed { command: String },
|
||||
|
||||
#[error("Invalid input: {input} - {reason}")]
|
||||
#[error("nix {command} timed out after {} seconds", duration.as_secs())]
|
||||
Timeout {
|
||||
command: String,
|
||||
duration: Duration,
|
||||
},
|
||||
|
||||
#[error("'{expression}' failed to evaluate: {stderr}")]
|
||||
PreEvalFailed {
|
||||
expression: String,
|
||||
stderr: String,
|
||||
},
|
||||
|
||||
#[error("invalid input '{input}': {reason}")]
|
||||
InvalidInput { input: String, reason: String },
|
||||
|
||||
#[error("failed to parse JSON from nix output: {detail}")]
|
||||
JsonParse { detail: String },
|
||||
|
||||
#[error("no flake inputs found in lock file")]
|
||||
NoFlakeInputs,
|
||||
|
||||
#[error("no inputs selected")]
|
||||
UpdateCancelled,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, EhError>;
|
||||
|
|
@ -40,9 +63,209 @@ impl EhError {
|
|||
pub const fn exit_code(&self) -> i32 {
|
||||
match self {
|
||||
Self::ProcessExit { code } => *code,
|
||||
Self::NixCommandFailed(_) => 1,
|
||||
Self::CommandFailed { .. } => 1,
|
||||
_ => 1,
|
||||
Self::NixCommandFailed { .. } => 2,
|
||||
Self::CommandFailed { .. } => 3,
|
||||
Self::HashExtractionFailed { .. } => 4,
|
||||
Self::NoNixFilesFound => 5,
|
||||
Self::HashFixFailed { .. } => 6,
|
||||
Self::InvalidInput { .. } => 7,
|
||||
Self::Io(_) => 8,
|
||||
Self::Regex(_) => 9,
|
||||
Self::Utf8(_) => 10,
|
||||
Self::Timeout { .. } => 11,
|
||||
Self::PreEvalFailed { .. } => 12,
|
||||
Self::JsonParse { .. } => 13,
|
||||
Self::NoFlakeInputs => 14,
|
||||
Self::UpdateCancelled => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn hint(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::NixCommandFailed { .. } => {
|
||||
Some("run with --show-trace for more details")
|
||||
},
|
||||
Self::PreEvalFailed { .. } => {
|
||||
Some("check that the expression exists and is spelled correctly")
|
||||
},
|
||||
Self::HashExtractionFailed { .. } => {
|
||||
Some("nix reported a hash mismatch but the hash could not be parsed")
|
||||
},
|
||||
Self::NoNixFilesFound => {
|
||||
Some("run this command from a directory containing .nix files")
|
||||
},
|
||||
Self::Timeout { .. } => {
|
||||
Some(
|
||||
"the command took too long; try a faster network or a smaller \
|
||||
derivation",
|
||||
)
|
||||
},
|
||||
Self::InvalidInput { .. } => {
|
||||
Some("avoid shell metacharacters in nix arguments")
|
||||
},
|
||||
Self::JsonParse { .. } => {
|
||||
Some("ensure 'nix flake metadata --json' produces valid output")
|
||||
},
|
||||
Self::NoFlakeInputs => {
|
||||
Some("run this from a directory with a flake.lock that has inputs")
|
||||
},
|
||||
Self::Io(_)
|
||||
| Self::Regex(_)
|
||||
| Self::Utf8(_)
|
||||
| Self::HashFixFailed { .. }
|
||||
| Self::ProcessExit { .. }
|
||||
| Self::CommandFailed { .. }
|
||||
| Self::UpdateCancelled => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_exit_codes() {
|
||||
assert_eq!(
|
||||
EhError::NixCommandFailed {
|
||||
command: "build".into(),
|
||||
}
|
||||
.exit_code(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
EhError::CommandFailed {
|
||||
command: "x".into(),
|
||||
}
|
||||
.exit_code(),
|
||||
3
|
||||
);
|
||||
assert_eq!(
|
||||
EhError::HashExtractionFailed {
|
||||
stderr: String::new(),
|
||||
}
|
||||
.exit_code(),
|
||||
4
|
||||
);
|
||||
assert_eq!(EhError::NoNixFilesFound.exit_code(), 5);
|
||||
assert_eq!(EhError::HashFixFailed { path: "x".into() }.exit_code(), 6);
|
||||
assert_eq!(
|
||||
EhError::InvalidInput {
|
||||
input: "x".into(),
|
||||
reason: "y".into(),
|
||||
}
|
||||
.exit_code(),
|
||||
7
|
||||
);
|
||||
assert_eq!(
|
||||
EhError::Timeout {
|
||||
command: "build".into(),
|
||||
duration: Duration::from_secs(300),
|
||||
}
|
||||
.exit_code(),
|
||||
11
|
||||
);
|
||||
assert_eq!(
|
||||
EhError::PreEvalFailed {
|
||||
expression: "x".into(),
|
||||
stderr: "y".into(),
|
||||
}
|
||||
.exit_code(),
|
||||
12
|
||||
);
|
||||
assert_eq!(EhError::ProcessExit { code: 42 }.exit_code(), 42);
|
||||
assert_eq!(
|
||||
EhError::JsonParse {
|
||||
detail: "x".into(),
|
||||
}
|
||||
.exit_code(),
|
||||
13
|
||||
);
|
||||
assert_eq!(EhError::NoFlakeInputs.exit_code(), 14);
|
||||
assert_eq!(EhError::UpdateCancelled.exit_code(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_messages() {
|
||||
let err = EhError::Timeout {
|
||||
command: "build".into(),
|
||||
duration: Duration::from_secs(300),
|
||||
};
|
||||
assert_eq!(err.to_string(), "nix build timed out after 300 seconds");
|
||||
|
||||
let err = EhError::PreEvalFailed {
|
||||
expression: "nixpkgs#hello".into(),
|
||||
stderr: "attribute not found".into(),
|
||||
};
|
||||
assert!(err.to_string().contains("nixpkgs#hello"));
|
||||
assert!(err.to_string().contains("attribute not found"));
|
||||
|
||||
let err = EhError::HashExtractionFailed {
|
||||
stderr: "some output".into(),
|
||||
};
|
||||
assert!(err.to_string().contains("could not extract hash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hints() {
|
||||
assert!(
|
||||
EhError::PreEvalFailed {
|
||||
expression: "x".into(),
|
||||
stderr: "y".into(),
|
||||
}
|
||||
.hint()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
EhError::HashExtractionFailed {
|
||||
stderr: String::new(),
|
||||
}
|
||||
.hint()
|
||||
.is_some()
|
||||
);
|
||||
assert!(EhError::NoNixFilesFound.hint().is_some());
|
||||
assert!(
|
||||
EhError::Timeout {
|
||||
command: "x".into(),
|
||||
duration: Duration::from_secs(1),
|
||||
}
|
||||
.hint()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
EhError::InvalidInput {
|
||||
input: "x".into(),
|
||||
reason: "y".into(),
|
||||
}
|
||||
.hint()
|
||||
.is_some()
|
||||
);
|
||||
// Variants with hints
|
||||
assert!(
|
||||
EhError::NixCommandFailed {
|
||||
command: "build".into(),
|
||||
}
|
||||
.hint()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
EhError::JsonParse {
|
||||
detail: "x".into(),
|
||||
}
|
||||
.hint()
|
||||
.is_some()
|
||||
);
|
||||
assert!(EhError::NoFlakeInputs.hint().is_some());
|
||||
// Variants without hints
|
||||
assert!(
|
||||
EhError::CommandFailed {
|
||||
command: "x".into(),
|
||||
}
|
||||
.hint()
|
||||
.is_none()
|
||||
);
|
||||
assert!(EhError::ProcessExit { code: 1 }.hint().is_none());
|
||||
assert!(EhError::UpdateCancelled.hint().is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ pub mod command;
|
|||
pub mod error;
|
||||
pub mod run;
|
||||
pub mod shell;
|
||||
pub mod update;
|
||||
pub mod util;
|
||||
|
||||
pub use clap::{CommandFactory, Parser, Subcommand};
|
||||
|
|
@ -34,4 +35,9 @@ pub enum Command {
|
|||
#[arg(trailing_var_arg = true)]
|
||||
args: Vec<String>,
|
||||
},
|
||||
/// Update flake inputs interactively
|
||||
Update {
|
||||
#[arg(trailing_var_arg = true)]
|
||||
args: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,30 +2,30 @@ use std::{env, path::Path};
|
|||
|
||||
use eh::{Cli, Command, CommandFactory, Parser};
|
||||
use error::Result;
|
||||
use yansi::Paint;
|
||||
|
||||
mod build;
|
||||
mod command;
|
||||
mod error;
|
||||
mod run;
|
||||
mod shell;
|
||||
mod update;
|
||||
mod util;
|
||||
|
||||
fn main() {
|
||||
let format = tracing_subscriber::fmt::format()
|
||||
.with_level(true) // don't include levels in formatted output
|
||||
.with_target(true) // don't include targets
|
||||
.with_thread_ids(false) // include the thread ID of the current thread
|
||||
.with_thread_names(false) // include the name of the current thread
|
||||
.compact(); // use the `Compact` formatting style.
|
||||
tracing_subscriber::fmt().event_format(format).init();
|
||||
|
||||
let result = run_app();
|
||||
|
||||
match result {
|
||||
Ok(code) => std::process::exit(code),
|
||||
Err(e) => {
|
||||
eprintln!("Error: {e}");
|
||||
std::process::exit(e.exit_code());
|
||||
let code = e.exit_code();
|
||||
if code != 0 {
|
||||
eh_log::log_error!("{e}");
|
||||
if let Some(hint) = e.hint() {
|
||||
eh_log::log_hint!("{hint}");
|
||||
}
|
||||
}
|
||||
std::process::exit(code);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -36,43 +36,55 @@ fn dispatch_multicall(
|
|||
args: std::env::Args,
|
||||
) -> Option<Result<i32>> {
|
||||
let rest: Vec<String> = args.collect();
|
||||
|
||||
// Validate arguments before processing
|
||||
if let Err(e) = util::validate_nix_args(&rest) {
|
||||
return Some(Err(e));
|
||||
|
||||
let subcommand = match app_name {
|
||||
"nr" => "run",
|
||||
"ns" => "shell",
|
||||
"nb" => "build",
|
||||
"nu" => "update",
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
// Handle --help/-h/--version before forwarding to nix
|
||||
if rest.iter().any(|a| a == "--help" || a == "-h") {
|
||||
eprintln!(
|
||||
"{}: shorthand for '{}'",
|
||||
app_name.bold(),
|
||||
format!("eh {subcommand}").bold()
|
||||
);
|
||||
eprintln!(" {} {app_name} [args...]", "usage:".green().bold());
|
||||
eprintln!(
|
||||
" All arguments are forwarded to '{}'.",
|
||||
format!("nix {subcommand}").dim()
|
||||
);
|
||||
return Some(Ok(0));
|
||||
}
|
||||
|
||||
|
||||
if rest.iter().any(|a| a == "--version") {
|
||||
eprintln!("{} (eh {})", app_name.bold(), env!("CARGO_PKG_VERSION"));
|
||||
return Some(Ok(0));
|
||||
}
|
||||
|
||||
if subcommand == "update" {
|
||||
return Some(update::handle_update(&rest));
|
||||
}
|
||||
|
||||
let hash_extractor = util::RegexHashExtractor;
|
||||
let fixer = util::DefaultNixFileFixer;
|
||||
let classifier = util::DefaultNixErrorClassifier;
|
||||
|
||||
match app_name {
|
||||
"nr" => {
|
||||
Some(run::handle_nix_run(
|
||||
&rest,
|
||||
&hash_extractor,
|
||||
&fixer,
|
||||
&classifier,
|
||||
))
|
||||
Some(match subcommand {
|
||||
"run" => run::handle_nix_run(&rest, &hash_extractor, &fixer, &classifier),
|
||||
"shell" => {
|
||||
shell::handle_nix_shell(&rest, &hash_extractor, &fixer, &classifier)
|
||||
},
|
||||
"ns" => {
|
||||
Some(shell::handle_nix_shell(
|
||||
&rest,
|
||||
&hash_extractor,
|
||||
&fixer,
|
||||
&classifier,
|
||||
))
|
||||
"build" => {
|
||||
build::handle_nix_build(&rest, &hash_extractor, &fixer, &classifier)
|
||||
},
|
||||
"nb" => {
|
||||
Some(build::handle_nix_build(
|
||||
&rest,
|
||||
&hash_extractor,
|
||||
&fixer,
|
||||
&classifier,
|
||||
))
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
// subcommand is assigned from the match on app_name above;
|
||||
// only "run"/"shell"/"build" are possible values.
|
||||
_ => unreachable!(),
|
||||
})
|
||||
}
|
||||
|
||||
fn run_app() -> Result<i32> {
|
||||
|
|
@ -107,7 +119,9 @@ fn run_app() -> Result<i32> {
|
|||
build::handle_nix_build(&args, &hash_extractor, &fixer, &classifier)
|
||||
},
|
||||
|
||||
_ => {
|
||||
Some(Command::Update { args }) => update::handle_update(&args),
|
||||
|
||||
None => {
|
||||
Cli::command().print_help()?;
|
||||
println!();
|
||||
Ok(0)
|
||||
|
|
|
|||
115
eh/src/update.rs
Normal file
115
eh/src/update.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
use crate::{
|
||||
command::{NixCommand, StdIoInterceptor},
|
||||
error::{EhError, Result},
|
||||
};
|
||||
|
||||
/// Parse flake input names from `nix flake metadata --json` output.
|
||||
pub fn parse_flake_inputs(stdout: &str) -> Result<Vec<String>> {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(stdout).map_err(|e| EhError::JsonParse {
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
|
||||
let inputs = value
|
||||
.get("locks")
|
||||
.and_then(|l| l.get("nodes"))
|
||||
.and_then(|n| n.get("root"))
|
||||
.and_then(|r| r.get("inputs"))
|
||||
.and_then(|i| i.as_object())
|
||||
.ok_or(EhError::NoFlakeInputs)?;
|
||||
|
||||
let mut names: Vec<String> = inputs.keys().cloned().collect();
|
||||
names.sort();
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Fetch flake input names by running `nix flake metadata --json`.
|
||||
fn fetch_flake_inputs() -> Result<Vec<String>> {
|
||||
let output = NixCommand::new("flake")
|
||||
.arg("metadata")
|
||||
.arg("--json")
|
||||
.print_build_logs(false)
|
||||
.output()?;
|
||||
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
parse_flake_inputs(&stdout)
|
||||
}
|
||||
|
||||
/// Prompt the user to select inputs via a multi-select dialog.
|
||||
fn prompt_input_selection(inputs: &[String]) -> Result<Vec<String>> {
|
||||
let selections = dialoguer::MultiSelect::new()
|
||||
.with_prompt("Select inputs to update")
|
||||
.items(inputs)
|
||||
.interact()
|
||||
.map_err(|e| EhError::Io(std::io::Error::other(e)))?;
|
||||
|
||||
if selections.is_empty() {
|
||||
return Err(EhError::UpdateCancelled);
|
||||
}
|
||||
|
||||
Ok(selections.iter().map(|&i| inputs[i].clone()).collect())
|
||||
}
|
||||
|
||||
/// Entry point for the `update` subcommand.
|
||||
///
|
||||
/// If `args` is non-empty, use them as explicit input names.
|
||||
/// Otherwise, fetch inputs interactively and prompt for selection.
|
||||
pub fn handle_update(args: &[String]) -> Result<i32> {
|
||||
let selected = if args.is_empty() {
|
||||
let inputs = fetch_flake_inputs()?;
|
||||
if inputs.is_empty() {
|
||||
return Err(EhError::NoFlakeInputs);
|
||||
}
|
||||
prompt_input_selection(&inputs)?
|
||||
} else {
|
||||
args.to_vec()
|
||||
};
|
||||
|
||||
let mut cmd = NixCommand::new("flake").arg("lock");
|
||||
for name in &selected {
|
||||
cmd = cmd.arg("--update-input").arg(name);
|
||||
}
|
||||
|
||||
eh_log::log_info!("updating inputs: {}", selected.join(", "));
|
||||
|
||||
let status = cmd.run_with_logs(StdIoInterceptor)?;
|
||||
Ok(status.code().unwrap_or(1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_flake_inputs() {
|
||||
let json = r#"{
|
||||
"locks": {
|
||||
"nodes": {
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"home-manager": "home-manager_2",
|
||||
"flake-utils": "flake-utils_2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let inputs = parse_flake_inputs(json).unwrap();
|
||||
assert_eq!(inputs, vec!["flake-utils", "home-manager", "nixpkgs"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_flake_inputs_invalid_json() {
|
||||
let result = parse_flake_inputs("not json");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_flake_inputs_no_inputs() {
|
||||
let json = r#"{"locks": {"nodes": {"root": {}}}}"#;
|
||||
let result = parse_flake_inputs(json);
|
||||
assert!(matches!(result, Err(EhError::NoFlakeInputs)));
|
||||
}
|
||||
}
|
||||
637
eh/src/util.rs
637
eh/src/util.rs
|
|
@ -1,11 +1,12 @@
|
|||
use std::{
|
||||
io::{BufWriter, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::LazyLock,
|
||||
};
|
||||
|
||||
use eh_log::{log_info, log_warn};
|
||||
use regex::Regex;
|
||||
use tempfile::NamedTempFile;
|
||||
use tracing::{info, warn};
|
||||
use walkdir::WalkDir;
|
||||
use yansi::Paint;
|
||||
|
||||
|
|
@ -14,22 +15,41 @@ use crate::{
|
|||
error::{EhError, Result},
|
||||
};
|
||||
|
||||
/// Compiled regex patterns for extracting the actual hash from nix stderr.
|
||||
static HASH_EXTRACT_PATTERNS: LazyLock<[Regex; 3]> = LazyLock::new(|| {
|
||||
[
|
||||
Regex::new(r"got:\s+(sha256-[a-zA-Z0-9+/=]+)").unwrap(),
|
||||
Regex::new(r"actual:\s+(sha256-[a-zA-Z0-9+/=]+)").unwrap(),
|
||||
Regex::new(r"have:\s+(sha256-[a-zA-Z0-9+/=]+)").unwrap(),
|
||||
]
|
||||
});
|
||||
|
||||
/// Compiled regex pattern for extracting the old (specified) hash from nix
|
||||
/// stderr.
|
||||
static HASH_OLD_EXTRACT_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"specified:\s+(sha256-[a-zA-Z0-9+/=]+)").unwrap()
|
||||
});
|
||||
|
||||
/// Compiled regex patterns for matching hash attributes in .nix files.
|
||||
static HASH_FIX_PATTERNS: LazyLock<[Regex; 3]> = LazyLock::new(|| {
|
||||
[
|
||||
Regex::new(r#"hash\s*=\s*"[^"]*""#).unwrap(),
|
||||
Regex::new(r#"sha256\s*=\s*"[^"]*""#).unwrap(),
|
||||
Regex::new(r#"outputHash\s*=\s*"[^"]*""#).unwrap(),
|
||||
]
|
||||
});
|
||||
|
||||
pub trait HashExtractor {
|
||||
fn extract_hash(&self, stderr: &str) -> Option<String>;
|
||||
fn extract_old_hash(&self, stderr: &str) -> Option<String>;
|
||||
}
|
||||
|
||||
pub struct RegexHashExtractor;
|
||||
|
||||
impl HashExtractor for RegexHashExtractor {
|
||||
fn extract_hash(&self, stderr: &str) -> Option<String> {
|
||||
let patterns = [
|
||||
r"got:\s+(sha256-[a-zA-Z0-9+/=]+)",
|
||||
r"actual:\s+(sha256-[a-zA-Z0-9+/=]+)",
|
||||
r"have:\s+(sha256-[a-zA-Z0-9+/=]+)",
|
||||
];
|
||||
for pattern in &patterns {
|
||||
if let Ok(re) = Regex::new(pattern)
|
||||
&& let Some(captures) = re.captures(stderr)
|
||||
for re in HASH_EXTRACT_PATTERNS.iter() {
|
||||
if let Some(captures) = re.captures(stderr)
|
||||
&& let Some(hash) = captures.get(1)
|
||||
{
|
||||
return Some(hash.as_str().to_string());
|
||||
|
|
@ -37,23 +57,43 @@ impl HashExtractor for RegexHashExtractor {
|
|||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_old_hash(&self, stderr: &str) -> Option<String> {
|
||||
HASH_OLD_EXTRACT_PATTERN
|
||||
.captures(stderr)
|
||||
.and_then(|c| c.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait NixFileFixer {
|
||||
fn fix_hash_in_files(&self, new_hash: &str) -> Result<bool>;
|
||||
fn fix_hash_in_files(
|
||||
&self,
|
||||
old_hash: Option<&str>,
|
||||
new_hash: &str,
|
||||
) -> Result<bool>;
|
||||
fn find_nix_files(&self) -> Result<Vec<PathBuf>>;
|
||||
fn fix_hash_in_file(&self, file_path: &Path, new_hash: &str) -> Result<bool>;
|
||||
fn fix_hash_in_file(
|
||||
&self,
|
||||
file_path: &Path,
|
||||
old_hash: Option<&str>,
|
||||
new_hash: &str,
|
||||
) -> Result<bool>;
|
||||
}
|
||||
|
||||
pub struct DefaultNixFileFixer;
|
||||
|
||||
impl NixFileFixer for DefaultNixFileFixer {
|
||||
fn fix_hash_in_files(&self, new_hash: &str) -> Result<bool> {
|
||||
fn fix_hash_in_files(
|
||||
&self,
|
||||
old_hash: Option<&str>,
|
||||
new_hash: &str,
|
||||
) -> Result<bool> {
|
||||
let nix_files = self.find_nix_files()?;
|
||||
let mut fixed = false;
|
||||
for file_path in nix_files {
|
||||
if self.fix_hash_in_file(&file_path, new_hash)? {
|
||||
println!("Updated hash in {}", file_path.display());
|
||||
if self.fix_hash_in_file(&file_path, old_hash, new_hash)? {
|
||||
log_info!("updated hash in {}", file_path.display().bold());
|
||||
fixed = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -61,8 +101,20 @@ impl NixFileFixer for DefaultNixFileFixer {
|
|||
}
|
||||
|
||||
fn find_nix_files(&self) -> Result<Vec<PathBuf>> {
|
||||
let should_skip = |entry: &walkdir::DirEntry| -> bool {
|
||||
// Never skip the root entry, otherwise the entire walk is empty
|
||||
if entry.depth() == 0 || !entry.file_type().is_dir() {
|
||||
return false;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy();
|
||||
name.starts_with('.')
|
||||
|| matches!(name.as_ref(), "node_modules" | "target" | "result")
|
||||
};
|
||||
|
||||
let files: Vec<PathBuf> = WalkDir::new(".")
|
||||
.max_depth(3)
|
||||
.into_iter()
|
||||
.filter_entry(|e| !should_skip(e))
|
||||
.filter_map(std::result::Result::ok)
|
||||
.filter(|entry| {
|
||||
entry.file_type().is_file()
|
||||
|
|
@ -80,39 +132,59 @@ impl NixFileFixer for DefaultNixFileFixer {
|
|||
Ok(files)
|
||||
}
|
||||
|
||||
fn fix_hash_in_file(&self, file_path: &Path, new_hash: &str) -> Result<bool> {
|
||||
// Pre-compile regex patterns once to avoid repeated compilation
|
||||
let patterns: Vec<(Regex, String)> = [
|
||||
(r#"hash\s*=\s*"[^"]*""#, format!(r#"hash = "{new_hash}""#)),
|
||||
(
|
||||
r#"sha256\s*=\s*"[^"]*""#,
|
||||
format!(r#"sha256 = "{new_hash}""#),
|
||||
),
|
||||
(
|
||||
r#"outputHash\s*=\s*"[^"]*""#,
|
||||
format!(r#"outputHash = "{new_hash}""#),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(pattern, replacement)| {
|
||||
Regex::new(pattern)
|
||||
.map(|re| (re, replacement))
|
||||
.map_err(EhError::Regex)
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
||||
fn fix_hash_in_file(
|
||||
&self,
|
||||
file_path: &Path,
|
||||
old_hash: Option<&str>,
|
||||
new_hash: &str,
|
||||
) -> Result<bool> {
|
||||
// Read the entire file content
|
||||
let content = std::fs::read_to_string(file_path)?;
|
||||
let mut replaced = false;
|
||||
let mut result_content = content;
|
||||
|
||||
// Apply replacements
|
||||
for (re, replacement) in &patterns {
|
||||
if re.is_match(&result_content) {
|
||||
result_content = re
|
||||
.replace_all(&result_content, replacement.as_str())
|
||||
.into_owned();
|
||||
replaced = true;
|
||||
if let Some(old) = old_hash {
|
||||
// Targeted replacement: only replace attributes whose value matches the
|
||||
// old hash. Uses regexes to handle variable whitespace around `=`.
|
||||
let old_escaped = regex::escape(old);
|
||||
let targeted_patterns = [
|
||||
(
|
||||
Regex::new(&format!(r#"hash\s*=\s*"{old_escaped}""#)).unwrap(),
|
||||
format!(r#"hash = "{new_hash}""#),
|
||||
),
|
||||
(
|
||||
Regex::new(&format!(r#"sha256\s*=\s*"{old_escaped}""#)).unwrap(),
|
||||
format!(r#"sha256 = "{new_hash}""#),
|
||||
),
|
||||
(
|
||||
Regex::new(&format!(r#"outputHash\s*=\s*"{old_escaped}""#)).unwrap(),
|
||||
format!(r#"outputHash = "{new_hash}""#),
|
||||
),
|
||||
];
|
||||
|
||||
for (re, replacement) in &targeted_patterns {
|
||||
if re.is_match(&result_content) {
|
||||
result_content = re
|
||||
.replace_all(&result_content, replacement.as_str())
|
||||
.into_owned();
|
||||
replaced = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: replace all hash attributes (original behavior)
|
||||
let replacements = [
|
||||
format!(r#"hash = "{new_hash}""#),
|
||||
format!(r#"sha256 = "{new_hash}""#),
|
||||
format!(r#"outputHash = "{new_hash}""#),
|
||||
];
|
||||
|
||||
for (re, replacement) in HASH_FIX_PATTERNS.iter().zip(&replacements) {
|
||||
if re.is_match(&result_content) {
|
||||
result_content = re
|
||||
.replace_all(&result_content, replacement.as_str())
|
||||
.into_owned();
|
||||
replaced = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -140,38 +212,121 @@ pub trait NixErrorClassifier {
|
|||
fn should_retry(&self, stderr: &str) -> bool;
|
||||
}
|
||||
|
||||
/// Pre-evaluate expression to catch errors early
|
||||
fn pre_evaluate(_subcommand: &str, args: &[String]) -> Result<bool> {
|
||||
/// Classifies what retry action should be taken based on nix stderr output.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum RetryAction {
|
||||
AllowUnfree,
|
||||
AllowInsecure,
|
||||
AllowBroken,
|
||||
None,
|
||||
}
|
||||
|
||||
impl RetryAction {
|
||||
/// Returns `(env_var, reason)` for this retry action,
|
||||
/// or `None` if no retry is needed.
|
||||
fn env_override(&self) -> Option<(&str, &str)> {
|
||||
match self {
|
||||
Self::AllowUnfree => {
|
||||
Some(("NIXPKGS_ALLOW_UNFREE", "has an unfree license"))
|
||||
},
|
||||
Self::AllowInsecure => {
|
||||
Some(("NIXPKGS_ALLOW_INSECURE", "has been marked as insecure"))
|
||||
},
|
||||
Self::AllowBroken => {
|
||||
Some(("NIXPKGS_ALLOW_BROKEN", "has been marked as broken"))
|
||||
},
|
||||
Self::None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the package/expression name from args (first non-flag argument).
|
||||
fn package_name(args: &[String]) -> &str {
|
||||
args
|
||||
.iter()
|
||||
.find(|a| !a.starts_with('-'))
|
||||
.map(String::as_str)
|
||||
.unwrap_or("<unknown>")
|
||||
}
|
||||
|
||||
/// Print a retry message with consistent formatting.
|
||||
/// Format: ` -> <pkg>: <reason>, setting <ENV>=1`
|
||||
fn print_retry_msg(pkg: &str, reason: &str, env_var: &str) {
|
||||
log_warn!(
|
||||
"{}: {}, setting {}",
|
||||
pkg.bold(),
|
||||
reason,
|
||||
format!("{env_var}=1").bold()
|
||||
);
|
||||
}
|
||||
|
||||
/// Classify stderr into a retry action.
|
||||
pub fn classify_retry_action(stderr: &str) -> RetryAction {
|
||||
if stderr.contains("has an unfree license") && stderr.contains("refusing") {
|
||||
RetryAction::AllowUnfree
|
||||
} else if stderr.contains("has been marked as insecure")
|
||||
&& stderr.contains("refusing")
|
||||
{
|
||||
RetryAction::AllowInsecure
|
||||
} else if stderr.contains("has been marked as broken")
|
||||
&& stderr.contains("refusing")
|
||||
{
|
||||
RetryAction::AllowBroken
|
||||
} else {
|
||||
RetryAction::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if stderr looks like a genuine hash mismatch error
|
||||
/// (not just any mention of "hash" or "sha256").
|
||||
fn is_hash_mismatch_error(stderr: &str) -> bool {
|
||||
stderr.contains("hash mismatch")
|
||||
|| (stderr.contains("specified:") && stderr.contains("got:"))
|
||||
}
|
||||
|
||||
/// Pre-evaluate expression to catch errors early.
|
||||
///
|
||||
/// Returns a `RetryAction` if the evaluation fails with a retryable error
|
||||
/// (unfree/insecure/broken), allowing the caller to retry with the right
|
||||
/// environment variables without ever streaming the verbose nix error output.
|
||||
fn pre_evaluate(args: &[String]) -> Result<RetryAction> {
|
||||
// Find flake references or expressions to evaluate
|
||||
// Only take the first non-flag argument (the package/expression)
|
||||
let eval_arg = args.iter().find(|arg| !arg.starts_with('-'));
|
||||
|
||||
let Some(eval_arg) = eval_arg else {
|
||||
return Ok(true); // No expression to evaluate
|
||||
return Ok(RetryAction::None); // No expression to evaluate
|
||||
};
|
||||
|
||||
let eval_cmd = NixCommand::new("eval").arg(eval_arg).arg("--raw");
|
||||
let eval_cmd = NixCommand::new("eval")
|
||||
.arg(eval_arg)
|
||||
.print_build_logs(false);
|
||||
|
||||
let output = eval_cmd.output()?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(true);
|
||||
return Ok(RetryAction::None);
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// If eval fails due to unfree/insecure/broken, don't fail pre-evaluation
|
||||
// Let the main command handle it with retry logic
|
||||
if stderr.contains("has an unfree license")
|
||||
|| stderr.contains("refusing to evaluate")
|
||||
|| stderr.contains("has been marked as insecure")
|
||||
|| stderr.contains("has been marked as broken")
|
||||
{
|
||||
return Ok(true);
|
||||
// Classify whether this is a retryable error (unfree/insecure/broken)
|
||||
let action = classify_retry_action(&stderr);
|
||||
if action != RetryAction::None {
|
||||
return Ok(action);
|
||||
}
|
||||
|
||||
// For other eval failures, fail early
|
||||
Ok(false)
|
||||
// Non-retryable eval failure — fail fast with a clear message
|
||||
// rather than running the full command and showing the same error again.
|
||||
let stderr_clean = stderr
|
||||
.trim()
|
||||
.strip_prefix("error:")
|
||||
.unwrap_or(stderr.trim())
|
||||
.trim();
|
||||
Err(EhError::PreEvalFailed {
|
||||
expression: eval_arg.clone(),
|
||||
stderr: stderr_clean.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_nix_args(args: &[String]) -> Result<()> {
|
||||
|
|
@ -202,15 +357,28 @@ pub fn handle_nix_with_retry(
|
|||
interactive: bool,
|
||||
) -> Result<i32> {
|
||||
validate_nix_args(args)?;
|
||||
// Pre-evaluate for build commands to catch errors early
|
||||
if !pre_evaluate(subcommand, args)? {
|
||||
return Err(EhError::NixCommandFailed(
|
||||
"Expression evaluation failed".to_string(),
|
||||
));
|
||||
|
||||
// Pre-evaluate to detect retryable errors (unfree/insecure/broken) before
|
||||
// running the actual command. This avoids streaming verbose nix error output
|
||||
// only to retry immediately after.
|
||||
let pkg = package_name(args);
|
||||
let pre_eval_action = pre_evaluate(args)?;
|
||||
if let Some((env_var, reason)) = pre_eval_action.env_override() {
|
||||
print_retry_msg(pkg, reason, env_var);
|
||||
let mut retry_cmd = NixCommand::new(subcommand)
|
||||
.print_build_logs(true)
|
||||
.args_ref(args)
|
||||
.env(env_var, "1")
|
||||
.impure(true);
|
||||
if interactive {
|
||||
retry_cmd = retry_cmd.interactive(true);
|
||||
}
|
||||
let retry_status = retry_cmd.run_with_logs(StdIoInterceptor)?;
|
||||
return Ok(retry_status.code().unwrap_or(1));
|
||||
}
|
||||
|
||||
// For run commands, try interactive first to avoid breaking terminal
|
||||
if subcommand == "run" && interactive {
|
||||
// For run/shell commands, try interactive mode now that pre-eval passed
|
||||
if interactive {
|
||||
let status = NixCommand::new(subcommand)
|
||||
.print_build_logs(true)
|
||||
.interactive(true)
|
||||
|
|
@ -221,18 +389,22 @@ pub fn handle_nix_with_retry(
|
|||
}
|
||||
}
|
||||
|
||||
// First, always capture output to check for errors that need retry
|
||||
// Capture output to check for errors that need retry (hash mismatches etc.)
|
||||
let output_cmd = NixCommand::new(subcommand)
|
||||
.print_build_logs(true)
|
||||
.args_ref(args);
|
||||
let output = output_cmd.output()?;
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Check if we need to retry with special flags
|
||||
// Check for hash mismatch errors
|
||||
if let Some(new_hash) = hash_extractor.extract_hash(&stderr) {
|
||||
match fixer.fix_hash_in_files(&new_hash) {
|
||||
let old_hash = hash_extractor.extract_old_hash(&stderr);
|
||||
match fixer.fix_hash_in_files(old_hash.as_deref(), &new_hash) {
|
||||
Ok(true) => {
|
||||
info!("{}", Paint::green("✔ Fixed hash mismatch, retrying..."));
|
||||
log_info!(
|
||||
"{}: hash mismatch corrected in local files, rebuilding",
|
||||
pkg.bold()
|
||||
);
|
||||
let mut retry_cmd = NixCommand::new(subcommand)
|
||||
.print_build_logs(true)
|
||||
.args_ref(args);
|
||||
|
|
@ -246,72 +418,33 @@ pub fn handle_nix_with_retry(
|
|||
// No files were fixed, continue with normal error handling
|
||||
},
|
||||
Err(EhError::NoNixFilesFound) => {
|
||||
warn!("No .nix files found to fix hash in");
|
||||
log_warn!(
|
||||
"{}: hash mismatch detected but no .nix files found to update",
|
||||
pkg.bold()
|
||||
);
|
||||
// Continue with normal error handling
|
||||
},
|
||||
Err(e) => {
|
||||
return Err(e);
|
||||
},
|
||||
}
|
||||
} else if stderr.contains("hash") || stderr.contains("sha256") {
|
||||
// If there's a hash-related error but we couldn't extract it, that's a
|
||||
// failure
|
||||
return Err(EhError::HashExtractionFailed);
|
||||
} else if is_hash_mismatch_error(&stderr) {
|
||||
// There's a genuine hash mismatch but we couldn't extract the new hash
|
||||
return Err(EhError::HashExtractionFailed {
|
||||
stderr: stderr.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: check for unfree/insecure/broken in captured output
|
||||
// (in case pre_evaluate didn't catch it, e.g. from a dependency)
|
||||
if classifier.should_retry(&stderr) {
|
||||
if stderr.contains("has an unfree license") && stderr.contains("refusing") {
|
||||
warn!(
|
||||
"{}",
|
||||
Paint::yellow(
|
||||
"⚠ Unfree package detected, retrying with NIXPKGS_ALLOW_UNFREE=1..."
|
||||
)
|
||||
);
|
||||
let action = classify_retry_action(&stderr);
|
||||
if let Some((env_var, reason)) = action.env_override() {
|
||||
print_retry_msg(pkg, reason, env_var);
|
||||
let mut retry_cmd = NixCommand::new(subcommand)
|
||||
.print_build_logs(true)
|
||||
.args_ref(args)
|
||||
.env("NIXPKGS_ALLOW_UNFREE", "1")
|
||||
.impure(true);
|
||||
if interactive {
|
||||
retry_cmd = retry_cmd.interactive(true);
|
||||
}
|
||||
let retry_status = retry_cmd.run_with_logs(StdIoInterceptor)?;
|
||||
return Ok(retry_status.code().unwrap_or(1));
|
||||
}
|
||||
if stderr.contains("has been marked as insecure")
|
||||
&& stderr.contains("refusing")
|
||||
{
|
||||
warn!(
|
||||
"{}",
|
||||
Paint::yellow(
|
||||
"⚠ Insecure package detected, retrying with \
|
||||
NIXPKGS_ALLOW_INSECURE=1..."
|
||||
)
|
||||
);
|
||||
let mut retry_cmd = NixCommand::new(subcommand)
|
||||
.print_build_logs(true)
|
||||
.args_ref(args)
|
||||
.env("NIXPKGS_ALLOW_INSECURE", "1")
|
||||
.impure(true);
|
||||
if interactive {
|
||||
retry_cmd = retry_cmd.interactive(true);
|
||||
}
|
||||
let retry_status = retry_cmd.run_with_logs(StdIoInterceptor)?;
|
||||
return Ok(retry_status.code().unwrap_or(1));
|
||||
}
|
||||
if stderr.contains("has been marked as broken")
|
||||
&& stderr.contains("refusing")
|
||||
{
|
||||
warn!(
|
||||
"{}",
|
||||
Paint::yellow(
|
||||
"⚠ Broken package detected, retrying with NIXPKGS_ALLOW_BROKEN=1..."
|
||||
)
|
||||
);
|
||||
let mut retry_cmd = NixCommand::new(subcommand)
|
||||
.print_build_logs(true)
|
||||
.args_ref(args)
|
||||
.env("NIXPKGS_ALLOW_BROKEN", "1")
|
||||
.env(env_var, "1")
|
||||
.impure(true);
|
||||
if interactive {
|
||||
retry_cmd = retry_cmd.interactive(true);
|
||||
|
|
@ -330,22 +463,23 @@ pub fn handle_nix_with_retry(
|
|||
std::io::stderr()
|
||||
.write_all(&output.stderr)
|
||||
.map_err(EhError::Io)?;
|
||||
Err(EhError::ProcessExit {
|
||||
code: output.status.code().unwrap_or(1),
|
||||
})
|
||||
|
||||
match output.status.code() {
|
||||
Some(code) => Err(EhError::ProcessExit { code }),
|
||||
// No exit code means the process was killed by a signal
|
||||
None => {
|
||||
Err(EhError::NixCommandFailed {
|
||||
command: subcommand.to_string(),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DefaultNixErrorClassifier;
|
||||
|
||||
impl NixErrorClassifier for DefaultNixErrorClassifier {
|
||||
fn should_retry(&self, stderr: &str) -> bool {
|
||||
RegexHashExtractor.extract_hash(stderr).is_some()
|
||||
|| (stderr.contains("has an unfree license")
|
||||
&& stderr.contains("refusing"))
|
||||
|| (stderr.contains("has been marked as insecure")
|
||||
&& stderr.contains("refusing"))
|
||||
|| (stderr.contains("has been marked as broken")
|
||||
&& stderr.contains("refusing"))
|
||||
classify_retry_action(stderr) != RetryAction::None
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -379,7 +513,7 @@ mod tests {
|
|||
|
||||
let fixer = DefaultNixFileFixer;
|
||||
let result = fixer
|
||||
.fix_hash_in_file(file_path, "sha256-newhash999")
|
||||
.fix_hash_in_file(file_path, None, "sha256-newhash999")
|
||||
.unwrap();
|
||||
|
||||
assert!(result, "Hash replacement should return true");
|
||||
|
|
@ -413,7 +547,7 @@ mod tests {
|
|||
// Test hash replacement
|
||||
let fixer = DefaultNixFileFixer;
|
||||
let result = fixer
|
||||
.fix_hash_in_file(&file_path, "sha256-newhash999")
|
||||
.fix_hash_in_file(&file_path, None, "sha256-newhash999")
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
|
|
@ -448,7 +582,7 @@ mod tests {
|
|||
// Test that streaming can handle large files without memory issues
|
||||
let fixer = DefaultNixFileFixer;
|
||||
let result = fixer
|
||||
.fix_hash_in_file(file_path, "sha256-newhash999")
|
||||
.fix_hash_in_file(file_path, None, "sha256-newhash999")
|
||||
.unwrap();
|
||||
|
||||
assert!(result, "Hash replacement should work for large files");
|
||||
|
|
@ -483,7 +617,9 @@ mod tests {
|
|||
|
||||
// Test hash replacement
|
||||
let fixer = DefaultNixFileFixer;
|
||||
let result = fixer.fix_hash_in_file(file_path, "sha256-newhash").unwrap();
|
||||
let result = fixer
|
||||
.fix_hash_in_file(file_path, None, "sha256-newhash")
|
||||
.unwrap();
|
||||
|
||||
assert!(result, "Hash replacement should succeed");
|
||||
|
||||
|
|
@ -538,4 +674,233 @@ mod tests {
|
|||
safe_args
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_input_validation_empty_args() {
|
||||
let result = validate_nix_args(&[]);
|
||||
assert!(result.is_ok(), "Empty args should be accepted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_extraction_got_pattern() {
|
||||
let stderr = "hash mismatch in fixed-output derivation\n specified: \
|
||||
sha256-AAAA\n got: \
|
||||
sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=";
|
||||
let extractor = RegexHashExtractor;
|
||||
let hash = extractor.extract_hash(stderr);
|
||||
assert!(hash.is_some());
|
||||
assert!(hash.unwrap().starts_with("sha256-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_extraction_actual_pattern() {
|
||||
let stderr = "hash mismatch\n actual: \
|
||||
sha256-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=";
|
||||
let extractor = RegexHashExtractor;
|
||||
let hash = extractor.extract_hash(stderr);
|
||||
assert!(hash.is_some());
|
||||
assert!(hash.unwrap().starts_with("sha256-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_extraction_have_pattern() {
|
||||
let stderr = "hash mismatch\n have: \
|
||||
sha256-DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=";
|
||||
let extractor = RegexHashExtractor;
|
||||
let hash = extractor.extract_hash(stderr);
|
||||
assert!(hash.is_some());
|
||||
assert!(hash.unwrap().starts_with("sha256-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_extraction_no_match() {
|
||||
let stderr = "error: some other nix error without hashes";
|
||||
let extractor = RegexHashExtractor;
|
||||
assert!(extractor.extract_hash(stderr).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_extraction_partial_match() {
|
||||
// Contains "got:" but no sha256 hash
|
||||
let stderr = "got: some-other-value";
|
||||
let extractor = RegexHashExtractor;
|
||||
assert!(extractor.extract_hash(stderr).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_false_positive_hash_detection() {
|
||||
// Normal nix output mentioning "hash" or "sha256" without being a mismatch
|
||||
let cases = [
|
||||
"evaluating attribute 'sha256' of derivation 'hello'",
|
||||
"building '/nix/store/hash-something.drv'",
|
||||
"copying path '/nix/store/sha256-abcdef-hello'",
|
||||
"this derivation has a hash attribute set",
|
||||
];
|
||||
for stderr in &cases {
|
||||
assert!(
|
||||
!is_hash_mismatch_error(stderr),
|
||||
"Should not detect hash mismatch in: {stderr}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_genuine_hash_mismatch_detection() {
|
||||
assert!(is_hash_mismatch_error(
|
||||
"hash mismatch in fixed-output derivation"
|
||||
));
|
||||
assert!(is_hash_mismatch_error(
|
||||
"specified: sha256-AAAA\n got: sha256-BBBB"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_retry_action_unfree() {
|
||||
let stderr =
|
||||
"error: Package 'foo' has an unfree license, refusing to evaluate.";
|
||||
assert_eq!(classify_retry_action(stderr), RetryAction::AllowUnfree);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_retry_action_insecure() {
|
||||
let stderr =
|
||||
"error: Package 'bar' has been marked as insecure, refusing to evaluate.";
|
||||
assert_eq!(classify_retry_action(stderr), RetryAction::AllowInsecure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_retry_action_broken() {
|
||||
let stderr =
|
||||
"error: Package 'baz' has been marked as broken, refusing to evaluate.";
|
||||
assert_eq!(classify_retry_action(stderr), RetryAction::AllowBroken);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_retry_action_none() {
|
||||
let stderr = "error: attribute 'nonexistent' not found";
|
||||
assert_eq!(classify_retry_action(stderr), RetryAction::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_action_env_overrides() {
|
||||
let (var, reason) = RetryAction::AllowUnfree.env_override().unwrap();
|
||||
assert_eq!(var, "NIXPKGS_ALLOW_UNFREE");
|
||||
assert!(reason.contains("unfree"));
|
||||
|
||||
let (var, reason) = RetryAction::AllowInsecure.env_override().unwrap();
|
||||
assert_eq!(var, "NIXPKGS_ALLOW_INSECURE");
|
||||
assert!(reason.contains("insecure"));
|
||||
|
||||
let (var, reason) = RetryAction::AllowBroken.env_override().unwrap();
|
||||
assert_eq!(var, "NIXPKGS_ALLOW_BROKEN");
|
||||
assert!(reason.contains("broken"));
|
||||
|
||||
assert_eq!(RetryAction::None.env_override(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classifier_should_retry() {
|
||||
let classifier = DefaultNixErrorClassifier;
|
||||
assert!(
|
||||
classifier.should_retry(
|
||||
"Package 'x' has an unfree license, refusing to evaluate"
|
||||
)
|
||||
);
|
||||
assert!(classifier.should_retry(
|
||||
"Package 'x' has been marked as insecure, refusing to evaluate"
|
||||
));
|
||||
assert!(classifier.should_retry(
|
||||
"Package 'x' has been marked as broken, refusing to evaluate"
|
||||
));
|
||||
assert!(!classifier.should_retry("error: attribute not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_old_hash_extraction() {
|
||||
let stderr =
|
||||
"hash mismatch in fixed-output derivation\n specified: \
|
||||
sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n got: \
|
||||
sha256-BBBB=";
|
||||
let extractor = RegexHashExtractor;
|
||||
let old = extractor.extract_old_hash(stderr);
|
||||
assert!(old.is_some());
|
||||
assert_eq!(
|
||||
old.unwrap(),
|
||||
"sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_old_hash_extraction_missing() {
|
||||
let stderr = "hash mismatch\n got: sha256-BBBB=";
|
||||
let extractor = RegexHashExtractor;
|
||||
assert!(extractor.extract_old_hash(stderr).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_targeted_hash_replacement_only_matching() {
|
||||
let temp_file = NamedTempFile::new().unwrap();
|
||||
let file_path = temp_file.path();
|
||||
|
||||
// File with two derivations, each with a different hash
|
||||
let test_content = r#"{ pkgs }:
|
||||
{
|
||||
a = pkgs.fetchurl {
|
||||
url = "https://example.com/a.tar.gz";
|
||||
hash = "sha256-AAAA";
|
||||
};
|
||||
b = pkgs.fetchurl {
|
||||
url = "https://example.com/b.tar.gz";
|
||||
hash = "sha256-BBBB";
|
||||
};
|
||||
}"#;
|
||||
|
||||
let mut file = std::fs::File::create(file_path).unwrap();
|
||||
file.write_all(test_content.as_bytes()).unwrap();
|
||||
file.flush().unwrap();
|
||||
|
||||
let fixer = DefaultNixFileFixer;
|
||||
// Only replace the hash matching "sha256-AAAA"
|
||||
let result = fixer
|
||||
.fix_hash_in_file(file_path, Some("sha256-AAAA"), "sha256-NEWW")
|
||||
.unwrap();
|
||||
|
||||
assert!(result, "Targeted replacement should return true");
|
||||
|
||||
let updated = std::fs::read_to_string(file_path).unwrap();
|
||||
assert!(
|
||||
updated.contains(r#"hash = "sha256-NEWW""#),
|
||||
"Matching hash should be replaced"
|
||||
);
|
||||
assert!(
|
||||
updated.contains(r#"hash = "sha256-BBBB""#),
|
||||
"Non-matching hash should be untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_targeted_hash_replacement_no_match() {
|
||||
let temp_file = NamedTempFile::new().unwrap();
|
||||
let file_path = temp_file.path();
|
||||
|
||||
let test_content = r#"{ hash = "sha256-XXXX"; }"#;
|
||||
|
||||
let mut file = std::fs::File::create(file_path).unwrap();
|
||||
file.write_all(test_content.as_bytes()).unwrap();
|
||||
file.flush().unwrap();
|
||||
|
||||
let fixer = DefaultNixFileFixer;
|
||||
// old_hash doesn't match anything in the file
|
||||
let result = fixer
|
||||
.fix_hash_in_file(file_path, Some("sha256-NOMATCH"), "sha256-NEWW")
|
||||
.unwrap();
|
||||
|
||||
assert!(!result, "Should return false when old hash doesn't match");
|
||||
|
||||
let updated = std::fs::read_to_string(file_path).unwrap();
|
||||
assert!(
|
||||
updated.contains("sha256-XXXX"),
|
||||
"Original hash should be untouched"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6
flake.lock
generated
6
flake.lock
generated
|
|
@ -2,11 +2,11 @@
|
|||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1762844143,
|
||||
"narHash": "sha256-SlybxLZ1/e4T2lb1czEtWVzDCVSTvk9WLwGhmxFmBxI=",
|
||||
"lastModified": 1769461804,
|
||||
"narHash": "sha256-msG8SU5WsBUfVVa/9RPLaymvi5bI8edTavbIq3vRlhI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9da7f1cf7f8a6e2a7cb3001b048546c92a8258b4",
|
||||
"rev": "bfc1b8a4574108ceef22f02bafcf6611380c100d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue