diff --git a/Cargo.lock b/Cargo.lock index f32a1a5..e721d13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,24 +68,40 @@ dependencies = [ ] [[package]] -name = "base64" -version = "0.22.1" +name = "anyhow" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bn-loader" -version = "0.1.2" +version = "0.2.0" dependencies = [ + "anyhow", "clap", "clap_complete", "globset", - "semver", "serde", "serde_json", + "tempfile", "termcolor", "toml", - "ureq", + "zip", ] [[package]] @@ -99,20 +115,10 @@ dependencies = [ ] [[package]] -name = "bytes" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" - -[[package]] -name = "cc" -version = "1.2.51" +name = "bumpalo" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" -dependencies = [ - "find-msvc-tools", - "shlex", -] +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "cfg-if" @@ -187,6 +193,34 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -194,10 +228,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "find-msvc-tools" -version = "0.1.6" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "flate2" @@ -209,15 +253,23 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "getrandom" -version = "0.2.16" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "wasi", + "r-efi", + "wasip2", + "wasip3", ] [[package]] @@ -233,6 +285,15 @@ dependencies = [ "regex-syntax", ] +[[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" @@ -246,20 +307,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "http" -version = "1.4.0" +name = "id-arena" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "indexmap" @@ -268,7 +319,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -292,12 +345,24 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[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.179" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "log" version = "0.4.29" @@ -333,10 +398,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] -name = "percent-encoding" -version = "2.3.2" +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] [[package]] name = "proc-macro2" @@ -356,6 +425,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "regex-automata" version = "0.4.13" @@ -374,52 +449,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] -name = "ring" -version = "0.17.14" +name = "rustix" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "cc", - "cfg-if", - "getrandom", + "bitflags", + "errno", "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustls" -version = "0.23.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", + "linux-raw-sys", + "windows-sys 0.61.2", ] [[package]] @@ -498,12 +537,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "2.0.113" @@ -515,6 +548,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -524,6 +570,26 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml" version = "0.9.10+spec-1.1.0" @@ -570,65 +636,67 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] -name = "untrusted" -version = "0.9.0" +name = "unicode-xid" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] -name = "ureq" -version = "3.1.4" +name = "utf8parse" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a" -dependencies = [ - "base64", - "flate2", - "log", - "percent-encoding", - "rustls", - "rustls-pki-types", - "ureq-proto", - "utf-8", - "webpki-roots", -] +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] -name = "ureq-proto" -version = "0.5.3" +name = "wasip2" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "base64", - "http", - "httparse", - "log", + "wit-bindgen 0.57.1", ] [[package]] -name = "utf-8" -version = "0.7.6" +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 = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] [[package]] -name = "utf8parse" -version = "0.2.2" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] [[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +name = "wasm-metadata" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] [[package]] -name = "webpki-roots" -version = "1.0.5" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "rustls-pki-types", + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", ] [[package]] @@ -646,22 +714,13 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -673,22 +732,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - [[package]] name = "windows-targets" version = "0.53.5" @@ -696,34 +739,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - [[package]] name = "windows_aarch64_msvc" version = "0.53.1" @@ -732,90 +763,171 @@ checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" -version = "0.52.6" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] -name = "windows_i686_gnu" +name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" +name = "windows_i686_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] -name = "windows_i686_gnullvm" +name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] -name = "windows_i686_msvc" -version = "0.52.6" +name = "windows_x86_64_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] -name = "windows_i686_msvc" +name = "windows_x86_64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] [[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" +name = "wit-bindgen-core" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] [[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" +name = "wit-bindgen-rust" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] [[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" +name = "wit-bindgen-rust-macro" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] [[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" +name = "wit-component" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] [[package]] -name = "winnow" -version = "0.7.14" +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] -name = "zeroize" -version = "1.8.2" +name = "zip" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror", + "zopfli", +] [[package]] name = "zmij" version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30e0d8dffbae3d840f64bda38e28391faef673a7b5a6017840f2a106c8145868" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index 293c9eb..e169b46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bn-loader" -version = "0.1.2" +version = "0.2.0" edition = "2024" description = "A profile launcher for Binary Ninja that manages multiple configurations" license = "BSD-3-Clause" @@ -24,5 +24,6 @@ serde_json = "1" toml = "0.9" globset = "0.4" termcolor = "1.4" -ureq = "3" -semver = "1" +anyhow = "1" +zip = { version = "2", default-features = false, features = ["deflate"] } +tempfile = "3" diff --git a/README.md b/README.md index 74491be..09d84a3 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,6 @@ A basic config looks like this: ```toml [global] default_profile = "personal" -check_updates = true [profiles.personal] install_dir = "C:\\Program Files\\Binary Ninja Personal" @@ -67,40 +66,105 @@ bn-loader --list # Launch with debug output bn-loader personal --debug -# Check for updates -bn-loader --check-update +# Force color output (also: "always", "never", or default "auto") +bn-loader --color always profile diff personal commercial ``` ### Commands -**init** - Create a new profile from an existing one: +Profile management commands are grouped under `bn-loader profile`. The flat launch shortcut (`bn-loader `) and `--list` remain at the top level. + +**profile init** - Create a new profile from an existing one: ```bash -bn-loader init dev --template personal --config-dir ~/bn-dev-config +bn-loader profile init dev --template personal --config-dir ~/bn-dev-config ``` This copies the license and install directory from the template but gives the new profile its own config directory. -**sync** - Copy settings between profiles: +**profile install** - Install Binary Ninja from an archive and register a profile: + +```bash +# Linux: extract a .zip bundle (registers profile 'stable' at ~/.config/bn-loader/profiles/stable) +bn-loader profile install ~/Downloads/binaryninja_linux_X.Y.Z_personal.zip --dest /opt/binaryninja/stable + +# Windows: extract an NSIS installer .exe (requires 7-Zip) +bn-loader profile install C:\Downloads\binaryninja_win64_X.Y.Z_personal.exe --dest "C:\Program Files\Binary Ninja Personal" + +# Override the auto-derived profile name and/or config dir +bn-loader profile install ~/Downloads/binaryninja_linux_X.Y.Z_personal.zip \ + --dest /opt/binaryninja/stable \ + --name personal-stable \ + --config-dir ~/.binaryninja-personal-stable + +# Extract only -- don't touch the config file +bn-loader profile install ~/Downloads/binaryninja_linux_X.Y.Z_personal.zip --dest /opt/binaryninja/stable --no-register +``` + +By default, `bn-loader profile install` registers a new profile in your config: +- `--name` defaults to a sanitized basename of `--dest` (`/opt/binaryninja/stable` -> `stable`). Invalid characters become underscores. +- `--config-dir` defaults to `~/.config/bn-loader/profiles/`. +- The profile name is the only uniqueness constraint. If you omit `--name` and the derived name is already taken, bn-loader auto-bumps to `-2`, `-3`, etc. +- If you explicitly pass `--name` and that name already exists, bn-loader errors out -- an explicit name is never silently rewritten. +- `--config-dir` can be shared across multiple profiles (e.g., two installations that share plugins/settings). bn-loader does not check or warn if the config dir already contains files. + +Pass `--no-register` to skip profile registration entirely (install becomes a pure extract). + +For the Windows NSIS path, `bn-loader` shells out to 7z. Resolution order is `--seven-zip` flag, then `[install] seven_zip` in your config, then `$PATH`. NSIS-only artifacts (`$PLUGINSDIR/`, installer images, VC++ redistributables) are filtered out automatically. ZIP archives have their single top-level directory stripped (so `--dest /opt/binaryninja/stable` produces `binaryninja` directly under it, not nested). + +If `--dest` is non-empty, `bn-loader profile install` requires `--force` to overlay-extract on top (existing files are preserved unless they share a name with an entry in the archive). The blast-radius warning lists which profiles will be affected by the install (relevant for updates and shared installations). Pass `--yes` to skip the interactive `[y/N]` confirmation; pass `--force --yes` for a fully unattended overlay install. + +**profile update** - Re-extract Binary Ninja into an existing profile's install_dir: +```bash +bn-loader profile update stable ~/Downloads/binaryninja_linux_NEW.zip --yes +``` +A thin wrapper over `profile install`. Looks up the profile by name, uses its `install_dir` as the dest, and runs the install logic with `--force` (overlay) and `--no-register` (already registered) implicit. Honors `--yes`, `--dry-run`, `--seven-zip`. Useful for unattended updates. + +**profile remove** - Deregister a profile (and optionally delete its on-disk dirs): +```bash +# Just deregister (default): leaves install_dir and config_dir on disk +bn-loader profile remove dev + +# Also delete the profile's config_dir +bn-loader profile remove dev --purge + +# Also delete the install_dir (only with --purge --force) +bn-loader profile remove dev --purge --force +``` +Always shows blast radius before acting (warns if `install_dir` or `config_dir` is shared with other profiles). Refuses to `--purge` a `config_dir` shared with other profiles, and refuses to `--purge --force` an `install_dir` shared with other profiles — remove those profiles first or skip `--purge`/`--force`. Note: editing the config file is a TOML round-trip, so comments and exact formatting are not preserved across removes. + +**profile sync** - Copy settings between profiles: ```bash # Sync from personal to all other profiles -bn-loader sync --from personal +bn-loader profile sync --from personal # Sync to a specific profile -bn-loader sync --from personal --to commercial +bn-loader profile sync --from personal --to commercial # Preview changes without applying -bn-loader sync --from personal --dry-run +bn-loader profile sync --from personal --dry-run ``` License files and other sensitive data are excluded by default. You can add more exclusions in the `[sync]` section of your config. -**plugins** - List installed plugins for a profile: +**profile plugins** - List installed plugins for a profile: +```bash +bn-loader profile plugins personal +``` + +**profile diff** - Compare two profiles: +```bash +bn-loader profile diff personal commercial +``` + +**profile list** - List available profiles (canonical form: `bn-loader profile list`): ```bash -bn-loader plugins personal +bn-loader profile list ``` +This produces the same output as the `--list` shortcut flag (see Usage above). -**diff** - Compare two profiles: +**doctor** - Validate the whole config (read-only): ```bash -bn-loader diff personal commercial +bn-loader doctor ``` +Checks each profile's `install_dir` / `executable` / `config_dir`, plus global checks (`[install] seven_zip` if set, orphan dirs under `~/.config/bn-loader/profiles/`). Prints `[OK]` / `[WARN]` / `[FAIL]` per check (to stderr) and a summary line on stdout. Exits 0 if no failures, 1 otherwise — useful for `bn-loader doctor && bn-loader ` patterns and CI. **completions** - Set up shell completions: ```bash @@ -110,6 +174,25 @@ bn-loader completions fish bn-loader completions powershell ``` +### Flag conventions + +Standardized across every mutating command: + +- `--yes` (`-y`) — skip all interactive `[y/N]` confirmation prompts (just say yes). +- `--force` (`-f`) — override safety checks. Allow destructive defaults that are blocked otherwise (e.g., overlay-extract on a non-empty `--dest`, remove a profile whose `install_dir` is shared, sync without backups). +- `--dry-run` — print what would happen without doing it. + +Fully-unattended scripts typically want `--yes --force`. The two are independent — `--yes` answers prompts, `--force` overrides safety blocks. + +### Output streams + +`bn-loader` follows standard stdout/stderr discipline so subcommand output composes cleanly with shell pipelines: + +- **stdout:** subcommand result data (profile lists, diff entries, plugin tables, doctor summary). +- **stderr:** status updates, warnings, errors, prompts. + +So `bn-loader profile plugins commercial | grep ...` filters only the actual plugin lines, and `bn-loader profile list 2>/dev/null` outputs only the profile list with no header noise. + ## Shell Completions bn-loader supports tab completion for profile names and commands. Run `bn-loader completions ` for setup instructions specific to your shell. @@ -122,7 +205,6 @@ These go in the `[global]` section: |--------|---------|-------------| | `default_profile` | none | Profile to launch when no argument given | | `color` | `"auto"` | Color output: `"auto"`, `"always"`, `"never"` | -| `check_updates` | `true` | Check GitHub for new releases on launch | | `backup_retention` | `5` | Number of sync backups to keep (0 = unlimited) | | `debug` | `false` | Enable debug logging globally | @@ -168,7 +250,7 @@ exclusions = ["my-custom-dir/", "*.tmp"] Or use the `--exclude` flag for one-off exclusions: ```bash -bn-loader sync --from personal --exclude "temp/" +bn-loader profile sync --from personal --exclude "temp/" ``` ## License diff --git a/example.config.toml b/example.config.toml index 39c0991..5c154b0 100644 --- a/example.config.toml +++ b/example.config.toml @@ -10,7 +10,6 @@ [global] # default_profile = "personal" # Launch this profile when no argument given # color = "auto" # Color output: "auto", "always", "never" -# check_updates = true # Check for updates on launch # backup_retention = 5 # Keep this many sync backups (0 = unlimited) # debug = false # Enable debug logging globally @@ -26,6 +25,20 @@ # [sync] # exclusions = ["my-custom-dir/", "*.tmp"] +# ============================================================================ +# Install Settings (optional) +# ============================================================================ +# +# Used by `bn-loader install `. Currently only seven_zip is configurable. +# +# seven_zip: optional path to 7-Zip's executable, used to extract Windows NSIS +# installer .exe files. Resolution order: --seven-zip CLI flag, then this +# config field, then $PATH lookup. If you have 7-Zip in a non-standard location, +# set it here so you don't have to pass --seven-zip every time. +# +# [install] +# seven_zip = "C:\\Program Files\\7-Zip\\7z.exe" + # ============================================================================ # Profile Examples # ============================================================================ diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..db5788a --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,52 @@ +use crate::config::{Config, Profile}; +use anyhow::{Context, Result}; +use std::io::{self, Write}; +use std::process; + +/// Print error to stderr and exit non-zero on Err; exit 0 on Ok. Never returns. +/// +/// When `debug` is true, prints the full anyhow cause chain on indented `caused by:` +/// lines after the top message; otherwise prints just the top message. +pub(crate) fn report_and_exit(result: Result<()>, debug: bool) -> ! { + match result { + Ok(()) => process::exit(0), + Err(e) => { + eprintln!("Error: {e}"); + if debug { + for cause in e.chain().skip(1) { + eprintln!(" caused by: {cause}"); + } + } + process::exit(1); + } + } +} + +/// Look up a profile by name. Returns Err with a helpful message including a hint +/// to use `--list` if the profile is missing. +pub(crate) fn resolve_profile<'a>(config: &'a Config, name: &str) -> Result<&'a Profile> { + config.profiles.get(name).ok_or_else(|| { + anyhow::anyhow!("Profile '{name}' not found.\nUse --list to see available profiles.") + }) +} + +/// Prompt the user with a yes/no question. Default answer is "no" when `default_no` +/// is true (shown as `[y/N]`), otherwise "yes" (shown as `[Y/n]`). Returns Ok(true) +/// when the user answers yes. +pub(crate) fn confirm_prompt(message: &str, default_no: bool) -> Result { + let suffix = if default_no { "[y/N]" } else { "[Y/n]" }; + print!("{message} {suffix} "); + io::stdout().flush().context("Failed to flush stdout")?; + + let mut input = String::new(); + io::stdin() + .read_line(&mut input) + .context("Failed to read input")?; + + let trimmed = input.trim(); + Ok(if default_no { + trimmed.eq_ignore_ascii_case("y") || trimmed.eq_ignore_ascii_case("yes") + } else { + !(trimmed.eq_ignore_ascii_case("n") || trimmed.eq_ignore_ascii_case("no")) + }) +} diff --git a/src/colors.rs b/src/colors.rs deleted file mode 100644 index 02e6b49..0000000 --- a/src/colors.rs +++ /dev/null @@ -1,28 +0,0 @@ -use std::io::{self, Write}; -use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor}; - -pub(crate) fn stdout() -> StandardStream { - StandardStream::stdout(ColorChoice::Auto) -} - -pub(crate) fn writeln_colored( - stream: &mut StandardStream, - text: &str, - color: Color, -) -> io::Result<()> { - stream.set_color(ColorSpec::new().set_fg(Some(color)))?; - writeln!(stream, "{text}")?; - stream.reset() -} - -pub(crate) fn write_bold(stream: &mut StandardStream, text: &str) -> io::Result<()> { - stream.set_color(ColorSpec::new().set_bold(true))?; - write!(stream, "{text}")?; - stream.reset() -} - -pub(crate) fn writeln_bold(stream: &mut StandardStream, text: &str) -> io::Result<()> { - stream.set_color(ColorSpec::new().set_bold(true))?; - writeln!(stream, "{text}")?; - stream.reset() -} diff --git a/src/config.rs b/src/config.rs index dfda276..a12924e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,4 @@ +use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::env; @@ -37,9 +38,12 @@ fn user_config_path() -> Option { home_dir().map(|home| home.join(".config").join(CONFIG_FILE_NAME)) } -/// Get the cache directory for bn-loader -pub(crate) fn cache_dir() -> Option { - home_dir().map(|home| home.join(".cache").join("bn-loader")) +/// Get the default base directory for auto-generated profile config dirs. +/// +/// Returns `/.config/bn-loader/profiles`. Used by `bn-loader install` +/// to derive a default `--config-dir` when one isn't supplied. +pub(crate) fn default_profiles_dir() -> Option { + home_dir().map(|home| home.join(".config").join("bn-loader").join("profiles")) } pub(crate) fn default_exclusions() -> Vec { @@ -53,15 +57,11 @@ pub(crate) fn default_exclusions() -> Vec { ] } -fn default_true() -> bool { - true -} - fn default_backup_retention() -> usize { 5 } -#[derive(Deserialize, Serialize, Clone, Copy, Default, PartialEq, Eq)] +#[derive(Deserialize, Serialize, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)] #[serde(rename_all = "lowercase")] pub(crate) enum ColorMode { #[default] @@ -80,10 +80,6 @@ pub(crate) struct GlobalConfig { #[serde(default)] pub color: ColorMode, - /// Check for updates on launch - #[serde(default = "default_true")] - pub check_updates: bool, - /// How many sync backups to retain (0 = unlimited) #[serde(default = "default_backup_retention")] pub backup_retention: usize, @@ -101,6 +97,8 @@ pub(crate) struct Config { pub profiles: HashMap, #[serde(default)] pub sync: SyncConfig, + #[serde(default)] + pub install: InstallConfig, } #[derive(Deserialize, Serialize, Default, Clone)] @@ -110,6 +108,14 @@ pub(crate) struct SyncConfig { pub exclusions: Vec, } +#[derive(Deserialize, Serialize, Default, Clone)] +pub(crate) struct InstallConfig { + /// Optional path to the 7-Zip executable (used for NSIS installer extraction). + /// Resolution order: --seven-zip CLI flag > this config field > $PATH lookup. + #[serde(default)] + pub seven_zip: Option, +} + #[derive(Deserialize, Serialize, Clone)] pub(crate) struct Profile { pub install_dir: PathBuf, @@ -151,8 +157,83 @@ pub(crate) fn find_config_file(custom_path: Option<&str>) -> Option { None } -pub(crate) fn load_config(path: &Path) -> Result { - let content = - fs::read_to_string(path).map_err(|e| format!("Failed to read config file: {e}"))?; - toml::from_str(&content).map_err(|e| format!("Failed to parse config file: {e}")) +pub(crate) fn load_config(path: &Path) -> Result { + let content = fs::read_to_string(path).context("Failed to read config file")?; + toml::from_str(&content).context("Failed to parse config file") +} + +/// Append a new `[profiles.]` block to the given config file. +/// +/// Validates the profile name to prevent TOML injection and uses the `toml` crate +/// to escape path values. +pub(crate) fn append_profile_to_config( + config_path: &Path, + name: &str, + install_dir: &Path, + config_dir: &Path, +) -> Result<()> { + if !is_valid_profile_name(name) { + bail!( + "Invalid profile name '{name}': must contain only alphanumeric characters, hyphens, and underscores" + ); + } + + let mut file = fs::OpenOptions::new() + .append(true) + .open(config_path) + .context("Failed to open config file")?; + + let install_str = install_dir.to_string_lossy(); + let config_str = config_dir.to_string_lossy(); + let install_escaped = toml::Value::String(install_str.into_owned()); + let config_escaped = toml::Value::String(config_str.into_owned()); + + let profile_toml = format!( + "\n[profiles.{name}]\ninstall_dir = {install_escaped}\nconfig_dir = {config_escaped}\n" + ); + + use std::io::Write; + file.write_all(profile_toml.as_bytes()) + .context("Failed to write to config file")?; + + println!(" Added profile to: {}", config_path.display()); + + Ok(()) +} + +/// Remove the `[profiles.]` block from a config file. Returns Ok(()) even if +/// the profile wasn't present (idempotent). +/// +/// Implementation: re-read the file, parse with toml, mutate the in-memory Table, +/// serialize back. This loses comments and exact formatting (TOML round-trip is lossy +/// for whitespace/comments), but it's safe and predictable. +pub(crate) fn remove_profile_from_config(config_path: &Path, name: &str) -> Result<()> { + if !is_valid_profile_name(name) { + bail!( + "Invalid profile name '{name}': must contain only alphanumeric characters, hyphens, and underscores" + ); + } + + let content = fs::read_to_string(config_path).context("Failed to read config file")?; + let mut doc: toml::Table = content + .parse() + .context("Failed to parse config file as TOML")?; + + if let Some(profiles_value) = doc.get_mut("profiles") + && let Some(profiles_table) = profiles_value.as_table_mut() + { + profiles_table.remove(name); + } + + let serialized = toml::to_string_pretty(&doc).context("Failed to re-serialize config")?; + fs::write(config_path, serialized).context("Failed to write updated config file")?; + Ok(()) +} + +/// Profile name must be non-empty and contain only alphanumerics, hyphens, and underscores. +pub(crate) fn is_valid_profile_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') } diff --git a/src/diff.rs b/src/diff.rs index bc5f341..83bc198 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -1,10 +1,10 @@ -use crate::colors::{stdout, write_bold, writeln_bold, writeln_colored}; -use crate::config::Profile; +use crate::config::{Config, Profile}; +use crate::output::Output; use crate::plugins::{PluginInfo, list_plugins}; +use anyhow::Result; use serde_json::Value; use std::collections::HashSet; use std::fs; -use std::io::Write; use termcolor::Color; const SETTINGS_FILE: &str = "settings.json"; @@ -13,31 +13,31 @@ const MAX_VALUE_DISPLAY_LEN: usize = 30; const VALUE_PREVIEW_LEN: usize = 27; pub(crate) fn diff_profiles( + config: &Config, name1: &str, profile1: &Profile, name2: &str, profile2: &Profile, -) -> Result<(), String> { - let mut out = stdout(); +) -> Result<()> { + let out = Output::new(config.global.color); - write_bold(&mut out, "Comparing profiles: ") - .and_then(|()| writeln!(out, "'{name1}' vs '{name2}'\n")) - .map_err(|e| e.to_string())?; + out.out_bold_inline("Comparing profiles: "); + out.out(&format!("'{name1}' vs '{name2}'\n")); - diff_plugins(&mut out, name1, profile1, name2, profile2)?; - writeln!(out).map_err(|e| e.to_string())?; - diff_settings(&mut out, name1, profile1, name2, profile2)?; + diff_plugins(&out, name1, profile1, name2, profile2)?; + out.out(""); + diff_settings(&out, name1, profile1, name2, profile2)?; Ok(()) } fn diff_plugins( - out: &mut termcolor::StandardStream, + out: &Output, name1: &str, profile1: &Profile, name2: &str, profile2: &Profile, -) -> Result<(), String> { +) -> Result<()> { let plugins1 = list_plugins(profile1)?; let plugins2 = list_plugins(profile2)?; @@ -48,12 +48,10 @@ fn diff_plugins( .iter() .filter(|p| !set2.contains(p.dir_name.as_str())) .collect(); - let only_in_2: Vec<&PluginInfo> = plugins2 .iter() .filter(|p| !set1.contains(p.dir_name.as_str())) .collect(); - let in_both: Vec<(&PluginInfo, &PluginInfo)> = plugins1 .iter() .filter_map(|p1| { @@ -64,63 +62,57 @@ fn diff_plugins( }) .collect(); - writeln_bold(out, "=== Plugins ===").map_err(|e| e.to_string())?; - writeln!( - out, + out.heading("=== Plugins ==="); + out.out(&format!( " {} has {} plugins, {} has {} plugins", name1, plugins1.len(), name2, plugins2.len() - ) - .map_err(|e| e.to_string())?; + )); if !only_in_1.is_empty() { - writeln!(out, "\n Only in '{name1}':").map_err(|e| e.to_string())?; + out.out(&format!("\n Only in '{name1}':")); for p in &only_in_1 { let name = p.name.as_deref().unwrap_or(&p.dir_name); - writeln_colored(out, &format!(" + {name}"), Color::Green) - .map_err(|e| e.to_string())?; + out.out_colored(&format!(" + {name}"), Color::Green); } } if !only_in_2.is_empty() { - writeln!(out, "\n Only in '{name2}':").map_err(|e| e.to_string())?; + out.out(&format!("\n Only in '{name2}':")); for p in &only_in_2 { let name = p.name.as_deref().unwrap_or(&p.dir_name); - writeln_colored(out, &format!(" - {name}"), Color::Red) - .map_err(|e| e.to_string())?; + out.out_colored(&format!(" - {name}"), Color::Red); } } - // Check version differences let version_diffs: Vec<_> = in_both .iter() .filter(|(p1, p2)| p1.version != p2.version) .collect(); if !version_diffs.is_empty() { - writeln!(out, "\n Version differences:").map_err(|e| e.to_string())?; + out.out("\n Version differences:"); for (p1, p2) in &version_diffs { let name = p1.name.as_deref().unwrap_or(&p1.dir_name); let v1 = p1.version.as_deref().unwrap_or("?"); let v2 = p2.version.as_deref().unwrap_or("?"); - writeln_colored(out, &format!(" ~ {name} : {v1} -> {v2}"), Color::Yellow) - .map_err(|e| e.to_string())?; + out.out_colored(&format!(" ~ {name} : {v1} -> {v2}"), Color::Yellow); } } if only_in_1.is_empty() && only_in_2.is_empty() && version_diffs.is_empty() { - writeln!(out, " (no differences)").map_err(|e| e.to_string())?; + out.out(" (no differences)"); } Ok(()) } enum DiffKind { - Added, // + green - Removed, // - red - Changed, // ~ yellow + Added, + Removed, + Changed, } struct DiffEntry { @@ -129,12 +121,12 @@ struct DiffEntry { } fn diff_settings( - out: &mut termcolor::StandardStream, + out: &Output, name1: &str, profile1: &Profile, name2: &str, profile2: &Profile, -) -> Result<(), String> { +) -> Result<()> { let settings1_path = profile1.config_dir.join(SETTINGS_FILE); let settings2_path = profile2.config_dir.join(SETTINGS_FILE); @@ -154,37 +146,37 @@ fn diff_settings( None }; - writeln_bold(out, "=== Settings ===").map_err(|e| e.to_string())?; + out.heading("=== Settings ==="); match (&settings1, &settings2) { (None, None) => { - writeln!(out, " Neither profile has {SETTINGS_FILE}").map_err(|e| e.to_string())?; + out.out(&format!(" Neither profile has {SETTINGS_FILE}")); } (Some(_), None) => { - writeln!(out, " Only '{name1}' has {SETTINGS_FILE}").map_err(|e| e.to_string())?; + out.out(&format!(" Only '{name1}' has {SETTINGS_FILE}")); } (None, Some(_)) => { - writeln!(out, " Only '{name2}' has {SETTINGS_FILE}").map_err(|e| e.to_string())?; + out.out(&format!(" Only '{name2}' has {SETTINGS_FILE}")); } (Some(v1), Some(v2)) => { let diffs = diff_json_objects(v1, v2, ""); if diffs.is_empty() { - writeln!(out, " (no differences)").map_err(|e| e.to_string())?; + out.out(" (no differences)"); } else { - writeln!(out, " {} differences found:\n", diffs.len()) - .map_err(|e| e.to_string())?; + out.out(&format!(" {} differences found:\n", diffs.len())); for diff in diffs.iter().take(MAX_DIFF_DISPLAY) { let color = match diff.kind { DiffKind::Added => Color::Green, DiffKind::Removed => Color::Red, DiffKind::Changed => Color::Yellow, }; - writeln_colored(out, &format!(" {}", diff.text), color) - .map_err(|e| e.to_string())?; + out.out_colored(&format!(" {}", diff.text), color); } if diffs.len() > MAX_DIFF_DISPLAY { - writeln!(out, " ... and {} more", diffs.len() - MAX_DIFF_DISPLAY) - .map_err(|e| e.to_string())?; + out.out(&format!( + " ... and {} more", + diffs.len() - MAX_DIFF_DISPLAY + )); } } } diff --git a/src/doctor.rs b/src/doctor.rs new file mode 100644 index 0000000..8a5342d --- /dev/null +++ b/src/doctor.rs @@ -0,0 +1,147 @@ +use crate::config::{Config, default_profiles_dir}; +use crate::output::Output; +use anyhow::Result; +use std::collections::HashSet; +use std::path::Path; + +/// Result of one doctor check. +#[allow(dead_code)] +enum Status { + Ok, + Warn(String), + Fail(String), +} + +pub(crate) fn run_doctor(out: &Output, config: &Config) -> Result { + let mut failures = 0usize; + let mut warnings = 0usize; + let mut checks = 0usize; + + out.heading("Doctor: validating bn-loader config...\n"); + + for (name, profile) in &config.profiles { + out.heading(&format!("Profile '{name}':")); + + checks += 1; + match check_dir(&profile.install_dir) { + Status::Ok => out.success(&format!( + " [OK] install_dir: {}", + profile.install_dir.display() + )), + Status::Warn(msg) => { + out.warn(&format!(" [WARN] install_dir: {msg}")); + warnings += 1; + } + Status::Fail(msg) => { + out.warn(&format!(" [FAIL] install_dir: {msg}")); + failures += 1; + } + } + + let exe_path = profile.install_dir.join(&profile.executable); + checks += 1; + if exe_path.is_file() { + out.success(&format!(" [OK] executable: {}", exe_path.display())); + } else { + out.warn(&format!( + " [FAIL] executable: not found at {}", + exe_path.display() + )); + failures += 1; + } + + checks += 1; + match check_dir(&profile.config_dir) { + Status::Ok => out.success(&format!( + " [OK] config_dir: {}", + profile.config_dir.display() + )), + Status::Warn(msg) => { + out.warn(&format!(" [WARN] config_dir: {msg}")); + warnings += 1; + } + Status::Fail(msg) => { + out.warn(&format!(" [FAIL] config_dir: {msg}")); + failures += 1; + } + } + } + + out.heading("\nGlobal:"); + + checks += 1; + if let Some(seven_zip) = &config.install.seven_zip { + if seven_zip.is_file() { + out.success(&format!( + " [OK] [install] seven_zip: {}", + seven_zip.display() + )); + } else { + out.warn(&format!( + " [FAIL] [install] seven_zip: not a file: {}", + seven_zip.display() + )); + failures += 1; + } + } else { + out.success(" [OK] [install] seven_zip: not set (will fall back to PATH)"); + } + + checks += 1; + if let Some(profiles_dir) = default_profiles_dir() { + if profiles_dir.is_dir() { + let referenced: HashSet<&Path> = config + .profiles + .values() + .map(|p| p.config_dir.as_path()) + .collect(); + let mut orphans: Vec = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&profiles_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() && !referenced.contains(path.as_path()) { + orphans.push(path.display().to_string()); + } + } + } + if orphans.is_empty() { + out.success(&format!( + " [OK] no orphan dirs under {}", + profiles_dir.display() + )); + } else { + out.warn(&format!( + " [WARN] orphan dirs under {} (not referenced by any profile): {}", + profiles_dir.display(), + orphans.join(", ") + )); + warnings += 1; + } + } else { + out.success(&format!( + " [OK] default profiles dir does not exist yet: {}", + profiles_dir.display() + )); + } + } else { + out.warn(" [WARN] could not determine home directory; orphan check skipped"); + warnings += 1; + } + + out.out(&format!( + "Doctor: {} checks, {} warning(s), {} failure(s).", + checks, warnings, failures + )); + + Ok(if failures > 0 { 1 } else { 0 }) +} + +fn check_dir(p: &Path) -> Status { + if !p.exists() { + return Status::Fail(format!("does not exist: {}", p.display())); + } + if !p.is_dir() { + return Status::Fail(format!("exists but is not a directory: {}", p.display())); + } + Status::Ok +} diff --git a/src/fs_util.rs b/src/fs_util.rs new file mode 100644 index 0000000..d08d1e1 --- /dev/null +++ b/src/fs_util.rs @@ -0,0 +1,29 @@ +use anyhow::{Context, Result}; +use std::fs; +use std::path::Path; + +/// Recursively copy `src` to `dst`. If `dst` already exists, it is removed first. +pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { + if dst.exists() { + fs::remove_dir_all(dst).context("Failed to remove existing directory")?; + } + + fs::create_dir_all(dst) + .with_context(|| format!("Failed to create directory {}", dst.display()))?; + + for entry in + fs::read_dir(src).with_context(|| format!("Failed to read directory {}", src.display()))? + { + let entry = entry.context("Failed to read entry")?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + + if src_path.is_dir() { + copy_dir_recursive(&src_path, &dst_path)?; + } else { + fs::copy(&src_path, &dst_path).context("Failed to copy file")?; + } + } + + Ok(()) +} diff --git a/src/init.rs b/src/init.rs index ea3452c..9d7ba7b 100644 --- a/src/init.rs +++ b/src/init.rs @@ -1,6 +1,7 @@ -use crate::config::Config; +use crate::config::{Config, append_profile_to_config}; +use crate::output::Output; +use anyhow::{Context, Result, anyhow, bail}; use std::fs; -use std::io::Write; use std::path::Path; const LICENSE_FILES: &[&str] = &["license.dat", "license.txt"]; @@ -9,62 +10,69 @@ pub(crate) struct InitOptions<'a> { pub name: &'a str, pub template: &'a str, pub config_dir: &'a Path, + pub dry_run: bool, + /// Reserved for future interactive prompts. Currently no-op. + pub yes: bool, } pub(crate) fn run_init( + out: &Output, config: &Config, config_path: &Path, options: &InitOptions, -) -> Result<(), String> { - // Validate template exists +) -> Result<()> { + let _ = options.yes; // Reserved for future prompts. + let template_profile = config .profiles .get(options.template) - .ok_or_else(|| format!("Template profile '{}' not found", options.template))?; + .ok_or_else(|| anyhow!("Template profile '{}' not found", options.template))?; - // Check if profile name already exists if config.profiles.contains_key(options.name) { - return Err(format!("Profile '{}' already exists", options.name)); + bail!("Profile '{}' already exists", options.name); } - // Check if config_dir already exists if options.config_dir.exists() { - return Err(format!( + bail!( "Config directory already exists: {}", options.config_dir.display() - )); + ); } - println!("Initializing profile '{}'...", options.name); - println!(" Template: {}", options.template); - println!(" Install dir: {}", template_profile.install_dir.display()); - println!(" Config dir: {}", options.config_dir.display()); + out.heading(&format!("Initializing profile '{}'...", options.name)); + out.status(&format!(" Template: {}", options.template)); + out.status(&format!( + " Install dir: {}", + template_profile.install_dir.display() + )); + out.status(&format!(" Config dir: {}", options.config_dir.display())); + + if options.dry_run { + out.status("\n[Dry run] No changes made."); + return Ok(()); + } - // Create the config directory - fs::create_dir_all(options.config_dir) - .map_err(|e| format!("Failed to create config directory: {e}"))?; + fs::create_dir_all(options.config_dir).context("Failed to create config directory")?; - // Copy license files from template let mut copied_files = Vec::new(); for license_file in LICENSE_FILES { let src = template_profile.config_dir.join(license_file); if src.exists() { let dst = options.config_dir.join(license_file); - fs::copy(&src, &dst).map_err(|e| format!("Failed to copy {license_file}: {e}"))?; + fs::copy(&src, &dst).with_context(|| format!("Failed to copy {license_file}"))?; copied_files.push(*license_file); } } if copied_files.is_empty() { - eprintln!( + out.warn(&format!( "Warning: No license files found in template profile at {}", template_profile.config_dir.display() - ); + )); } else { - println!(" Copied: {}", copied_files.join(", ")); + out.status(&format!(" Copied: {}", copied_files.join(", "))); } - // Append new profile to config file append_profile_to_config( config_path, options.name, @@ -72,51 +80,14 @@ pub(crate) fn run_init( options.config_dir, )?; - println!("\nProfile '{}' initialized successfully.", options.name); - println!("You can now launch it with: bn-loader {}", options.name); - - Ok(()) -} - -fn append_profile_to_config( - config_path: &Path, - name: &str, - install_dir: &Path, - config_dir: &Path, -) -> Result<(), String> { - // Validate profile name to prevent TOML injection - if !is_valid_profile_name(name) { - return Err(format!( - "Invalid profile name '{name}': must contain only alphanumeric characters, hyphens, and underscores" - )); - } - - let mut file = fs::OpenOptions::new() - .append(true) - .open(config_path) - .map_err(|e| format!("Failed to open config file: {e}"))?; - - // Use toml crate to properly escape path values - let install_str = install_dir.to_string_lossy(); - let config_str = config_dir.to_string_lossy(); - let install_escaped = toml::Value::String(install_str.into_owned()); - let config_escaped = toml::Value::String(config_str.into_owned()); - - let profile_toml = format!( - "\n[profiles.{name}]\ninstall_dir = {install_escaped}\nconfig_dir = {config_escaped}\n" - ); - - file.write_all(profile_toml.as_bytes()) - .map_err(|e| format!("Failed to write to config file: {e}"))?; - - println!(" Added profile to: {}", config_path.display()); + out.success(&format!( + "\nProfile '{}' initialized successfully.", + options.name + )); + out.status(&format!( + "You can now launch it with: bn-loader {}", + options.name + )); Ok(()) } - -fn is_valid_profile_name(name: &str) -> bool { - !name.is_empty() - && name - .chars() - .all(|c| c.is_alphanumeric() || c == '-' || c == '_') -} diff --git a/src/install.rs b/src/install.rs new file mode 100644 index 0000000..1b45372 --- /dev/null +++ b/src/install.rs @@ -0,0 +1,670 @@ +use crate::config::Config; +use anyhow::{Context, Result, anyhow, bail}; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Options for the `install` subcommand. +pub(crate) struct InstallOptions<'a> { + /// Path to the archive on local disk (.zip or .exe). + pub archive: &'a Path, + /// Target installation directory (created if missing). + pub dest: &'a Path, + /// Optional explicit profile name. If None, derived from the basename of `dest`. + pub name: Option<&'a str>, + /// Optional explicit config directory. If None, derived from the (possibly bumped) name. + pub config_dir: Option<&'a Path>, + /// If true, skip profile registration entirely (install is extract-only). + pub no_register: bool, + /// Allow destructive overrides (e.g., extracting on top of a non-empty dest). + pub force: bool, + /// Skip interactive [y/N] confirmation prompts. + pub yes: bool, + /// Print plan and exit without writing anything. + pub dry_run: bool, + /// Optional 7z executable path override (CLI flag). + pub seven_zip: Option<&'a Path>, + /// Path to the active config file (used to append a new profile entry when registering). + pub config_path: &'a Path, +} + +/// Detected archive kind, chosen by file extension. +enum ArchiveType { + Zip, + NsisExe, +} + +fn detect_archive_type(archive: &Path) -> Result { + let ext = archive + .extension() + .and_then(|s| s.to_str()) + .map(str::to_ascii_lowercase); + + match ext.as_deref() { + Some("zip") => Ok(ArchiveType::Zip), + Some("exe") => { + if !cfg!(windows) { + bail!( + "Windows installer .exe archives can only be extracted on Windows (current target lacks 7z + NSIS interop in this tool)" + ); + } + Ok(ArchiveType::NsisExe) + } + Some(other) => bail!("Unsupported archive extension '.{other}': expected .zip or .exe"), + None => bail!("Archive has no extension; cannot detect type"), + } +} + +fn validate_dest( + out: &crate::output::Output, + dest: &Path, + force: bool, + yes: bool, + config: &Config, +) -> Result<()> { + let exists = dest.exists(); + let is_empty = if exists { + if !dest.is_dir() { + bail!( + "Destination exists but is not a directory: {}", + dest.display() + ); + } + std::fs::read_dir(dest) + .with_context(|| format!("Failed to read destination directory {}", dest.display()))? + .next() + .is_none() + } else { + true + }; + + let users = profiles_using_install_dir(config, dest); + let needs_override = !is_empty || !users.is_empty(); + + if !needs_override { + return Ok(()); + } + + if !is_empty { + out.warn(&format!( + "Warning: destination directory is not empty: {}", + dest.display() + )); + out.status( + "Files in the archive will overlay existing entries with the same name. Non-conflicting files are preserved (no pre-clean)." + ); + } + if !users.is_empty() { + out.warn(&format!( + "Install path is currently referenced by {} profile(s): {}", + users.len(), + users.join(", ") + )); + } + + if !is_empty && !force { + bail!( + "Destination is not empty and --force was not given. Pass --force to overlay-extract." + ); + } + + if force && !is_empty { + out.status(&format!( + "(--force) Will overlay-extract on top of non-empty {}", + dest.display() + )); + } + + if yes { + return Ok(()); + } + + if !crate::cli::confirm_prompt("Continue?", true)? { + bail!("Aborted by user."); + } + Ok(()) +} + +/// Sorted list of profile names whose `install_dir` resolves to the same path as `dest`. +/// +/// Matching strategy: prefer `fs::canonicalize` on both paths when both succeed; +/// otherwise fall back to lexical equality of path components. The fallback may miss +/// slightly-different surface forms of the same path -- but the worst case is a missed +/// warning (same as current behavior with no warning at all). +fn profiles_using_install_dir(config: &Config, dest: &Path) -> Vec { + let mut names: Vec = config + .profiles + .iter() + .filter(|(_, profile)| paths_refer_to_same(&profile.install_dir, dest)) + .map(|(name, _)| name.clone()) + .collect(); + names.sort(); + names +} + +/// True if `a` and `b` refer to the same filesystem location, to the extent we can tell +/// without requiring either path to exist. +fn paths_refer_to_same(a: &Path, b: &Path) -> bool { + if let (Ok(ca), Ok(cb)) = (std::fs::canonicalize(a), std::fs::canonicalize(b)) { + return ca == cb; + } + a.components().collect::>() == b.components().collect::>() +} + +pub(crate) fn run_install( + out: &crate::output::Output, + config: &Config, + options: &InstallOptions, +) -> Result<()> { + let archive_type = detect_archive_type(options.archive)?; + validate_dest(out, options.dest, options.force, options.yes, config)?; + + let registration_plan = if options.no_register { + None + } else { + Some(plan_profile_registration(config, options)?) + }; + + if options.dry_run { + out.heading("[Dry run] Install plan:"); + out.status(&format!(" Archive: {}", options.archive.display())); + out.status(&format!(" Dest: {}", options.dest.display())); + if let Some(plan) = ®istration_plan { + out.status(&format!( + " Register profile '{}' (config_dir={})", + plan.name, + plan.config_dir.display() + )); + } else { + out.status(" No profile registration (--no-register)."); + } + out.status("\n[Dry run] No changes made."); + return Ok(()); + } + + match archive_type { + ArchiveType::Zip => extract_zip(out, options.archive, options.dest)?, + ArchiveType::NsisExe => { + let seven_zip = resolve_seven_zip(options.seven_zip, config)?; + extract_nsis(out, options.archive, options.dest, &seven_zip)?; + } + } + + out.success(&format!("\nInstalled to {}", options.dest.display())); + + if let Some(plan) = registration_plan { + fs::create_dir_all(&plan.config_dir).with_context(|| { + format!( + "Failed to create config directory {}", + plan.config_dir.display() + ) + })?; + crate::config::append_profile_to_config( + options.config_path, + &plan.name, + options.dest, + &plan.config_dir, + )?; + if let Some(original) = &plan.original_name { + out.warn(&format!( + "Note: profile name '{}' was already taken; registered as '{}' instead.", + original, plan.name + )); + } + out.success(&format!( + "Registered profile '{}' (install_dir={}, config_dir={})", + plan.name, + options.dest.display(), + plan.config_dir.display() + )); + out.status(&format!("Launch it with: bn-loader {}", plan.name)); + } + + Ok(()) +} + +/// Resolve the path to a 7z executable. +/// +/// Resolution order: +/// 1. Explicit `--seven-zip` CLI flag. +/// 2. `[install] seven_zip` config field. +/// 3. Looked up by name on `$PATH`. +/// +/// If none of these locate an existing executable, returns an error with installation guidance. +fn resolve_seven_zip(cli_flag: Option<&Path>, config: &Config) -> Result { + if let Some(p) = cli_flag { + if p.is_file() { + return Ok(p.to_path_buf()); + } + bail!("--seven-zip path does not point to a file: {}", p.display()); + } + + if let Some(p) = config.install.seven_zip.as_deref() { + if p.is_file() { + return Ok(p.to_path_buf()); + } + bail!( + "[install] seven_zip path in config does not point to a file: {}", + p.display() + ); + } + + let candidate = if cfg!(windows) { "7z.exe" } else { "7z" }; + if let Some(found) = which(candidate) { + return Ok(found); + } + + Err(anyhow!( + "Could not find 7z executable. Install 7-Zip (https://www.7-zip.org/) and either put it on PATH, pass --seven-zip , or set [install] seven_zip in your config." + )) +} + +/// Minimal $PATH lookup for an executable name. Avoids pulling in a `which` crate dep. +fn which(name: &str) -> Option { + let path_env = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path_env) { + let candidate = dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +/// Extract a ZIP archive into `dest`. +/// +/// If the archive has exactly one top-level entry and that entry is a directory, +/// it is stripped — the directory's children land directly under `dest`. This matches +/// the Linux Binary Ninja bundle layout (`binaryninja/...`) and the user's intuition +/// that `--dest /opt/binaryninja-personal` should produce `/opt/binaryninja-personal/binaryninja`, +/// not `/opt/binaryninja-personal/binaryninja/binaryninja`. +fn extract_zip(out: &crate::output::Output, archive: &Path, dest: &Path) -> Result<()> { + let file = fs::File::open(archive) + .with_context(|| format!("Failed to open archive {}", archive.display()))?; + let mut zip = zip::ZipArchive::new(file).context("Failed to read ZIP archive")?; + + fs::create_dir_all(dest) + .with_context(|| format!("Failed to create destination directory {}", dest.display()))?; + + let strip_prefix = detect_zip_strip_prefix(&mut zip)?; + + out.status(&format!( + "Extracting {} entries from {}{}", + zip.len(), + archive.display(), + if let Some(p) = &strip_prefix { + format!(" (stripping leading '{}/')", p.display()) + } else { + String::new() + } + )); + + for i in 0..zip.len() { + let mut entry = zip + .by_index(i) + .with_context(|| format!("Failed to read entry {i} from archive"))?; + + // Use enclosed_name to defeat directory-traversal attempts. + let raw_name = match entry.enclosed_name() { + Some(n) => n, + None => continue, // unsafe path (e.g., absolute or contains ..) — skip silently + }; + + let relative = match &strip_prefix { + Some(prefix) => match raw_name.strip_prefix(prefix) { + Ok(p) if p.as_os_str().is_empty() => continue, // the stripped dir entry itself + Ok(p) => p.to_path_buf(), + Err(_) => raw_name.clone(), // shouldn't happen if detect_zip_strip_prefix is correct + }, + None => raw_name.clone(), + }; + + let out_path = dest.join(&relative); + + if entry.is_dir() { + fs::create_dir_all(&out_path) + .with_context(|| format!("Failed to create directory {}", out_path.display()))?; + continue; + } + + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("Failed to create parent directory {}", parent.display()) + })?; + } + + let mut out_file = fs::File::create(&out_path) + .with_context(|| format!("Failed to create output file {}", out_path.display()))?; + io::copy(&mut entry, &mut out_file) + .with_context(|| format!("Failed to write {}", out_path.display()))?; + + // Preserve unix executable bit when present (binaryninja, crashpad_handler, scc, etc.). + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Some(mode) = entry.unix_mode() { + let perms = std::fs::Permissions::from_mode(mode); + let _ = std::fs::set_permissions(&out_path, perms); + } + } + } + + Ok(()) +} + +/// If the archive has exactly one unique top-level path component AND no entry uses +/// that component as its full path (i.e., it's only ever the parent of nested entries), +/// return that component. Otherwise return None. +/// +/// Many ZIPs omit explicit directory entries — only the leaf files are listed, with +/// directory paths inferred from the slashes. So we cannot rely on finding an explicit +/// `binaryninja/` directory entry; we have to detect the wrapper by structure. +fn detect_zip_strip_prefix(zip: &mut zip::ZipArchive) -> Result> { + use std::collections::HashSet; + + let mut top_level: HashSet = HashSet::new(); + let mut top_level_appears_as_file = false; + + for i in 0..zip.len() { + let entry = zip.by_index(i).with_context(|| { + format!("Failed to read entry {i} while detecting top-level prefix") + })?; + let Some(name) = entry.enclosed_name() else { + continue; + }; + let mut components = name.components(); + let Some(first) = components.next() else { + continue; + }; + let first_path = PathBuf::from(first.as_os_str()); + + // If this entry IS the top-level (no further components) AND it's a file + // (not a directory entry), then the top-level isn't a directory wrapper — + // it's a real file at the archive root. Don't strip. + let has_more = components.next().is_some(); + if !has_more && !entry.is_dir() { + top_level_appears_as_file = true; + } + + top_level.insert(first_path); + + if top_level.len() > 1 { + return Ok(None); + } + } + + if top_level.len() == 1 && !top_level_appears_as_file { + let only = top_level.into_iter().next().unwrap(); + return Ok(Some(only)); + } + + Ok(None) +} + +/// Names (or directory roots) extracted from the NSIS installer that are NOT part of +/// the actual Binary Ninja install. Filtered out before moving to dest. +/// +/// Verified against `binaryninja_win64_5.3.9434_personal.exe` via `7z l`. Includes: +/// - `$PLUGINSDIR/`: NSIS internal staging directory. +/// - `*.bmp`, `icon.ico`: installer chrome/branding. +/// - `vc_redist*.exe`, `vcredist*.exe`: VC++ redistributables run by the installer. +fn is_nsis_artifact(top_name: &str) -> bool { + if top_name == "$PLUGINSDIR" { + return true; + } + if top_name.eq_ignore_ascii_case("icon.ico") { + return true; + } + let lower = top_name.to_ascii_lowercase(); + if lower.ends_with(".bmp") { + return true; + } + if lower.starts_with("vc_redist") && lower.ends_with(".exe") { + return true; + } + if lower.starts_with("vcredist") && lower.ends_with(".exe") { + return true; + } + false +} + +/// Extract a Windows NSIS installer EXE into `dest`. +/// +/// Strategy: +/// 1. Extract the EXE into a fresh temp directory using 7z (`7z x -o -y `). +/// 2. Walk the temp dir's top-level entries, filter out NSIS-only artifacts +/// (see `is_nsis_artifact`), and move the survivors into `dest`. +/// 3. Clean up the temp directory (handled by `tempfile::TempDir`'s Drop impl). +fn extract_nsis( + out: &crate::output::Output, + archive: &Path, + dest: &Path, + seven_zip: &Path, +) -> Result<()> { + let temp = tempfile::Builder::new() + .prefix("bn-loader-install-") + .tempdir() + .context("Failed to create temporary directory for NSIS extraction")?; + let temp_path = temp.path(); + + out.status(&format!( + "Extracting {} via 7z into {} (will filter NSIS artifacts before moving to {})", + archive.display(), + temp_path.display(), + dest.display() + )); + + let status = Command::new(seven_zip) + .arg("x") + .arg(archive) + .arg(format!("-o{}", temp_path.display())) + .arg("-y") + .status() + .with_context(|| format!("Failed to invoke 7z at {}", seven_zip.display()))?; + + if !status.success() { + bail!( + "7z exited with status {}: {} extraction failed", + status, + archive.display() + ); + } + + fs::create_dir_all(dest) + .with_context(|| format!("Failed to create destination directory {}", dest.display()))?; + + let mut moved = 0usize; + let mut skipped: Vec = Vec::new(); + + for entry in fs::read_dir(temp_path) + .with_context(|| format!("Failed to read temp dir {}", temp_path.display()))? + { + let entry = entry.context("Failed to read temp dir entry")?; + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + if is_nsis_artifact(&name_str) { + skipped.push(name_str.into_owned()); + continue; + } + + let src_path = entry.path(); + let dst_path = dest.join(&name); + + // If a same-named entry exists at dest (e.g., --force overlay), remove it first + // so rename-or-copy semantics are predictable. + if dst_path.exists() { + if dst_path.is_dir() { + fs::remove_dir_all(&dst_path) + .with_context(|| format!("Failed to remove existing {}", dst_path.display()))?; + } else { + fs::remove_file(&dst_path) + .with_context(|| format!("Failed to remove existing {}", dst_path.display()))?; + } + } + + // Try a same-volume rename first (fast). Fall back to recursive copy if it + // crosses filesystems (typical when temp is on a different volume than dest). + match fs::rename(&src_path, &dst_path) { + Ok(()) => {} + Err(_) => { + if src_path.is_dir() { + crate::fs_util::copy_dir_recursive(&src_path, &dst_path)?; + } else { + if let Some(parent) = dst_path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("Failed to create parent directory {}", parent.display()) + })?; + } + fs::copy(&src_path, &dst_path).with_context(|| { + format!( + "Failed to copy {} to {}", + src_path.display(), + dst_path.display() + ) + })?; + } + } + } + moved += 1; + } + + out.status(&format!( + "Moved {} top-level entries; skipped {} installer artifact(s){}", + moved, + skipped.len(), + if skipped.is_empty() { + String::new() + } else { + format!(": {}", skipped.join(", ")) + } + )); + + // `temp` goes out of scope here and TempDir cleans up the directory. + drop(temp); + Ok(()) +} + +/// Plan for registering a new profile after extraction. +struct RegistrationPlan { + name: String, + config_dir: PathBuf, + /// If the auto-bump path was taken (both name and config_dir derived) and the + /// originally-derived name was already in use, this is the original derived name — + /// used only for the "registered as X instead" message. + original_name: Option, +} + +/// Compute what name and config dir should be used for the new profile entry, +/// honoring the user's explicit overrides and applying defaults / collision avoidance +/// where appropriate. Does NOT write anything — purely a plan. +/// +/// The only uniqueness constraint is the profile name. install_dir and config_dir +/// can be shared across multiple profiles (intentional use cases include two BN +/// installations sharing a config_dir for plugins/settings parity). +fn plan_profile_registration( + config: &Config, + options: &InstallOptions, +) -> Result { + let derived_name = derive_default_name(options.dest)?; + let name_was_explicit = options.name.is_some(); + let cfg_was_explicit = options.config_dir.is_some(); + + let initial_name = options.name.map(str::to_string).unwrap_or(derived_name); + let initial_cfg = match options.config_dir { + Some(p) => p.to_path_buf(), + None => default_config_dir_for(&initial_name)?, + }; + + if name_was_explicit || cfg_was_explicit { + // Strict mode: never auto-modify what the user explicitly typed. + // Profile name is the only uniqueness constraint — config_dir reuse across + // profiles is intentionally allowed. + if config.profiles.contains_key(&initial_name) { + bail!( + "Profile '{}' already exists in the config. Pick a different --name or remove the existing entry.", + initial_name + ); + } + return Ok(RegistrationPlan { + name: initial_name, + config_dir: initial_cfg, + original_name: None, + }); + } + + // Both derived: auto-bump only on profile-name collision. + let original = initial_name.clone(); + for i in 1..=999 { + let candidate_name = if i == 1 { + initial_name.clone() + } else { + format!("{original}-{i}") + }; + if !config.profiles.contains_key(&candidate_name) { + let candidate_cfg = default_config_dir_for(&candidate_name)?; + return Ok(RegistrationPlan { + name: candidate_name.clone(), + config_dir: candidate_cfg, + original_name: if candidate_name == original { + None + } else { + Some(original) + }, + }); + } + } + bail!( + "Could not find an unused profile name starting with '{}' after 999 attempts. Pass --name explicitly.", + original + ) +} + +/// Derive a default profile name from the basename of `dest`. +/// +/// Sanitizes by replacing any character that isn't alphanumeric, hyphen, or underscore +/// with `_`. Errors if the sanitized result is empty or otherwise invalid per +/// `crate::config::is_valid_profile_name`. +fn derive_default_name(dest: &Path) -> Result { + let basename = dest + .file_name() + .and_then(|s| s.to_str()) + .ok_or_else(|| { + anyhow!( + "Cannot derive a default profile name from --dest path '{}': no usable basename. Pass --name explicitly.", + dest.display() + ) + })?; + + let sanitized: String = basename + .chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect(); + + if !crate::config::is_valid_profile_name(&sanitized) { + bail!( + "Cannot derive a valid profile name from --dest basename '{}'. Pass --name explicitly.", + basename + ); + } + + Ok(sanitized) +} + +/// Compute the default config dir for a given profile name. +fn default_config_dir_for(name: &str) -> Result { + crate::config::default_profiles_dir() + .ok_or_else(|| { + anyhow!( + "Cannot determine home directory for default --config-dir. Pass --config-dir explicitly." + ) + }) + .map(|d| d.join(name)) +} diff --git a/src/launch.rs b/src/launch.rs index d38667a..df7abaf 100644 --- a/src/launch.rs +++ b/src/launch.rs @@ -1,4 +1,5 @@ use crate::config::{ENV_VAR_NAME, Profile}; +use anyhow::{Context, Result, anyhow}; use std::path::{Path, PathBuf}; use std::process::Command; @@ -11,25 +12,26 @@ pub(crate) struct LaunchOptions<'a> { } pub(crate) fn launch_profile( + out: &crate::output::Output, name: &str, profile: &Profile, options: &LaunchOptions, -) -> Result<(), String> { +) -> Result<()> { let exe_path = profile.install_dir.join(&profile.executable); if !profile.install_dir.exists() { - return Err(format!( + return Err(anyhow!( "Install directory does not exist: {}", profile.install_dir.display() )); } if !exe_path.exists() { - return Err(format!("Executable not found: {}", exe_path.display())); + return Err(anyhow!("Executable not found: {}", exe_path.display())); } if !profile.config_dir.exists() { - return Err(format!( + return Err(anyhow!( "Config directory does not exist: {}", profile.config_dir.display() )); @@ -37,35 +39,40 @@ pub(crate) fn launch_profile( let use_debug = options.debug || profile.debug; - println!("Launching profile '{name}'..."); - println!(" Install dir: {}", profile.install_dir.display()); - println!(" Config dir: {}", profile.config_dir.display()); - println!(" Executable: {}", profile.executable); + out.heading(&format!("Launching profile '{name}'...")); + out.status(&format!(" Install dir: {}", profile.install_dir.display())); + out.status(&format!(" Config dir: {}", profile.config_dir.display())); + out.status(&format!(" Executable: {}", profile.executable)); if use_debug { - launch_debug(profile, &exe_path, options) + launch_debug(out, profile, &exe_path, options) } else { launch_normal(profile, &exe_path) } } -fn launch_normal(profile: &Profile, exe_path: &Path) -> Result<(), String> { +fn launch_normal(profile: &Profile, exe_path: &Path) -> Result<()> { Command::new(exe_path) .current_dir(&profile.install_dir) .env(ENV_VAR_NAME, &profile.config_dir) .spawn() - .map_err(|e| format!("Failed to launch Binary Ninja: {e}"))?; + .context("Failed to launch Binary Ninja")?; Ok(()) } -fn launch_debug(profile: &Profile, exe_path: &Path, options: &LaunchOptions) -> Result<(), String> { +fn launch_debug( + out: &crate::output::Output, + profile: &Profile, + exe_path: &Path, + options: &LaunchOptions, +) -> Result<()> { let log_path = options .log_file .cloned() .unwrap_or_else(|| profile.config_dir.join(DEBUG_LOG_FILENAME)); - println!(" Debug mode: enabled"); - println!(" Log file: {}", log_path.display()); + out.status(" Debug mode: enabled"); + out.status(&format!(" Log file: {}", log_path.display())); // Use Binary Ninja's native debug flags: -d for debug mode, -l for log file let child = Command::new(exe_path) @@ -75,19 +82,22 @@ fn launch_debug(profile: &Profile, exe_path: &Path, options: &LaunchOptions) -> .arg("-l") .arg(&log_path) .spawn() - .map_err(|e| format!("Failed to launch Binary Ninja: {e}"))?; + .context("Failed to launch Binary Ninja")?; - println!("\nBinary Ninja launched (PID: {}).", child.id()); - println!("Debug logs will be written to: {}", log_path.display()); + out.success(&format!("\nBinary Ninja launched (PID: {}).", child.id())); + out.status(&format!( + "Debug logs will be written to: {}", + log_path.display() + )); #[cfg(windows)] - println!( + out.status(&format!( "\nTo monitor: Get-Content -Path \"{}\" -Wait", log_path.display() - ); + )); #[cfg(not(windows))] - println!("\nTo monitor: tail -f \"{}\"", log_path.display()); + out.status(&format!("\nTo monitor: tail -f \"{}\"", log_path.display())); Ok(()) } diff --git a/src/main.rs b/src/main.rs index 7b4044f..a286b43 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,25 +1,35 @@ -mod colors; +mod cli; mod completions; mod config; mod diff; +mod doctor; +mod fs_util; mod init; +mod install; mod launch; +mod output; mod plugins; +mod remove; mod sync; mod update; use clap::{CommandFactory, Parser, Subcommand, ValueEnum}; use clap_complete::CompleteEnv; use clap_complete::engine::{ArgValueCandidates, CompletionCandidate}; +use cli::{report_and_exit, resolve_profile}; use config::{CONFIG_FILE_NAME, Config, find_config_file, load_config}; use diff::diff_profiles; +use doctor::run_doctor; use init::{InitOptions, run_init}; +use install::{InstallOptions, run_install}; use launch::{LaunchOptions, launch_profile}; use plugins::{list_plugins, print_plugins}; +use remove::{RemoveOptions, run_remove}; use std::env; use std::path::{Path, PathBuf}; use std::process; use sync::{SyncOptions, run_sync}; +use update::{UpdateOptions, run_update}; /// Get profile names from config for shell completion fn profile_completer() -> Vec { @@ -29,6 +39,14 @@ fn profile_completer() -> Vec { .unwrap_or_default() } +/// CLI flag wins over config; config wins over the default (Auto). +fn effective_color( + cli_color: Option, + config_color: config::ColorMode, +) -> config::ColorMode { + cli_color.unwrap_or(config_color) +} + #[derive(Parser)] #[command(name = "bn-loader", version, about = "Binary Ninja profile launcher")] struct Cli { @@ -39,6 +57,10 @@ struct Cli { #[arg(long, short = 'c', global = true)] config: Option, + /// Color output mode (overrides [global] color in config) + #[arg(long, value_enum, global = true)] + color: Option, + /// List available profiles #[arg(long, short = 'l')] list: bool, @@ -54,74 +76,128 @@ struct Cli { /// Write debug output to file #[arg(long)] log_file: Option, - - /// Check for updates and exit - #[arg(long)] - check_update: bool, } #[derive(Subcommand)] enum Commands { + /// Profile management commands (init, install, sync, diff, plugins, list) + Profile { + #[command(subcommand)] + subcommand: ProfileCommand, + }, + + /// Validate the whole config (read-only) + Doctor, + + /// Generate shell completions + Completions { + /// Shell type + #[arg(value_enum)] + shell: ShellType, + }, +} + +#[derive(Subcommand)] +enum ProfileCommand { + /// List available profiles + List, + /// Create a new profile from a template Init { - /// Name for the new profile name: String, - - /// Source profile for license and `install_dir` #[arg(long, add = ArgValueCandidates::new(profile_completer))] template: String, - - /// Directory for new profile's config #[arg(long)] config_dir: PathBuf, + #[arg(long)] + dry_run: bool, + #[arg(long, short = 'y')] + yes: bool, }, /// Sync config between profiles Sync { - /// Source profile to sync from #[arg(long, add = ArgValueCandidates::new(profile_completer))] from: String, - - /// Target profile (default: all other profiles) #[arg(long, add = ArgValueCandidates::new(profile_completer))] to: Option, - - /// Additional exclusion pattern (can be repeated) #[arg(long, action = clap::ArgAction::Append)] exclude: Vec, - - /// Show what would be synced without changes #[arg(long)] dry_run: bool, - - /// Skip confirmation prompt - #[arg(long, short)] + #[arg(long, short = 'y')] yes: bool, + #[arg(long)] + force: bool, }, /// List plugins for a profile Plugins { - /// Profile name #[arg(add = ArgValueCandidates::new(profile_completer))] profile: String, }, /// Compare two profiles Diff { - /// First profile #[arg(add = ArgValueCandidates::new(profile_completer))] profile1: String, - - /// Second profile #[arg(add = ArgValueCandidates::new(profile_completer))] profile2: String, }, - /// Generate shell completions - Completions { - /// Shell type - #[arg(value_enum)] - shell: ShellType, + /// Install Binary Ninja from an archive and register a profile + Install { + archive: PathBuf, + #[arg(long)] + dest: PathBuf, + #[arg(long)] + name: Option, + #[arg(long)] + config_dir: Option, + #[arg(long, conflicts_with_all = ["name", "config_dir"])] + no_register: bool, + #[arg(long, short = 'f')] + force: bool, + #[arg(long, short = 'y')] + yes: bool, + #[arg(long)] + dry_run: bool, + #[arg(long)] + seven_zip: Option, + }, + + /// Remove a profile (deregister from config; --purge to also delete on-disk dirs) + Remove { + #[arg(add = ArgValueCandidates::new(profile_completer))] + name: String, + /// Also delete the profile's config_dir on disk + #[arg(long)] + purge: bool, + /// With --purge, also delete the profile's install_dir + #[arg(long, short = 'f')] + force: bool, + /// Skip the confirmation prompt + #[arg(long, short = 'y')] + yes: bool, + /// Print what would be removed and exit + #[arg(long)] + dry_run: bool, + }, + + /// Re-extract Binary Ninja into an existing profile's install_dir + Update { + #[arg(add = ArgValueCandidates::new(profile_completer))] + name: String, + archive: PathBuf, + /// Skip interactive confirmation prompts + #[arg(long, short = 'y')] + yes: bool, + /// Print plan and exit without making changes + #[arg(long)] + dry_run: bool, + /// Override 7z executable path (for NSIS .exe extraction) + #[arg(long)] + seven_zip: Option, }, } @@ -133,10 +209,10 @@ pub enum ShellType { Fish, } -fn list_profiles_cmd(config: &Config) { - println!("Available profiles:"); +fn list_profiles_cmd(out: &output::Output, config: &Config) { + out.heading("Available profiles:"); for (name, profile) in &config.profiles { - println!(" {} -> {}", name, profile.install_dir.display()); + out.out(&format!(" {} -> {}", name, profile.install_dir.display())); } } @@ -190,43 +266,81 @@ fn main() { return; } - // Manual update check (doesn't require config) - if cli.check_update { - println!("Checking for updates..."); - println!("Current version: {}", env!("CARGO_PKG_VERSION")); - match update::check_for_updates_forced() { - Some(info) => { - println!("Update available: v{} -> v{}", info.current, info.latest); - println!("Download: {}", info.url); - } - None => { - println!("You're on the latest version."); - } - } - return; - } - // All other commands need config - let (config_path, config) = load_config_or_exit(cli.config.as_deref()); - - // Check for updates (non-blocking, silent on error) - if config.global.check_updates - && let Some(update_info) = update::check_for_updates() - { - update::print_update_notice(&update_info); - } + let (config_path, mut config) = load_config_or_exit(cli.config.as_deref()); + config.global.color = effective_color(cli.color, config.global.color); if cli.list { - list_profiles_cmd(&config); + let out = output::Output::new(config.global.color); + list_profiles_cmd(&out, &config); return; } match cli.command { - Some(Commands::Init { + Some(Commands::Profile { subcommand }) => { + dispatch_profile(&config, &config_path, subcommand); + } + Some(Commands::Doctor) => { + let out = output::Output::new(config.global.color); + match run_doctor(&out, &config) { + Ok(code) => process::exit(code), + Err(e) => { + eprintln!("Error: {e}"); + process::exit(1); + } + } + } + Some(Commands::Completions { .. }) => { + // Already handled above + unreachable!() + } + None => { + // Launch profile mode + let name = match cli.profile { + Some(n) => n, + None => { + if let Some(default) = &config.global.default_profile { + default.clone() + } else { + eprintln!("Error: No profile specified."); + eprintln!("Use --list to see available profiles, or --help for usage."); + eprintln!( + "Tip: Set global.default_profile in config to launch without arguments." + ); + process::exit(1); + } + } + }; + + let use_debug = cli.debug || config.global.debug; + let log_file = cli.log_file.clone(); + let out = output::Output::new(config.global.color); + let result = resolve_profile(&config, &name).and_then(|profile| { + let options = LaunchOptions { + debug: use_debug, + log_file: log_file.as_ref(), + }; + launch_profile(&out, &name, profile, &options) + }); + report_and_exit(result, use_debug); + } + } +} + +fn dispatch_profile(config: &Config, config_path: &Path, subcommand: ProfileCommand) -> ! { + let out = output::Output::new(config.global.color); + match subcommand { + ProfileCommand::List => { + list_profiles_cmd(&out, config); + process::exit(0); + } + ProfileCommand::Init { name, template, config_dir, - }) => { + dry_run, + yes, + } => { let expanded_config_dir = if config_dir.is_relative() { env::current_dir() .map(|cwd| cwd.join(&config_dir)) @@ -234,25 +348,26 @@ fn main() { } else { config_dir }; - let options = InitOptions { name: &name, template: &template, config_dir: &expanded_config_dir, + dry_run, + yes, }; - if let Err(e) = run_init(&config, &config_path, &options) { - eprintln!("Error: {e}"); - process::exit(1); - } + report_and_exit( + run_init(&out, config, config_path, &options), + config.global.debug, + ); } - - Some(Commands::Sync { + ProfileCommand::Sync { from, to, exclude, dry_run, yes, - }) => { + force, + } => { let extra_exclusions: Vec<&str> = exclude.iter().map(std::string::String::as_str).collect(); let options = SyncOptions { @@ -261,92 +376,88 @@ fn main() { extra_exclusions, dry_run, yes, + force, backup_retention: config.global.backup_retention, }; - if let Err(e) = run_sync(&config, &options) { - eprintln!("Error: {e}"); - process::exit(1); - } + report_and_exit(run_sync(&out, config, &options), config.global.debug); } - - Some(Commands::Plugins { profile }) => { - let prof = if let Some(p) = config.profiles.get(&profile) { - p - } else { - eprintln!("Error: Profile '{profile}' not found."); - process::exit(1); - }; - match list_plugins(prof) { - Ok(plugins) => print_plugins(&profile, &plugins), - Err(e) => { - eprintln!("Error: {e}"); - process::exit(1); - } - } - } - - Some(Commands::Diff { profile1, profile2 }) => { - let prof1 = if let Some(p) = config.profiles.get(&profile1) { - p - } else { - eprintln!("Error: Profile '{profile1}' not found."); - process::exit(1); - }; - let prof2 = if let Some(p) = config.profiles.get(&profile2) { - p - } else { - eprintln!("Error: Profile '{profile2}' not found."); - process::exit(1); - }; - if let Err(e) = diff_profiles(&profile1, prof1, &profile2, prof2) { - eprintln!("Error: {e}"); - process::exit(1); - } + ProfileCommand::Plugins { profile } => { + let result = resolve_profile(config, &profile).and_then(|prof| { + let plugins = list_plugins(prof)?; + print_plugins(&out, &profile, &plugins); + Ok(()) + }); + report_and_exit(result, config.global.debug); } - - Some(Commands::Completions { .. }) => { - // Already handled above - unreachable!() + ProfileCommand::Diff { profile1, profile2 } => { + let result = resolve_profile(config, &profile1).and_then(|prof1| { + let prof2 = resolve_profile(config, &profile2)?; + diff_profiles(config, &profile1, prof1, &profile2, prof2) + }); + report_and_exit(result, config.global.debug); } - - None => { - // Launch profile mode - let name = match cli.profile { - Some(n) => n, - None => { - // Try default profile from global config - if let Some(default) = &config.global.default_profile { - default.clone() - } else { - eprintln!("Error: No profile specified."); - eprintln!("Use --list to see available profiles, or --help for usage."); - eprintln!( - "Tip: Set global.default_profile in config to launch without arguments." - ); - process::exit(1); - } - } + ProfileCommand::Install { + archive, + dest, + name, + config_dir, + no_register, + force, + yes, + dry_run, + seven_zip, + } => { + let options = InstallOptions { + archive: &archive, + dest: &dest, + name: name.as_deref(), + config_dir: config_dir.as_deref(), + no_register, + force, + yes, + dry_run, + seven_zip: seven_zip.as_deref(), + config_path, }; - - let profile = if let Some(p) = config.profiles.get(&name) { - p - } else { - eprintln!("Error: Profile '{name}' not found."); - eprintln!("Use --list to see available profiles."); - process::exit(1); + report_and_exit(run_install(&out, config, &options), config.global.debug); + } + ProfileCommand::Remove { + name, + purge, + force, + yes, + dry_run, + } => { + let options = RemoveOptions { + name: &name, + purge, + force, + yes, + dry_run, }; - - // Combine CLI debug flag with global debug setting - let use_debug = cli.debug || config.global.debug; - - let options = LaunchOptions { - debug: use_debug, - log_file: cli.log_file.as_ref(), + report_and_exit( + run_remove(&out, config, config_path, &options), + config.global.debug, + ); + } + ProfileCommand::Update { + name, + archive, + yes, + dry_run, + seven_zip, + } => { + let options = UpdateOptions { + name: &name, + archive: &archive, + yes, + dry_run, + seven_zip: seven_zip.as_deref(), }; - if let Err(e) = launch_profile(&name, profile, &options) { - eprintln!("Error: {e}"); - process::exit(1); - } + report_and_exit( + run_update(&out, config, config_path, &options), + config.global.debug, + ); } } } diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..921adcb --- /dev/null +++ b/src/output.rs @@ -0,0 +1,90 @@ +#![allow(dead_code)] // Helpers consumed by later UX-pass tasks. + +use crate::config::ColorMode; +use std::io::{self, Write}; +use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor}; + +/// Owns the per-invocation color preference. Constructed once at the top of a +/// subcommand handler and threaded through the helpers below. +pub(crate) struct Output { + mode: ColorMode, +} + +impl Output { + pub(crate) fn new(mode: ColorMode) -> Self { + Self { mode } + } + + fn stderr(&self) -> StandardStream { + StandardStream::stderr(self.mode.into()) + } + + fn stdout(&self) -> StandardStream { + StandardStream::stdout(self.mode.into()) + } + + /// Bold heading line on stderr (e.g., "Sync Plan", "=== Plugins ==="). + /// Status decoration, not subcommand result data, so → stderr. + pub(crate) fn heading(&self, text: &str) { + let mut s = self.stderr(); + let _ = s.set_color(ColorSpec::new().set_bold(true)); + let _ = writeln!(s, "{text}"); + let _ = s.reset(); + } + + /// Indented status/info line on stderr. Default color. + pub(crate) fn status(&self, text: &str) { + let mut s = self.stderr(); + let _ = writeln!(s, "{text}"); + } + + /// Indented warning line on stderr, yellow when colors enabled. + pub(crate) fn warn(&self, text: &str) { + let mut s = self.stderr(); + let _ = s.set_color(ColorSpec::new().set_fg(Some(Color::Yellow))); + let _ = writeln!(s, "{text}"); + let _ = s.reset(); + } + + /// Indented success line on stderr, green when colors enabled. + pub(crate) fn success(&self, text: &str) { + let mut s = self.stderr(); + let _ = s.set_color(ColorSpec::new().set_fg(Some(Color::Green))); + let _ = writeln!(s, "{text}"); + let _ = s.reset(); + } + + /// Subcommand result data on stdout. Plain (no color decoration) — pipeable. + pub(crate) fn out(&self, text: &str) { + let mut s = io::stdout(); + let _ = writeln!(s, "{text}"); + } + + /// Colored result data on stdout (used for diff entries: + green, - red, ~ yellow). + /// Most callers want `out` instead. + pub(crate) fn out_colored(&self, text: &str, color: Color) { + let mut s = self.stdout(); + let _ = s.set_color(ColorSpec::new().set_fg(Some(color))); + let _ = writeln!(s, "{text}"); + let _ = s.reset(); + } + + /// Bold-emphasis text on stdout without a trailing newline. Used by diff for the + /// "Comparing profiles: X vs Y" prefix where we want bold + plain on the same line. + pub(crate) fn out_bold_inline(&self, text: &str) { + let mut s = self.stdout(); + let _ = s.set_color(ColorSpec::new().set_bold(true)); + let _ = write!(s, "{text}"); + let _ = s.reset(); + } +} + +impl From for ColorChoice { + fn from(mode: ColorMode) -> Self { + match mode { + ColorMode::Auto => ColorChoice::Auto, + ColorMode::Always => ColorChoice::Always, + ColorMode::Never => ColorChoice::Never, + } + } +} diff --git a/src/plugins.rs b/src/plugins.rs index 4454d03..2c1ab91 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -1,4 +1,5 @@ use crate::config::Profile; +use anyhow::{Context, Result}; use serde::Deserialize; use std::fs; use std::path::Path; @@ -60,7 +61,7 @@ pub(crate) struct PluginInfo { pub source: PluginSource, } -pub(crate) fn list_plugins(profile: &Profile) -> Result, String> { +pub(crate) fn list_plugins(profile: &Profile) -> Result> { let mut plugins = Vec::new(); // 1. Manual plugins from plugins/ directory @@ -87,14 +88,13 @@ pub(crate) fn list_plugins(profile: &Profile) -> Result, String> Ok(plugins) } -fn read_manual_plugins(plugins_dir: &Path) -> Result, String> { +fn read_manual_plugins(plugins_dir: &Path) -> Result> { let mut plugins = Vec::new(); - let entries = - fs::read_dir(plugins_dir).map_err(|e| format!("Failed to read plugins directory: {e}"))?; + let entries = fs::read_dir(plugins_dir).context("Failed to read plugins directory")?; for entry in entries { - let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?; + let entry = entry.context("Failed to read entry")?; let path = entry.path(); if !path.is_dir() { @@ -134,12 +134,11 @@ fn read_plugin_metadata(plugin_dir: &Path, dir_name: &str) -> PluginInfo { } } -fn read_repo_plugins(status_file: &Path) -> Result, String> { - let content = fs::read_to_string(status_file) - .map_err(|e| format!("Failed to read plugin_status.json: {e}"))?; +fn read_repo_plugins(status_file: &Path) -> Result> { + let content = fs::read_to_string(status_file).context("Failed to read plugin_status.json")?; - let repos: PluginStatusFile = serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse plugin_status.json: {e}"))?; + let repos: PluginStatusFile = + serde_json::from_str(&content).context("Failed to parse plugin_status.json")?; let mut plugins = Vec::new(); @@ -168,9 +167,15 @@ fn read_repo_plugins(status_file: &Path) -> Result, String> { Ok(plugins) } -pub(crate) fn print_plugins(profile_name: &str, plugins: &[PluginInfo]) { +pub(crate) fn print_plugins( + out: &crate::output::Output, + profile_name: &str, + plugins: &[PluginInfo], +) { if plugins.is_empty() { - println!("No plugins installed for profile '{profile_name}'"); + out.status(&format!( + "No plugins installed for profile '{profile_name}'" + )); return; } @@ -187,35 +192,38 @@ pub(crate) fn print_plugins(profile_name: &str, plugins: &[PluginInfo]) { .filter(|p| matches!(p.source, PluginSource::Community)) .collect(); - println!( + out.heading(&format!( "Plugins for profile '{}' ({} total):", profile_name, plugins.len() - ); + )); if !official.is_empty() { - println!("\n [Official Repository] ({}):", official.len()); + out.heading(&format!("\n [Official Repository] ({}):", official.len())); for plugin in &official { - print_plugin_line(plugin); + print_plugin_line(out, plugin); } } if !community.is_empty() { - println!("\n [Community Repository] ({}):", community.len()); + out.heading(&format!( + "\n [Community Repository] ({}):", + community.len() + )); for plugin in &community { - print_plugin_line(plugin); + print_plugin_line(out, plugin); } } if !manual.is_empty() { - println!("\n [Manual] ({}):", manual.len()); + out.heading(&format!("\n [Manual] ({}):", manual.len())); for plugin in &manual { - print_plugin_line(plugin); + print_plugin_line(out, plugin); } } } -fn print_plugin_line(plugin: &PluginInfo) { +fn print_plugin_line(out: &crate::output::Output, plugin: &PluginInfo) { let display_name = plugin.name.as_deref().unwrap_or(&plugin.dir_name); let version = plugin.version.as_deref().unwrap_or("?"); let author = plugin @@ -224,5 +232,5 @@ fn print_plugin_line(plugin: &PluginInfo) { .map(|a| format!(" by {a}")) .unwrap_or_default(); - println!(" {display_name} v{version}{author}"); + out.out(&format!(" {display_name} v{version}{author}")); } diff --git a/src/remove.rs b/src/remove.rs new file mode 100644 index 0000000..abb1fbd --- /dev/null +++ b/src/remove.rs @@ -0,0 +1,136 @@ +use crate::config::{Config, remove_profile_from_config}; +use crate::output::Output; +use anyhow::{Context, Result, bail}; +use std::path::Path; + +pub(crate) struct RemoveOptions<'a> { + pub name: &'a str, + pub purge: bool, + pub force: bool, + pub yes: bool, + pub dry_run: bool, +} + +pub(crate) fn run_remove( + out: &Output, + config: &Config, + config_path: &Path, + options: &RemoveOptions, +) -> Result<()> { + let profile = config + .profiles + .get(options.name) + .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", options.name))?; + + out.heading(&format!("Removing profile '{}'", options.name)); + out.status(&format!(" install_dir: {}", profile.install_dir.display())); + out.status(&format!(" config_dir: {}", profile.config_dir.display())); + + let install_users: Vec<&String> = config + .profiles + .iter() + .filter(|(n, p)| *n != options.name && p.install_dir == profile.install_dir) + .map(|(n, _)| n) + .collect(); + if !install_users.is_empty() { + out.warn(&format!( + "install_dir is shared with {} other profile(s): {}", + install_users.len(), + install_users + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + )); + } + + let cfg_users: Vec<&String> = config + .profiles + .iter() + .filter(|(n, p)| *n != options.name && p.config_dir == profile.config_dir) + .map(|(n, _)| n) + .collect(); + if !cfg_users.is_empty() { + out.warn(&format!( + "config_dir is shared with {} other profile(s): {}", + cfg_users.len(), + cfg_users + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + )); + } + + if options.purge { + out.warn(&format!( + "--purge: will delete config_dir {}", + profile.config_dir.display() + )); + if options.force { + out.warn(&format!( + "--purge --force: will also delete install_dir {}", + profile.install_dir.display() + )); + } + if !cfg_users.is_empty() { + bail!( + "Cannot --purge config_dir: it's shared with other profiles ({}). Remove those first or skip --purge.", + cfg_users + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + ); + } + if options.force && !install_users.is_empty() { + bail!( + "Cannot --purge --force install_dir: it's shared with other profiles ({}). Remove those first or skip --force.", + install_users + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + ); + } + } + + if options.dry_run { + out.status("\n[Dry run] No changes made."); + return Ok(()); + } + + if !options.yes && !crate::cli::confirm_prompt("\nProceed with removal?", true)? { + out.status("Aborted."); + return Ok(()); + } + + if options.purge { + if profile.config_dir.exists() { + std::fs::remove_dir_all(&profile.config_dir).with_context(|| { + format!( + "Failed to delete config_dir {}", + profile.config_dir.display() + ) + })?; + out.status(&format!(" Deleted: {}", profile.config_dir.display())); + } + if options.force && profile.install_dir.exists() { + std::fs::remove_dir_all(&profile.install_dir).with_context(|| { + format!( + "Failed to delete install_dir {}", + profile.install_dir.display() + ) + })?; + out.status(&format!(" Deleted: {}", profile.install_dir.display())); + } + } + + remove_profile_from_config(config_path, options.name)?; + out.success(&format!("\nRemoved profile '{}'.", options.name)); + if !options.purge { + out.status("Disk artifacts left in place. Use --purge to also delete config_dir, --purge --force to also delete install_dir."); + } + + Ok(()) +} diff --git a/src/sync.rs b/src/sync.rs index bb81426..2424d2f 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -1,7 +1,9 @@ use crate::config::{Config, Profile, default_exclusions}; +use crate::fs_util::copy_dir_recursive; +use crate::output::Output; +use anyhow::{Context, Result, anyhow}; use globset::{Glob, GlobSet, GlobSetBuilder}; use std::fs; -use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::time::SystemTime; @@ -25,20 +27,22 @@ pub(crate) struct SyncOptions<'a> { pub extra_exclusions: Vec<&'a str>, pub dry_run: bool, pub yes: bool, + /// If true, skip creating backups in target profile directories before overwriting. + pub force: bool, pub backup_retention: usize, } -pub(crate) fn run_sync(config: &Config, options: &SyncOptions) -> Result<(), String> { +pub(crate) fn run_sync(out: &Output, config: &Config, options: &SyncOptions) -> Result<()> { let source = config .profiles .get(options.from) - .ok_or_else(|| format!("Source profile '{}' not found", options.from))?; + .ok_or_else(|| anyhow!("Source profile '{}' not found", options.from))?; let targets: Vec<(&str, &Profile)> = if let Some(to) = options.to { let target = config .profiles .get(to) - .ok_or_else(|| format!("Target profile '{to}' not found"))?; + .ok_or_else(|| anyhow!("Target profile '{to}' not found"))?; vec![(to, target)] } else { config @@ -50,10 +54,9 @@ pub(crate) fn run_sync(config: &Config, options: &SyncOptions) -> Result<(), Str }; if targets.is_empty() { - return Err("No target profiles to sync to".to_string()); + return Err(anyhow!("No target profiles to sync to")); } - // Start with defaults, add config exclusions, then CLI exclusions let mut exclusions = default_exclusions(); exclusions.extend(config.sync.exclusions.iter().cloned()); for excl in &options.extra_exclusions { @@ -61,107 +64,105 @@ pub(crate) fn run_sync(config: &Config, options: &SyncOptions) -> Result<(), Str } let glob_set = build_glob_set(&exclusions)?; - let items = collect_sync_items(&source.config_dir, &glob_set)?; + let items = collect_sync_items(&source.config_dir, &glob_set); - println!("Sync Plan:"); - println!( + out.heading("Sync Plan:"); + out.status(&format!( " Source: {} ({})", options.from, source.config_dir.display() - ); - println!(" Targets:"); + )); + out.status(" Targets:"); for (name, profile) in &targets { - println!(" - {} ({})", name, profile.config_dir.display()); + out.status(&format!( + " - {} ({})", + name, + profile.config_dir.display() + )); } - println!(" Items to sync: {}", items.len()); - println!(" Exclusions: {exclusions:?}"); + out.status(&format!(" Items to sync: {}", items.len())); + out.status(&format!(" Exclusions: {exclusions:?}")); if items.is_empty() { - println!("\nNo items to sync."); + out.status("\nNo items to sync."); return Ok(()); } - println!("\nItems:"); + out.status("\nItems:"); for item in &items { - println!(" {}", item.display()); + out.status(&format!(" {}", item.display())); } if options.dry_run { - println!("\n[Dry run] No changes made."); + out.status("\n[Dry run] No changes made."); return Ok(()); } - if !options.yes { - print!("\nProceed? [y/N] "); - io::stdout() - .flush() - .map_err(|e| format!("Failed to flush stdout: {e}"))?; - let mut input = String::new(); - io::stdin() - .read_line(&mut input) - .map_err(|e| format!("Failed to read input: {e}"))?; - if !input.trim().eq_ignore_ascii_case("y") { - println!("Aborted."); - return Ok(()); - } + if !options.yes && !crate::cli::confirm_prompt("\nProceed?", true)? { + out.status("Aborted."); + return Ok(()); + } + + if options.force { + out.warn("--force: skipping per-target backup creation."); } for (name, target) in &targets { sync_to_target( + out, &source.config_dir, &target.config_dir, &items, name, options.backup_retention, + options.force, )?; } - println!("\nSync complete."); + out.success("\nSync complete."); Ok(()) } -fn build_glob_set(patterns: &[String]) -> Result { +fn build_glob_set(patterns: &[String]) -> Result { let mut builder = GlobSetBuilder::new(); for pattern in patterns { let glob = - Glob::new(pattern).map_err(|e| format!("Invalid glob pattern '{pattern}': {e}"))?; + Glob::new(pattern).with_context(|| format!("Invalid glob pattern '{pattern}'"))?; builder.add(glob); } - builder - .build() - .map_err(|e| format!("Failed to build glob set: {e}")) + builder.build().context("Failed to build glob set") } -fn collect_sync_items(source_dir: &Path, exclusions: &GlobSet) -> Result, String> { +fn collect_sync_items(source_dir: &Path, exclusions: &GlobSet) -> Vec { let mut items = Vec::new(); - for item_name in SYNC_ITEMS { let item_path = source_dir.join(item_name); if item_path.exists() && !exclusions.is_match(item_name) { items.push(PathBuf::from(item_name)); } } - - Ok(items) + items } fn sync_to_target( + out: &Output, source_dir: &Path, target_dir: &Path, items: &[PathBuf], target_name: &str, backup_retention: usize, -) -> Result<(), String> { - println!("\nSyncing to '{target_name}'..."); - - let backup_dir = create_backup(target_dir, items)?; - if let Some(ref backup) = backup_dir { - println!(" Backup created: {}", backup.display()); - } - - // Clean up old backups if retention is set - if backup_retention > 0 { - cleanup_old_backups(target_dir, backup_retention)?; + force: bool, +) -> Result<()> { + out.status(&format!("\nSyncing to '{target_name}'...")); + + if !force { + let backup_dir = create_backup(target_dir, items)?; + if let Some(ref backup) = backup_dir { + out.status(&format!(" Backup created: {}", backup.display())); + } + if backup_retention > 0 { + cleanup_old_backups(out, target_dir, backup_retention)?; + } } for item in items { @@ -172,19 +173,18 @@ fn sync_to_target( copy_dir_recursive(&source_path, &target_path)?; } else { if let Some(parent) = target_path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create directory: {e}"))?; + fs::create_dir_all(parent).context("Failed to create directory")?; } fs::copy(&source_path, &target_path) - .map_err(|e| format!("Failed to copy {}: {}", item.display(), e))?; + .with_context(|| format!("Failed to copy {}", item.display()))?; } - println!(" Copied: {}", item.display()); + out.status(&format!(" Copied: {}", item.display())); } Ok(()) } -fn create_backup(target_dir: &Path, items: &[PathBuf]) -> Result, String> { +fn create_backup(target_dir: &Path, items: &[PathBuf]) -> Result> { let items_to_backup: Vec<&PathBuf> = items .iter() .filter(|item| target_dir.join(item).exists()) @@ -196,38 +196,34 @@ fn create_backup(target_dir: &Path, items: &[PathBuf]) -> Result let timestamp = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) - .map_err(|e| format!("System clock error: {e}"))? + .context("System clock error")? .as_secs(); let backup_name = format!("{BACKUP_PREFIX}{timestamp}"); let backup_dir = target_dir.join(&backup_name); - fs::create_dir_all(&backup_dir) - .map_err(|e| format!("Failed to create backup directory: {e}"))?; + fs::create_dir_all(&backup_dir).context("Failed to create backup directory")?; for item in items_to_backup { let source = target_dir.join(item); let dest = backup_dir.join(item); - if source.is_dir() { copy_dir_recursive(&source, &dest)?; } else { if let Some(parent) = dest.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create backup subdirectory: {e}"))?; + fs::create_dir_all(parent).context("Failed to create backup subdirectory")?; } fs::copy(&source, &dest) - .map_err(|e| format!("Failed to backup {}: {}", item.display(), e))?; + .with_context(|| format!("Failed to backup {}", item.display()))?; } } Ok(Some(backup_dir)) } -fn cleanup_old_backups(target_dir: &Path, retention: usize) -> Result<(), String> { - let entries = fs::read_dir(target_dir) - .map_err(|e| format!("Failed to read directory for backup cleanup: {e}"))?; +fn cleanup_old_backups(out: &Output, target_dir: &Path, retention: usize) -> Result<()> { + let entries = + fs::read_dir(target_dir).context("Failed to read directory for backup cleanup")?; - // Collect all backup directories with their timestamps let mut backups: Vec<(PathBuf, u64)> = entries .filter_map(std::result::Result::ok) .filter_map(|entry| { @@ -239,49 +235,21 @@ fn cleanup_old_backups(target_dir: &Path, retention: usize) -> Result<(), String if !name.starts_with(BACKUP_PREFIX) { return None; } - // Extract timestamp from name let timestamp: u64 = name.strip_prefix(BACKUP_PREFIX)?.parse().ok()?; Some((path, timestamp)) }) .collect(); - // Sort by timestamp (newest first) - backups.sort_by(|a, b| b.1.cmp(&a.1)); + backups.sort_by_key(|b| std::cmp::Reverse(b.1)); - // Remove old backups beyond retention limit for (path, _) in backups.into_iter().skip(retention) { if let Err(e) = fs::remove_dir_all(&path) { - eprintln!( + out.warn(&format!( " Warning: Failed to remove old backup {}: {e}", path.display() - ); - } else { - println!(" Removed old backup: {}", path.display()); - } - } - - Ok(()) -} - -fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> { - if dst.exists() { - fs::remove_dir_all(dst).map_err(|e| format!("Failed to remove existing directory: {e}"))?; - } - - fs::create_dir_all(dst) - .map_err(|e| format!("Failed to create directory {}: {}", dst.display(), e))?; - - for entry in fs::read_dir(src) - .map_err(|e| format!("Failed to read directory {}: {}", src.display(), e))? - { - let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?; - let src_path = entry.path(); - let dst_path = dst.join(entry.file_name()); - - if src_path.is_dir() { - copy_dir_recursive(&src_path, &dst_path)?; + )); } else { - fs::copy(&src_path, &dst_path).map_err(|e| format!("Failed to copy file: {e}"))?; + out.status(&format!(" Removed old backup: {}", path.display())); } } diff --git a/src/update.rs b/src/update.rs index ffa946c..054fb92 100644 --- a/src/update.rs +++ b/src/update.rs @@ -1,183 +1,45 @@ -use crate::config::cache_dir; -use semver::Version; -use serde::Deserialize; -use std::fs; -use std::io::Write; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -const GITHUB_REPO: &str = "alecnunn/bn-loader"; -const CACHE_FILE: &str = "update-check.json"; -const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); // 24 hours -const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); - -#[derive(Deserialize)] -struct GitHubRelease { - tag_name: String, - html_url: String, -} - -#[derive(serde::Serialize, serde::Deserialize, Default)] -struct UpdateCache { - last_check: u64, - latest_version: Option, - release_url: Option, -} - -pub(crate) struct UpdateInfo { - pub current: String, - pub latest: String, - pub url: String, -} - -/// Check for updates if enough time has passed since last check. -/// Returns Some(UpdateInfo) if an update is available, None otherwise. -pub(crate) fn check_for_updates() -> Option { - let cache = load_cache(); - - // Check if we should skip (checked recently) - if !should_check(&cache) { - // Still return update info if we have cached data showing update available - return check_cached_update(&cache); - } - - // Fetch latest release from GitHub - match fetch_latest_release() { - Ok((latest_version, release_url)) => { - // Save to cache - save_cache(&latest_version, &release_url); - - // Compare versions - compare_versions(&latest_version, &release_url) - } - Err(_) => { - // Silently fail - don't bother user with network errors - None - } - } -} - -/// Force check for updates, bypassing cache. Used for --check-update flag. -/// Returns Some(UpdateInfo) if update available, None if on latest or error. -pub(crate) fn check_for_updates_forced() -> Option { - match fetch_latest_release() { - Ok((latest_version, release_url)) => { - save_cache(&latest_version, &release_url); - compare_versions(&latest_version, &release_url) - } - Err(e) => { - eprintln!("Error checking for updates: {e}"); - None - } - } -} - -fn should_check(cache: &UpdateCache) -> bool { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - - now.saturating_sub(cache.last_check) > CHECK_INTERVAL.as_secs() -} - -fn check_cached_update(cache: &UpdateCache) -> Option { - let latest = cache.latest_version.as_ref()?; - let url = cache.release_url.as_ref()?; - compare_versions(latest, url) -} - -fn compare_versions(latest: &str, url: &str) -> Option { - // Strip 'v' prefix if present - let latest_clean = latest.strip_prefix('v').unwrap_or(latest); - - let current = Version::parse(CURRENT_VERSION).ok()?; - let latest_ver = Version::parse(latest_clean).ok()?; - - if latest_ver > current { - Some(UpdateInfo { - current: CURRENT_VERSION.to_string(), - latest: latest_clean.to_string(), - url: url.to_string(), - }) - } else { - None - } -} - -fn fetch_latest_release() -> Result<(String, String), String> { - let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases/latest"); - - let body = ureq::get(&url) - .header("User-Agent", "bn-loader") - .header("Accept", "application/vnd.github.v3+json") - .call() - .map_err(|e| format!("Failed to fetch release info: {e}"))? - .into_body() - .read_to_string() - .map_err(|e| format!("Failed to read response: {e}"))?; - - let release: GitHubRelease = - serde_json::from_str(&body).map_err(|e| format!("Failed to parse release info: {e}"))?; - - Ok((release.tag_name, release.html_url)) -} - -fn cache_path() -> Option { - cache_dir().map(|dir| dir.join(CACHE_FILE)) -} - -fn load_cache() -> UpdateCache { - let Some(path) = cache_path() else { - return UpdateCache::default(); +use crate::config::Config; +use crate::install::{InstallOptions, run_install}; +use crate::output::Output; +use anyhow::Result; +use std::path::Path; + +pub(crate) struct UpdateOptions<'a> { + pub name: &'a str, + pub archive: &'a Path, + pub yes: bool, + pub dry_run: bool, + pub seven_zip: Option<&'a Path>, +} + +pub(crate) fn run_update( + out: &Output, + config: &Config, + config_path: &Path, + options: &UpdateOptions, +) -> Result<()> { + let profile = config + .profiles + .get(options.name) + .ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", options.name))?; + + out.heading(&format!("Updating profile '{}'", options.name)); + out.status(&format!(" Archive: {}", options.archive.display())); + out.status(&format!(" Install dir: {}", profile.install_dir.display())); + + let install_options = InstallOptions { + archive: options.archive, + dest: &profile.install_dir, + name: None, + config_dir: None, + no_register: true, + force: true, + // In dry-run mode, suppress the interactive confirmation: the plan is shown but + // nothing is written, so prompting would only confuse scripted / piped invocations. + yes: options.yes || options.dry_run, + dry_run: options.dry_run, + seven_zip: options.seven_zip, + config_path, }; - - if !path.exists() { - return UpdateCache::default(); - } - - fs::read_to_string(&path) - .ok() - .and_then(|content| serde_json::from_str(&content).ok()) - .unwrap_or_default() -} - -fn save_cache(latest_version: &str, release_url: &str) { - let Some(path) = cache_path() else { - return; - }; - - // Ensure cache directory exists - if let Some(parent) = path.parent() { - let _ = fs::create_dir_all(parent); - } - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - - let cache = UpdateCache { - last_check: now, - latest_version: Some(latest_version.to_string()), - release_url: Some(release_url.to_string()), - }; - - if let Ok(json) = serde_json::to_string_pretty(&cache) - && let Ok(mut file) = fs::File::create(&path) - { - let _ = file.write_all(json.as_bytes()); - } -} - -/// Print update notification to stderr (so it doesn't interfere with stdout) -pub(crate) fn print_update_notice(info: &UpdateInfo) { - eprintln!(); - eprintln!(" +-------------------------------------------------+"); - eprintln!( - " | Update available: v{} -> v{:<16} |", - info.current, info.latest - ); - eprintln!(" | {:<47} |", info.url); - eprintln!(" +-------------------------------------------------+"); - eprintln!(); + run_install(out, config, &install_options) }