diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml index 6cb4e49..f2e67e5 100644 --- a/.github/workflows/rust-tests.yml +++ b/.github/workflows/rust-tests.yml @@ -22,3 +22,24 @@ jobs: - name: Run tests run: cargo test --locked + + windows-tests: + name: cargo test (windows) + runs-on: windows-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install stable Rust toolchain + uses: dtolnay/rust-toolchain@18f0487477426dce244608110aa2660c2893deb7 # stable + with: + components: clippy + + - name: Run updater tests on Windows + run: 'cargo test --locked --no-default-features --features update --bin xfetch update::' + + - name: Run all tests on Windows + run: cargo test --locked + + - name: Run clippy on Windows + run: cargo clippy --all-targets -- -D warnings diff --git a/CHANGELOG.md b/CHANGELOG.md index 47610f3..c78e72f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,112 @@ # Changelog +## 2026-11-06 — v1.0.3 + +### CLI + +- Added machine output: `--json`, `--get`, `--list-modules`, `modules` and a + stable JSON schema version. +- Added runtime overrides: `--layout`, `--theme`, `--modules`/`--only`, + `--color`/`--no-color`, `--offline`, `--no-cache`, `--quiet`, `--verbose`, + `--template` and `--format`. +- Added `doctor` with platform checks, `config validate|print|get|set`, + shell completions, a man page and a unified `addon` command. +- Added JSON output to addon list/info, theme list and `update --check`. +- Added stable exit codes for config, network, addon, update and not-found + failures. +- Moved the CLI into the modular `src/cli/` tree, including platform-specific + doctor checks. +- Added CLI integration tests covering output modes, overrides, diagnostics, + exit codes, completions and offline behavior. +- Added `docs/CLI.md` with the complete command, output, JSON and exit-code + reference. +- Added config backup and restore: `--backup`, `--restore` and + `config backup|restore`. Backups include the active theme and config-roulette + routes, and restores write a pre-restore safety backup. +- Added `scripts/test-cli-sandbox.sh` for end-to-end CLI verification. +- Added JSON results for addon install, rollback and remove. +- Added `config unset`, `reset`, `edit`, `backups` and `prune`, array/index + edits and comment-preserving nested value replacement. +- Extended backup/restore with gzip compression, passphrase encryption + (Argon2id + XChaCha20-Poly1305), `--with-addons`, logo/ASCII assets, root + remapping, restored-config validation, backup listing/pruning and + timestamp-based latest selection. +- Extended templates with conditionals, loops and value filters. +- Added remote `theme search|info|install|update` and `logo list|preview`. +- Added `doctor --fix`, `schema`, `self-uninstall` and `update --rollback`. +- Added help/output snapshots and Windows CI test/clippy coverage. +- Added `paths`, `env` and `build-info` diagnostics commands. +- Added `doctor --bench` and `doctor --image`. +- Added `--log-level` and `--log-json` structured logging. +- Added complete help descriptions for every command group and subcommand, + enforced by a test. + +### Command addons + +- Added the `command` addon family and `xfetch run` for plain-text CLI + commands, with `--render` using the core layout and a separate command + catalog repository. +- Added command catalog search, install, update, rollback, list, info and + remove, plus JSON output for those actions. + +### SDK and dependencies + +- Adopted the xfetch SDK 0.3.1: `xfetch-api-common` 0.3.1 plus + `xfetch-plugin-api`, `xfetch-extension-api` and `xfetch-effect-api` 0.3.1. + The JSON wire protocol remains `1` and the WIT remains `0.1.0`. +- The matching `xfetch-guest-api` and `xfetch-command-api` 0.3.1 crates are + published for addon authors; the core does not depend on them directly. + +### Packaging + +- Added crates.io metadata: `repository`, `homepage`, `documentation`, + `readme`, `keywords` and `categories`. +- The crate description now starts with `Fast` instead of `A fast`. +- Documented `winget install xfetch` as a Windows installation method, with + matching update and uninstall guidance. + +### Layouts + +- Added `layout: "right"`: the info column stays on the left and the ASCII + or image logo is rendered on the right. Image logos use the same width and + gap settings as the classic layout. +- Fixed the `right` layout: ASCII and image logos sit flush right, the + configured gap always separates text from the logo, and the text column is + skipped when an oversized image leaves no room. + +### Addon management + +- Installs now write an `.install.json` receipt beside each native or wasm + addon. The receipt records the installed version, source, repository, path, + catalog reference, SDK version and artifact SHA-256. +- Added `update` and `rollback` for plugins, effects and extensions: + `--check` reports available versions, `--all` updates every installed addon, + and `--force` rebuilds even when the catalog version is not newer. Native + addons are rebuilt from their recorded source; wasm addons keep their + artifact/build flow. The previous artifact and receipt are kept as `.bak` + for rollback. +- Added `install --all` for plugins, effects and extensions to install every + catalog entry, skipping already installed addons unless `--force` is used. +- Catalogs accept optional `repo`, `ref`, `sdk_version` and + `min_xfetch_version` fields for source resolution and compatibility checks. + +### Windows updater + +- `xfetch update` now works on Windows. Prebuilt installs download the + `.zip` release asset, verify `SHA256SUMS`, keep `xfetch.exe.bak` and replace + the running executable with `self-replace`. winget installs are delegated to + `winget upgrade --id Xfetch-cli.Xfetch`; source builds are rebuilt from their + checkout instead of being replaced with the crates.io release. +- The PowerShell installers write `%LOCALAPPDATA%\xfetch\install.json`, so a + prebuilt install, a source build and a manually copied binary are no longer + confused even when they share a directory. + +### Release + +- Bumped the crate version to 1.0.3 and the installer examples to 1.0.3. + The published v1.0.2 tag and its assets remain unchanged. + + ## 2026-11-03 — v1.0.2 ### Security diff --git a/Cargo.lock b/Cargo.lock index 24c81f2..6890219 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -94,6 +104,18 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +[[package]] +name = "argon2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.3.1", + "password-hash", +] + [[package]] name = "async-trait" version = "0.1.92" @@ -123,12 +145,27 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -192,6 +229,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.10.2" @@ -199,10 +242,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", + "cipher", "cpufeatures 0.3.1", "rand_core", ] +[[package]] +name = "chacha20poly1305" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "clap" version = "4.6.7" @@ -225,6 +292,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.7" @@ -243,6 +319,22 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" +[[package]] +name = "clap_mangen" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" +dependencies = [ + "clap", + "roff", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "cobs" version = "0.3.0" @@ -554,7 +646,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core", +] + +[[package]] +name = "ctutils" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" +dependencies = [ + "cmov", ] [[package]] @@ -598,6 +701,7 @@ dependencies = [ "block-buffer 0.12.1", "const-oid", "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -887,9 +991,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "rand_core", + "wasm-bindgen", ] [[package]] @@ -1124,6 +1230,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" +dependencies = [ + "hybrid-array", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1155,6 +1270,16 @@ dependencies = [ "libc", ] +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + [[package]] name = "json_comments" version = "0.2.2" @@ -1296,6 +1421,18 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -1424,12 +1561,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "getrandom 0.4.3", + "phc", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom 0.4.3", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1455,6 +1613,16 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.1", + "universal-hash", +] + [[package]] name = "postcard" version = "1.1.3" @@ -1640,6 +1808,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "rustc-demangle" version = "0.1.28" @@ -1731,6 +1905,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "self-replace" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "609c5606d9d357b26e4ee1a7a01beac396628d0fa01ebbade2a09cefb88e65af" +dependencies = [ + "fastrand", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "semver" version = "1.0.28" @@ -2142,6 +2327,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typenum" version = "1.20.1" @@ -2166,6 +2357,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -2267,6 +2468,50 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.261.0" @@ -2975,20 +3220,36 @@ dependencies = [ "rustix", ] +[[package]] +name = "xfetch-api-common" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d67a2db2341dd5b8d6ae933c35dcd75670b4379e206ac59ffc1f42c94021315d" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "xfetch-cli" -version = "1.0.2" +version = "1.0.3" dependencies = [ "anyhow", + "argon2", "base64 0.23.1", + "chacha20poly1305", "clap", + "clap_complete", + "clap_mangen", "console", "crossterm", "dirs", "flate2", + "getrandom 0.2.17", "image", "json_comments", "libc", + "self-replace", "semver", "serde", "serde_json", @@ -3005,36 +3266,40 @@ dependencies = [ "xfetch-effect-api", "xfetch-extension-api", "xfetch-plugin-api", + "zip", ] [[package]] name = "xfetch-effect-api" -version = "0.2.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69820c4df7636fc23ab84a3b6cb7d3c9b58b523fbc10e0e0d733c8bc5facd009" +checksum = "858f9c4671b66205a1985683eacf119af7a2b87a60953d8965c1d839d7e49310" dependencies = [ "serde", "serde_json", + "xfetch-api-common", ] [[package]] name = "xfetch-extension-api" -version = "0.2.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f0d4e7f50b3cc65e4ad5a6556354975e24429c430a8df06ff665709ed383cab" +checksum = "58797f1ea7e1c922c7a1b66aa76210a7d96fea84a11d9ff40afcfcb4fddc2aed" dependencies = [ "serde", "serde_json", + "xfetch-api-common", ] [[package]] name = "xfetch-plugin-api" -version = "0.2.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54bc785c4c4bb18d4041edff98baeff88a9ff5595b52212a3a01c7d56e74fb8d" +checksum = "79be6250101bee33db48a366b52d00284aa63f78017931f9237bf239a632dd14" dependencies = [ "serde", "serde_json", + "xfetch-api-common", ] [[package]] @@ -3140,6 +3405,24 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "zip" +version = "9.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaeb7f16d2605b49aef586996ded29e65642fab7259a3077e537351df6d80426" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "libc", + "memchr", + "nix", + "typed-path", + "windows-sys 0.61.2", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -3152,6 +3435,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zstd" version = "0.13.4" diff --git a/Cargo.toml b/Cargo.toml index 02615f8..5a525bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,15 @@ [package] name = "xfetch-cli" -version = "1.0.2" +version = "1.0.3" edition = "2024" license = "MIT" -description = "XFetch — a fast, customizable system information fetch tool for the terminal" +description = "Fast, customizable system information fetch tool for the terminal" +repository = "https://github.com/xfetch-cli/xfetch" +homepage = "https://xfetch-cli.github.io/web/" +documentation = "https://docs.rs/xfetch-cli" +readme = "README.md" +keywords = ["system-information", "fetch", "cli", "terminal", "neofetch"] +categories = ["command-line-utilities"] [[bin]] name = "xfetch" @@ -13,14 +19,14 @@ path = "src/main.rs" # WASM plugin/effect/extension support and the self-updater are enabled by # default; build with `--no-default-features` for a minimal binary. default = ["wasm", "update"] -wasm = ["dep:wasmtime", "dep:wasmtime-wasi", "dep:anyhow", "dep:base64", "dep:url"] -update = ["dep:flate2", "dep:semver", "dep:tar"] +wasm = ["dep:wasmtime", "dep:wasmtime-wasi", "dep:anyhow", "dep:url"] +update = ["dep:tar", "dep:zip", "dep:self-replace"] [dependencies] anyhow = { version = "1", optional = true } -base64 = { version = "0.23", optional = true } -flate2 = { version = "1", optional = true } -semver = { version = "1", optional = true } +base64 = "0.23" +flate2 = "1" +semver = "1" sha2 = "0.11" tar = { version = "0.4", optional = true } clap = { version = "4.6.7", features = ["derive"] } @@ -49,14 +55,21 @@ wasmtime-wasi = { version = "50.0.1", optional = true, default-features = false, "p1", "p2", ] } -xfetch-plugin-api = { version = "0.2.0", package = "xfetch-plugin-api" } -xfetch-extension-api = { version = "0.2.0", package = "xfetch-extension-api" } -xfetch-effect-api = { version = "0.2.0", package = "xfetch-effect-api" } +xfetch-plugin-api = { version = "0.3.1", package = "xfetch-plugin-api" } +xfetch-extension-api = { version = "0.3.1", package = "xfetch-extension-api" } +xfetch-effect-api = { version = "0.3.1", package = "xfetch-effect-api" } +clap_complete = "4" +clap_mangen = "0.2" +chacha20poly1305 = "0.11.0" +argon2 = "0.6.0" +getrandom = "0.2" # Windows-only native probes: CPU/date/battery/GPU are read through the Win32 # API instead of PDH counters or PowerShell subprocesses (see # src/info/platform/windows/). The dependency never enters other targets. [target.'cfg(windows)'.dependencies] +self-replace = { version = "1.5", optional = true } +zip = { version = "9", default-features = false, features = ["deflate"], optional = true } windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_Graphics_Gdi", diff --git a/README.md b/README.md index 4a19391..1a468c2 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,11 @@ irm https://raw.githubusercontent.com/xfetch-cli/xfetch/main/install-prebuilt.ps irm https://raw.githubusercontent.com/xfetch-cli/xfetch/main/install.ps1 | iex ``` +**Windows (winget):** +```powershell +winget install xfetch +``` + **Cargo (crates.io):** ```bash cargo install xfetch-cli @@ -141,9 +146,23 @@ yay -S xfetch-git # build from source - **Effects**: Installable intro animations that reveal the info with style (e.g. a "decrypt" effect) — opt-in via `"effects"` in the config. - **Daemon mode**: Pin an animated fetch at the top of the terminal and keep using the shell below. - **Live stats daemon**: Turn the pinned fetch into a live panel — re-probes cpu/memory/battery/... every few seconds (`daemon_live`), with hot reload of the config on the fly (`daemon_live_reload`). -- **Themes, plugins & extensions**: Switch visual themes, extend info with plugins, and transform the config with extensions. +- **Themes, plugins, extensions & commands**: Switch visual themes, extend info with plugins, transform the config with extensions, and add CLI commands. - **WebAssembly guests**: Plugins, effects and extensions can be sandboxed `.wasm` artifacts written in Rust, Python, Go, C and more, with manifest-based capabilities. See [docs/WASM.md](docs/WASM.md). +

What's new in 1.0.3

+ +- **Scriptable CLI**: JSON output, `--get`, runtime overrides, templates, a + stable schema, explicit exit codes, shell completions and a man page. +- **Command addons**: install and run plain-text CLI addons with + `xfetch command` and `xfetch run`. +- **Diagnostics and recovery**: `doctor`, `paths`, `env`, `build-info`, + config validation, encrypted backups/restores and `self-uninstall`. +- **Addon lifecycle**: `.install.json` receipts, update, rollback and `--all` + installs for plugins, effects, extensions and commands. +- **Right-side logo layout**: `layout: "right"` keeps the info on the left and + renders the logo on the right. +- **Windows**: native self-update, winget detection and `winget install xfetch`. +

Configuration

xfetch looks for a configuration file at: @@ -188,6 +207,31 @@ Simply run `xfetch` in your terminal. ```bash xfetch # render the fetch +xfetch --json # machine-readable fetch +xfetch --get os,cpu,memory # print specific values +xfetch --modules os,kernel,cpu # override the rendered modules +xfetch --layout right # runtime layout override +xfetch --theme dracula # runtime theme override +xfetch --format 'os={{os}}' # inline output template +xfetch modules # list module keys +xfetch doctor # diagnose config, terminal and addons +xfetch doctor --fix # apply safe fixes +xfetch doctor --bench --image # probe timings and logo preview +xfetch paths # resolved config/addon/cache paths +xfetch env # platform, environment and settings +xfetch build-info # build profile and enabled features +xfetch config validate # validate the config file +xfetch config edit # edit and validate the config +xfetch --backup # back up the current config +xfetch --restore # restore the latest backup +xfetch command install github # install a command addon +xfetch run github -- owner/repo # run it +xfetch theme search dark # search the remote theme registry +xfetch logo preview arch # preview a catalog logo +xfetch schema fetch # JSON Schema for machine output +xfetch --log-json --log-level debug # structured logs on stderr +xfetch completions zsh # shell completions +xfetch man # man page xfetch --daemon # pin an animated fetch at the top (daemon mode) xfetch --daemon-stop # stop the daemon xfetch --no-daemon-live # disable the live stats daemon (config: "daemon_live": true) @@ -197,6 +241,8 @@ xfetch --config path/to/config.jsonc xfetch --gen-config # generate a starter config xfetch --clean-cache # clear the module cache xfetch plugin install # install a plugin +xfetch plugin install --all # install every catalog plugin +xfetch plugin update --all # update every installed plugin xfetch plugin search # search the plugin catalog xfetch plugin available # list the plugin catalog xfetch extension install # install an extension @@ -216,6 +262,7 @@ xfetch update # update to the latest release diff --git a/ROADMAP.md b/ROADMAP.md index 3c39c0f..f4d1095 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -100,6 +100,8 @@ - [x] Create binary releases for Linux x86_64 (#103) - [x] Create binary releases for macOS (Intel & ARM) (#104) - [x] Create binary releases for Windows (#105) +- [x] Publish Windows packages to winget (#441119) +- [x] Add Windows self-update for winget, cargo and prebuilt installs (#201) - [x] Setup AUR package for Arch Linux (#106) - [x] Setup Homebrew tap for macOS (#107) - [x] Setup PyPI or cargo registry for distribution (#108) @@ -125,6 +127,7 @@ - [x] Setup code formatter (rustfmt) (#122) - [x] Implement platform-specific tests for each OS (#123) - [x] Add cross-platform testing suite (#124) +- [x] Run updater tests on Windows CI (#201) - [x] Setup code coverage reporting (#125) ## Phase 12 · Advanced Features diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 0000000..c160785 --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,1047 @@ +

Command Line Interface

+ +

+ This page is the complete reference for the xfetch command line: + global options, output modes, runtime overrides, every subcommand, JSON schemas, + exit codes and platform notes. For installation, see + INSTALLATION.md; for configuration keys, see + CONFIGURATION.md. +

+ +

Synopsis

+ +
xfetch [OPTIONS] [COMMAND]
+ +

+ With no command, xfetch renders the fetch. Global options may appear before or + after the subcommand. Unsupported flag combinations fail with exit code 2. +

+ +

Global Options

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OptionDescription
-c, --config <PATH>Use a custom config file.
--gen-configGenerate a starter config. See GEN_CONFIG.md.
--logo <NAME>Logo for the generated config. Only valid with --gen-config.
--layout <NAME>Override the layout at runtime. With --gen-config, select the generated layout instead.
--theme <NAME>Override the theme at runtime. The theme must exist.
--list-layoutsPrint every layout name accepted by --layout and exit.
--list-modulesPrint every module key accepted in modules and exit.
--clean-cacheRemove the package and public-IP cache.
--backupBack up the config currently in use, including its theme and config-roulette routes.
--restoreRestore the most recent config backup.
--benchmarkPrint benchmark lines for the info collection step.
--daemonPin an animated fetch at the top of the terminal. See DAEMON.md.
--daemon-stopStop the animated-logo daemon.
--no-daemon-liveDisable the live stats daemon for this run.
--daemon-live-stopStop the live stats daemon.
--daemon-live-reloadForce a hot reload of the live stats daemon.
--jsonEmit machine-readable JSON.
--get <KEY>Print only the requested module keys. Repeat the flag or use commas.
--modules <LIST>Override the rendered module list with a comma-separated key list.
--only <LIST>Alias for --modules.
--color <MODE>auto, always or never. Default: auto.
--no-colorDisable colors. Same as --color never.
--offlineDisable network access: public IP, catalogs, update checks, logo fetch and remote addon installs.
--no-cacheBypass the package and public-IP cache for this run.
--quietSuppress informational output. Command results are still printed.
--verbosePrint extra diagnostics to stderr. Equivalent to --log-level debug.
--log-level <LEVEL>off, error, warn, info, debug or trace. Overrides --quiet/--verbose.
--log-jsonEmit logs as JSON lines on stderr.
--template <FILE>Render the fetch with a template file.
--format <STRING>Render the fetch with an inline template.
-h, --helpPrint help.
-V, --versionPrint the version.
+ +

Output Modes

+ +

Normal Render

+ +
xfetch
+xfetch --layout right
+xfetch --modules os,kernel,cpu,memory
+xfetch --config ~/.config/xfetch/config.jsonc
+ +

+ The renderer reads the config, applies the theme and config providers, collects + only the required probes, and prints the selected layout. +

+ +

JSON

+ +
xfetch --json
+xfetch --json --benchmark
+xfetch --json --modules os,kernel,cpu
+ +

+ --json prints one object with schema_version: 1. It + never prints ANSI escapes. --benchmark adds a benchmark + array when present. +

+ + + + + + + + + + + + + + + + + + + + + + +
FieldTypeNotes
schema_versionnumberCurrently 1.
namestringxfetch.
versionstringBinary version.
os, kernel, hostname, userstringSystem identity.
shell, terminal, desktopstringSession information.
cpustringCPU model, cores and frequency.
gpuarray of stringsOne entry per detected GPU.
memory, swap, battery, uptimestringFormatted values.
disksarray of stringsFormatted disk rows.
packagesstringCombined package summary.
packages_breakdownarray of objects{"manager": "...", "count": "..."}.
datetime, local_ip, local_ip_v6, public_ipstringSession/network values.
network_interfacesstringInterface summary.
pluginsobjectMap of plugin:<name> to output lines.
benchmarkarray of stringsOnly with --benchmark.
+ +

Specific Values

+ +
xfetch --get os
+xfetch --get os,cpu,memory
+xfetch --get version,name
+xfetch --json --get os,cpu
+ +

+ --get probes only the requested keys. A single key prints the raw + value; multiple keys print key=value lines, with embedded newlines + escaped as \n. With --json the output is an object: +

+ +
{
+  "schema_version": 1,
+  "values": {
+    "cpu": "...",
+    "os": "..."
+  }
+}
+ +

+ Supported keys are the module keys from --list-modules, plus + version and name. Patterns such as + packages:pacman and plugin:<name> are accepted. + Unknown keys fail with exit code 7. +

+ +

Templates

+ +
xfetch --format 'os={{os}} kernel={{kernel}}'
+xfetch --template ~/.config/xfetch/my.tmpl
+ +

+ Templates replace {{key}} with the resolved value. Unknown keys + render as an empty string. Supported placeholders include every module key, + packages:<manager>, plugin:<name>, + palette, header, sep, + name and version. List values such as + gpu, disks, packages_breakdown and + plugin:<name> support loops and join. +

+ + + + + + + + + + + + + + + +
SyntaxMeaning
{{key}}Value, or the comma-joined list.
{{key|upper}}, lower, trimText transforms.
{{key|count}}List length or text length.
{{key|join:" / "}}Join a list with a separator.
{{key|default:"fallback"}}Fallback when empty.
{{#if key}}...{{else}}...{{/if}}Conditional block.
{{#unless key}}...{{/unless}}Inverted conditional.
{{#each list}}...{{this}}...{{/each}}Loop; {{this}} and {{@index}} are available.
+ +
{{name}} {{version}}
+{{#if kernel}}kernel: {{kernel}}{{/if}}
+{{#each gpu}}gpu[{{@index}}]: {{this}}{{/each}}
+{{#each disks}}{{this}}\n{{/each}}
+plugins: {{#each plugin:banner}}{{this}}{{/each}}
+
+ +

+ --get cannot be combined with --template or + --format. --json cannot be combined with + --template or --format. --get, + --template and --format only apply to the default + fetch and cannot be combined with a subcommand or daemon mode. The global + --json flag is accepted by subcommands that support machine + output. +

+ +

Other JSON Outputs

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CommandShape
--list-layouts --jsonArray of layout names.
modules --json, --list-modules --jsonArray of {key, description, platforms}.
theme list --json{schema_version, themes: [{name, path, version, source, installed_unix}]}.
theme search|info --jsonRegistry metadata objects.
theme install --json{schema_version, status, name, version, path}.
theme update --json{schema_version, check, themes: [{name, installed, available, update_available, status?}]}.
theme set|remove|export --json{schema_version, status, theme, path?}.
logo list --json{schema_version, logos: [{id, file, aliases}]}.
logo preview --json{schema_version, id, file, aliases, art}.
addon <kind> list --json{schema_version, kind, addons: [...]}.
addon <kind> info <name> --jsonFlattened addon object with schema_version.
addon <kind> install --json{schema_version, status, kind, name, path, source} or an installed array with --all.
addon <kind> rollback|remove --json{schema_version, status, kind, name}.
addon <kind> update --check --json{schema_version, kind, checks: [...]}.
run <name> --json{schema_version, command, args, lines}.
update --check --json{schema_version, status, current, latest, method?, command?, path?, update_available}.
update --rollback --json{schema_version, status: "rolled-back", path}.
doctor --json{schema_version, version, platform, checks, fixes}.
config print --jsonResolved config object.
config get --jsonThe raw JSON value.
config set|unset|reset|edit --jsonStatus object with path, key/status and optional safety_backup.
config backups --json{schema_version, backups: [{path, created_unix, created_utc, files, missing, size, compressed}]}.
config prune --json{schema_version, kept, deleted, dry_run}.
--clean-cache --json{"cleaned": true}.
--backup --json, config backup --json{schema_version, path, files, missing, warnings, with_addons, compressed, encrypted}.
--restore --json, config restore --json{schema_version, backup, dry_run, root, restored, missing, safety_backup, validation}.
--daemon-stop --json, --daemon-live-stop --json{"stopped": bool}.
--gen-config --json{"path": "..."}.
schemaJSON Schema object (or a map for all).
self-uninstall --json{schema_version, status, purged, removed}.
+ +

Runtime Overrides

+ + + + + + + + + + + + + +
FlagEffect
--layout <NAME>Replaces layout. Validated against --list-layouts.
--theme <NAME>Selects the theme before the file/theme merge. The theme must exist.
--modules <LIST>, --only <LIST>Replaces the rendered module list. Comma-separated; groups are not supported by this override.
--color, --no-colorControls ANSI output. Images are not affected.
--offlineDisables public IP, catalogs, updates, logo fetch and remote addon installs. Local addon installs still work.
--no-cacheBypasses the package/public-IP cache.
+ +

Precedence, from highest to lowest:

+ +
    +
  1. CLI overrides (--layout, --modules, --no-color, --offline, --no-cache)
  2. +
  3. Config providers (extensions)
  4. +
  5. Theme
  6. +
  7. Config file
  8. +
  9. Built-in defaults
  10. +
+ +

+ --theme is applied before the theme merge so the selected theme is + the one that participates. --color auto strips ANSI when stdout is + not a TTY; always keeps it, never and + --no-color remove it. +

+ +

Commands

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
CommandPurpose
plugin (alias plugins)Manage plugins. See PLUGINS.md.
effects (alias effect)Manage intro effects. See EFFECTS.md.
extension (alias extensions)Manage config providers. See EXTENSIONS.md.
addon <kind> <action>Unified addon command. kind is plugin, effect, extension or command.
command (alias commands)Manage CLI command addons: install, update, rollback, list, info, search and remove.
run <name> [--] <args>Run an installed command addon. --render uses the core layout and logo.
themeSearch, install, update, list, set, remove or export themes. See THEMES.md.
logoList and preview logos from the ASCII logo catalog.
wasmInspect and run WebAssembly guests. See WASM.md.
updateCheck for and install a newer release.
doctorDiagnose the installation, configuration, terminal and addons. Supports --fix, --bench and --image.
modulesList module keys (same registry as --list-modules).
configValidate, print, get, set, back up or restore configuration values.
completions <shell>Generate shell completions.
manRender the man page.
schemaPrint the JSON Schema for the CLI machine-readable outputs.
pathsPrint resolved config, addon, cache, backup and data paths.
envPrint build, platform, environment, settings and path context.
build-infoPrint version, target, profile and enabled features.
self-uninstallRemove the binary and optionally its config, cache and data.
+ +

Addon Commands

+ +

+ The plugin, effects, extension and + command families accept the same management actions. The unified + form is equivalent: +

+ +
xfetch addon plugin install banner
+xfetch addon effect install decrypt
+xfetch addon extension install config-roulette
+xfetch addon command install github
+xfetch command install github
+xfetch plugin install banner
+xfetch effects install decrypt
+xfetch extension install config-roulette
+ + + + + + + + + + + + + + +
ActionUsageNotes
installinstall [PATH] [--repo <URL>] [--sha256 <HEX>] [--name <NAME>] [--manifest <FILE>] [--all] [--force]Install from a catalog name, local directory, repository, direct wasm file or URL.
updateupdate [NAME] [--all] [--check] [--force]Rebuild native addons from the recorded source; keep .bak for rollback.
rollbackrollback <NAME>Restore the previous artifact and receipt.
listlist [--verbose]List installed addons. --verbose adds capabilities and limits.
infoinfo <NAME>Show metadata, capabilities, limits and the config snippet.
searchsearch [QUERY] (alias available)Search the catalog; omit the query to list everything.
removeremove <NAME>Remove the installed artifact and receipt.
+ +

+ --json is supported for every addon action: install + returns the installed name and path (or a list with --all), + rollback and remove return a status object, + list and info return metadata, search + returns the catalog and update --check returns version checks. +

+ +

Addon JSON

+ +
{
+  "schema_version": 1,
+  "kind": "plugin",
+  "addons": [
+    {
+      "kind": "plugin",
+      "name": "banner",
+      "path": "/home/user/.config/xfetch/plugins/xfetch-plugin-banner",
+      "runtime": "native",
+      "manifest_kind": "plugin",
+      "version": "0.2.0",
+      "description": "...",
+      "enabled": true,
+      "capabilities": ["..."],
+      "limits": "...",
+      "config": ["..."],
+      "manifest_source": null,
+      "installed_source": "catalog"
+    }
+  ]
+}
+ +

+ info --json prints the same fields flattened with + schema_version. install --json prints + {schema_version, status, kind, name, path, source} (or an + installed array with --all). rollback --json + and remove --json print {schema_version, status, kind, name}. + update --check --json prints: +

+ +
{
+  "schema_version": 1,
+  "kind": "plugin",
+  "checks": [
+    {
+      "name": "banner",
+      "installed": "0.2.0",
+      "available": "0.2.1",
+      "update_available": true,
+      "compatible": true,
+      "min_xfetch_version": null
+    }
+  ]
+}
+ +

Command Addons

+ +

+ Command addons expose extra CLI commands. They are installed and updated like + the other addon families, but they run on demand: +

+ +
xfetch command install github
+xfetch command list
+xfetch command info github
+xfetch command update --all --check
+xfetch command update github
+xfetch command rollback github
+xfetch command remove github
+
+xfetch run github -- rust-lang/rust
+xfetch run github -- https://github.com/rust-lang/rust
+xfetch --json run github -- rust-lang/rust
+xfetch --layout right run github --render -- rust-lang/rust
+ +

+ The command receives positional arguments and returns plain text, one field + per line. The core prints the text as-is by default; --render + passes the lines through the configured layout and logo. --json + wraps the output as + {schema_version, command, args, lines}. +

+ +

+ Official command addons live in + https://github.com/xfetch-cli/commands. The catalog is at + .../main/index.json (XFETCH_COMMAND_INDEX overrides + it); XFETCH_COMMAND_REPO overrides the install repository and + XFETCH_COMMAND_DEV_DIR adds a development directory. + --offline blocks catalog and remote installs. +

+ +

+ Wasm command addons use the command world in + xfetch:runtime@0.1.0; the request is JSON and the response is + plain text. Native commands may read argv and/or the JSON request on stdin. + See the WASM guide and the API + command-sdk.md. +

+ +

Theme and Logo Commands

+ +

Theme Registry

+ +
xfetch theme search dark
+xfetch theme info nord
+xfetch theme install nord
+xfetch theme install nord --force
+xfetch theme update nord
+xfetch theme update --all --check
+xfetch theme list
+xfetch theme list --json
+xfetch theme set dracula
+xfetch theme remove dracula
+xfetch theme export my-theme
+xfetch --theme dracula
+ +

+ theme search, theme info, theme install + and theme update use the registry at + https://raw.githubusercontent.com/xfetch-cli/themes/main/index.json. + Override it with XFETCH_THEME_INDEX. Installed themes write a + .<name>.xfetch-meta.json sidecar with the installed version + and source; theme list includes that metadata. + theme update --check exits 1 when an update is available. + --offline blocks registry access. +

+ +

+ theme list --json prints a themes array with + {name, path, version, source, installed_unix}. + theme search --json and theme info --json return + registry metadata. theme install --json returns + {schema_version, status, name, version, path}. + theme update --json returns a per-theme + {name, installed, available, update_available, status} list. +

+ +

Logo Catalog

+ +
xfetch logo list
+xfetch logo list linux
+xfetch logo list --json
+xfetch logo preview arch
+xfetch logo preview arch --json
+ +

+ Logos come from + https://raw.githubusercontent.com/xfetch-cli/logos/main + (XFETCH_LOGOS_URL overrides the base URL). logo list + filters by id, alias or file; logo preview prints the validated + ASCII art. --json returns + {schema_version, logos:[{id,file,aliases}]} for lists and + {schema_version, id, file, aliases, art} for a preview. + --offline blocks catalog access. +

+ +

WebAssembly Commands

+ +
xfetch wasm inspect ./plugin.wasm
+xfetch wasm inspect ./plugin.wasm --json
+xfetch wasm run ./plugin.wasm --request '{"version":1,"kind":"info_provider"}'
+xfetch wasm run ./plugin.wasm --request-file req.json --timeout 2 --kind effect
+xfetch wasm wit
+ + + + + + + + + + +
ActionDescription
inspect <FILE>Report kind, manifest, capabilities and limits without executing. Global --json switches to JSON.
run <FILE>Run a guest with a raw JSON request. Flags: --request, --request-file, --timeout, --kind plugin|effect|extension.
witPrint the canonical WIT package.
+ +

Update Command

+ +
xfetch update --check
+xfetch update --check --json
+xfetch update
+xfetch update --prebuilt
+xfetch update --prebuilt --bin-dir ~/.local/bin
+xfetch update --rollback
+xfetch update --rollback --bin-dir ~/.local/bin
+ + + + + + + + + + + + +
FlagDescription
--checkOnly report; exit 1 when a newer release exists.
--prebuiltInstall the prebuilt release in place (Unix and Windows).
--bin-dir <DIR>Directory holding the binary to replace (with --prebuilt).
-y, --yesAssume yes for prompts.
--rollbackRestore the previous binary from xfetch.bak/xfetch.exe.bak.
+ +

+ --json requires --check for update checks; it also + works with --rollback. Package-managed installs (Homebrew, cargo, + winget, AUR) are delegated to their package manager instead of replacing the + binary. +

+ +
{
+  "schema_version": 1,
+  "status": "update-available",
+  "current": "1.0.2",
+  "latest": "1.0.3",
+  "method": "prebuilt",
+  "command": "xfetch update --prebuilt",
+  "update_available": true
+}
+ +

Doctor Command

+ +
xfetch doctor
+xfetch doctor --json
+xfetch doctor --network
+xfetch doctor --json --network
+xfetch doctor --fix
+xfetch doctor --fix --json
+xfetch doctor --bench
+xfetch doctor --bench --json
+xfetch doctor --image
+ +

+ doctor checks the binary, config file, theme, layout, terminal + size, image protocol, logo width/gap geometry, addon directories, install + receipts and cache. --network additionally checks the catalogs and + the update API. It exits 1 when any check fails. --quiet hides + successful checks. --fix applies safe fixes: it generates a + missing config and removes an invalid cache file. --bench runs + the info collection benchmark and includes it in the report. + --image prints a preview of the configured logo/image when + stdout is a terminal. Applied fixes and benchmark lines are included in JSON + output. +

+ +
{
+  "schema_version": 1,
+  "version": "1.0.3",
+  "platform": {
+    "os": "linux",
+    "arch": "x86_64",
+    "family": "unix",
+    "details": [{"name": "Distribution", "value": "Arch Linux"}]
+  },
+  "checks": [
+    {
+      "category": "config",
+      "name": "file",
+      "status": "ok",
+      "detail": "/home/user/.config/xfetch/config.jsonc"
+    }
+  ],
+  "fixes": []
+}
+ +

status is ok, warn or fail.

+ +

Modules Command

+ +
xfetch --list-modules
+xfetch modules
+xfetch modules --json
+ +

+ The registry includes plain keys (os, cpu, + memory, ...), patterns (packages:<manager>, + plugin:<name>) and presentation keys + (palette, header, sep). +

+ +
[
+  {
+    "key": "os",
+    "description": "Operating system name and architecture",
+    "platforms": "all"
+  }
+]
+ +

Config Commands

+ +
xfetch config validate
+xfetch config print
+xfetch config print --raw
+xfetch config print --json
+xfetch config get layout
+xfetch config get colors.cpu
+xfetch config set layout right
+xfetch config set modules.1 kernel
+xfetch config set colors.cpu red
+xfetch config unset colors.cpu
+xfetch config reset --yes
+xfetch config edit
+xfetch config backups
+xfetch config prune --keep 5
+xfetch --config ./config.jsonc config set theme dracula
+ + + + + + + + + + + + + + + + +
ActionDescription
validateParse JSONC strictly, validate the schema and check that the referenced theme exists.
printPrint the resolved config after defaults, theme, providers and CLI overrides. --raw prints the stored file instead.
get <KEY>Read one dotted key path. Array indices such as modules.0 are supported.
set <KEY> <VALUE>Set one value. Dotted paths and array indices are supported. Existing nested values are replaced in place, preserving surrounding comments; missing values fall back to a semantic rewrite. The result is validated before writing.
unset <KEY>Remove an object key or array element. Top-level keys preserve the rest of the file; nested removals may rewrite it.
resetWrite the built-in defaults after a safety backup. Requires --yes in non-interactive runs.
editOpen the config in $VISUAL or $EDITOR after a safety backup, then validate it.
backupsList saved backups with timestamps, file counts and sizes.
pruneDelete old regular backups, keeping the newest N. Safety backups are preserved. --dry-run only lists them.
+ +

+ config set --json, config unset --json, + config reset --json and config edit --json print + status objects. config get --json prints the raw JSON value. + config backups --json and config prune --json return + structured backup metadata. +

+ +

Backup and Restore

+ +

+ xfetch --backup snapshots the config currently in use. The + backup adapts to the installed setup: +

+ +
    +
  • The main config file.
  • +
  • Active themes for the main config and for every route config, recursively.
  • +
  • For config-roulette (and any provider with a + routes argument): the routes file and every referenced route + config.
  • +
  • Nested provider routes, up to a safe depth.
  • +
  • Other provider arguments that look like local file paths and exist.
  • +
  • Referenced logo/ASCII assets.
  • +
  • With --with-addons: installed addon artifacts, sidecar + manifests and install receipts.
  • +
  • A resolved-config snapshot, missing files and parse warnings.
  • +
+ +
# Shortcuts
+xfetch --backup
+xfetch --restore
+
+# Explicit commands
+xfetch config backup
+xfetch config backup --output ~/backups
+xfetch config backup --output ~/backups/my-config.json
+xfetch config backup --with-addons
+xfetch config backup --compress
+xfetch config backup --encrypt --passphrase-file ~/.config/xfetch/backup.pass
+xfetch config restore
+xfetch config restore --from ~/backups/my-config.json
+xfetch config restore --from ~/backups --dry-run
+xfetch config restore --root ~/restored
+xfetch config restore --from backup.enc --passphrase-file backup.pass
+xfetch config backups
+xfetch config prune --keep 5
+xfetch config prune --keep 5 --dry-run
+xfetch --json --backup
+xfetch --json --restore
+ +

+ By default backups live in the xfetch config directory under + backups/ (usually ~/.config/xfetch/backups on + Linux). --output accepts a file or a directory. Without + --from, config restore uses the newest backup by + the bundle's own timestamp. config backups lists saved backups; + config prune --keep N deletes older regular backups and keeps + safety backups untouched. +

+ +

+ Restore always writes a safety backup named pre-restore-*.json + before touching the files. Files are written atomically and parent + directories are created. --dry-run prints the target paths + without writing. Missing entries recorded at backup time are skipped. The + restored main config is validated and the result is reported in the + validation field. +

+ +

+ With --config <PATH>, the main config file is restored to + that path while auxiliary files return to their original locations. + --root <DIR> remaps every file under the original config + directory into a new root, which is useful for restoring a complete setup + elsewhere. +

+ +

+ --compress writes gzip-compressed backups. --encrypt + writes a passphrase-encrypted backup using Argon2id and + XChaCha20-Poly1305; the passphrase comes from + --passphrase-file or XFETCH_BACKUP_PASSPHRASE. + Restore detects plain JSON, gzip and encrypted containers automatically. +

+ +

+ Runtime overrides such as --theme, --layout and + --modules are not persisted in the backup; the stored config and + its referenced files are captured. For config-roulette, the + resolved_config field is a snapshot of one invocation, while the + raw routes are preserved for a faithful restore. +

+ +

+ Backups are JSON text (optionally compressed/encrypted) and may contain + sensitive configuration values. On Unix the backup file is written with mode + 0600. Store encrypted backups and passphrase files securely. +

+ +
{
+  "schema_version": 1,
+  "created_unix": 1793987313,
+  "created_unix_ms": 1793987313123,
+  "created_utc": "2026-11-06T18:48:33Z",
+  "xfetch_version": "1.0.3",
+  "config_path": "/home/user/.config/xfetch/config.jsonc",
+  "theme": "nord",
+  "theme_path": "/home/user/.config/xfetch/themes/nord.jsonc",
+  "config_providers": ["config-roulette"],
+  "files": [
+    {"role": "config", "path": "...", "content": "..."},
+    {"role": "theme", "name": "nord", "path": "...", "content": "..."},
+    {"role": "routes", "name": "config-roulette", "path": "...", "content": "..."},
+    {"role": "route-config", "name": "a", "path": "...", "content": "..."},
+    {"role": "logo", "path": "...", "encoding": "base64", "content": "..."},
+    {"role": "addon-artifact", "name": "plugin:banner", "encoding": "base64", "content": "..."}
+  ],
+  "missing": [],
+  "warnings": [],
+  "resolved_config": {}
+}
+ +

Schema Command

+ +
xfetch schema
+xfetch schema fetch
+xfetch schema backup
+xfetch schema addon
+xfetch schema update
+xfetch schema doctor
+xfetch schema config
+ +

+ schema prints JSON Schema documents for the machine-readable + outputs. xfetch schema prints all schemas in one object; + xfetch schema <kind> prints one schema. The schemas are + descriptive contracts for the schema_version: 1 outputs. +

+ +

Self-Uninstall

+ +
xfetch self-uninstall
+xfetch self-uninstall --purge
+xfetch self-uninstall --purge --yes
+ +

+ self-uninstall removes the running binary and its update backup. + --purge also removes the xfetch config directory (including + themes, addons and backups), cache and data directories. Without + --yes, it asks for confirmation when stdin is a terminal and + refuses in non-interactive runs. +

+ +

Shell Integration

+ +

Completions

+ +
xfetch completions bash
+xfetch completions zsh
+xfetch completions fish
+xfetch completions elvish
+xfetch completions powershell
+ +

Examples:

+ +
# Bash
+xfetch completions bash > ~/.local/share/bash-completion/completions/xfetch
+
+# Zsh
+xfetch completions zsh > ~/.zfunc/_xfetch
+
+# Fish
+xfetch completions fish > ~/.config/fish/completions/xfetch.fish
+ +

Man Page

+ +
xfetch man
+xfetch man --output ~/.local/share/man/man1/xfetch.1
+ +

Exit Codes

+ + + + + + + + + + + + + + + +
CodeMeaning
0Success.
1Generic failure, doctor failures, or update --check finding a newer release.
2Usage error: invalid flag, invalid combination or clap parse error.
3Configuration error.
4Network error or a request blocked by --offline.
5Addon error.
6Update error.
7Not found: unknown module, config key or addon.
+ +

Environment Variables

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableEffect
XFETCH_PLUGIN_REPODefault plugin install repository.
XFETCH_EFFECT_REPODefault effect install repository.
XFETCH_EXTENSION_REPODefault extension install repository.
XFETCH_PLUGIN_INDEXPlugin catalog URL or local path.
XFETCH_EFFECT_INDEXEffect catalog URL or local path.
XFETCH_EXTENSION_INDEXExtension catalog URL or local path.
XFETCH_LOGOS_URLBase URL for the logo catalog.
XFETCH_THEME_INDEXTheme registry index URL or local path.
XFETCH_COMMAND_INDEXCommand addon catalog URL or local path.
XFETCH_COMMAND_REPODefault command addon install repository.
XFETCH_COMMAND_DEV_DIRExtra development directory for command lookup.
XFETCH_BACKUP_PASSPHRASEPassphrase for encrypted config backups.
VISUAL, EDITOREditor used by xfetch config edit.
XFETCH_PLUGIN_DEV_DIRExtra development directory for plugin lookup.
XPM_AUR_STATEOverride the xpm-aur state file used for package counts.
XDG_CONFIG_HOMEConfig root (default ~/.config).
XDG_CACHE_HOMECache root (default ~/.cache).
XDG_STATE_HOMEState root (default ~/.local/state).
KITTY_WINDOW_IDEnables the Kitty graphics protocol when set.
TERM_PROGRAM, LC_TERMINALSelect iTerm-compatible inline images when supported.
+ +

Platform Notes

+ + + + + + + + + + +
PlatformNotes
LinuxConfig under ~/.config/xfetch. Package counts read local databases. Daemon mode uses a terminal scroll region.
macOSConfig under ~/Library/Application Support/xfetch with a symlink from the XDG path. Homebrew installs are updated through Homebrew.
WindowsConfig under %APPDATA%\xfetch. Install with winget install xfetch or the PowerShell installers. The updater uses self-replace; winget-managed updates are delegated to winget upgrade. Daemon mode uses a worker process instead of fork.
+ +

+ Images use the Kitty graphics protocol when available, iTerm inline images in + compatible terminals, and half blocks otherwise. Text colors are controlled by + --color; images are not affected by --no-color. +

+ +

Examples

+ +
# Normal fetch
+xfetch
+
+# Machine-readable output
+xfetch --json
+xfetch --json --benchmark
+
+# Specific values
+xfetch --get os,cpu,memory
+xfetch --get version
+
+# Runtime overrides
+xfetch --layout right --theme dracula
+xfetch --modules os,kernel,cpu,memory --no-color
+xfetch --format '{{os}} | {{kernel}} | {{uptime}}'
+xfetch --format '{{#if gpu}}gpu: {{gpu|join:", "}}{{else}}no gpu{{/if}}'
+
+# Diagnostics
+xfetch doctor
+xfetch doctor --network --json
+xfetch doctor --fix
+
+# Configuration
+xfetch config validate
+xfetch config print
+xfetch config get layout
+xfetch config set layout right
+xfetch config set modules.1 kernel
+xfetch config unset colors.cpu
+xfetch config reset --yes
+xfetch config edit
+xfetch config backups
+xfetch config prune --keep 5
+xfetch --backup
+xfetch --restore
+xfetch config backup --output ~/backups --compress
+xfetch config backup --output ~/backups/secure.enc --encrypt --passphrase-file pass.txt
+xfetch config restore --from ~/backups --dry-run
+xfetch config restore --root ~/restored
+
+# Themes and logos
+xfetch theme search dark
+xfetch theme install nord
+xfetch theme update --all --check
+xfetch logo list
+xfetch logo preview arch
+
+# Command addons
+xfetch command install github
+xfetch run github -- rust-lang/rust
+xfetch --json run github -- rust-lang/rust
+xfetch --layout right run github --render -- rust-lang/rust
+
+# Addons
+xfetch addon plugin install banner
+xfetch plugin update --all --check
+xfetch addon plugin list --json
+xfetch effects install decrypt
+xfetch extension install config-roulette
+
+# Shell integration
+xfetch completions zsh
+xfetch man --output ~/.local/share/man/man1/xfetch.1
+xfetch schema fetch
+
+# Updates
+xfetch update --check
+xfetch update --check --json
+xfetch update
+xfetch update --rollback
+
+# Remove xfetch
+xfetch self-uninstall --purge --yes
+ +

Security Notes

+ +
    +
  • Native addons run unsandboxed with the current user's privileges.
  • +
  • Wasm addons use the deny-by-default manifest policy with time, memory, output, host-call and capability limits.
  • +
  • --sha256 on install is the provenance pin; it overrides the manifest digest.
  • +
  • Catalog fetches are bounded and schema-checked.
  • +
  • Config backups contain the raw config, referenced theme/route files and, with --with-addons, addon artifacts. Store them accordingly. On Unix they are written with mode 0600.
  • +
  • Encrypted backups use Argon2id and XChaCha20-Poly1305. Pass the passphrase through --passphrase-file or XFETCH_BACKUP_PASSPHRASE; do not put it directly on the command line.
  • +
  • --offline blocks xfetch network calls, but native addons are trusted code and can still make their own network requests. Wasm addons remain governed by the manifest policy.
  • +
  • Never place secrets in config files, addon manifests or command lines; prefer environment variables supplied by your shell or secret manager.
  • +
+ +

Logging

+ +
xfetch --log-level debug
+xfetch --log-level trace --verbose
+xfetch --log-json --log-level debug
+xfetch --log-level warn --quiet
+xfetch --log-level off
+ +

+ Log levels are off, error, warn, + info, debug and trace. Fatal CLI errors + are always printed. --quiet maps to warn; + --verbose maps to debug; an explicit + --log-level wins. --log-json writes + {"level": "...", "target": "xfetch", "message": "..."} lines to + stderr, keeping stdout clean for command results and --json. +

+ +

Diagnostics Commands

+ +
xfetch paths
+xfetch paths --json
+xfetch env
+xfetch env --json
+xfetch build-info
+xfetch build-info --json
+ +

+ paths prints the resolved config file, config directory, themes, + plugins, effects, extensions, backups, cache and data paths. + env adds build information, platform details, environment + variables (terminal, session, XDG), runtime settings and the same path map. + build-info prints the version, target OS/architecture, build + profile and enabled features (wasm, update, encryption). All three support + --json. +

+ +

Sandbox Test

+ +

+ Build the release binary and run the end-to-end sandbox: +

+ +
cargo build --release --locked
+bash scripts/test-cli-sandbox.sh
+ +

+ The script uses an isolated HOME and XDG directories, exercises + the output modes, overrides, templates, config editing, doctor (including + --fix), themes and logos, addons, wasm tooling, completions, man + page, schema, backup/restore (compression, encryption, assets, addons and + root remapping), update rollback, self-uninstall and a config-roulette setup + with a fake extension. It prints the sandbox path and a pass/fail summary. +

+ +

Related Documents

+ + diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index ab6d858..17893dc 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -29,6 +29,12 @@

Command Line Options

+

+ This table is a summary. The complete reference, including output modes, JSON + schemas, runtime overrides and exit codes, is in + CLI.md. +

+ @@ -43,14 +49,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - + - +
Flag / CommandDescription
xfetch --daemon-live-reloadForce hot reload in the live stats daemon (same as "daemon_live_reload": true).
xfetch --gen-configGenerate a starter config at the default location. See GEN_CONFIG.md.
xfetch --list-layoutsPrint the layout names accepted by --layout.
xfetch --layout <name>Override the layout for this run. With --gen-config, select the generated layout.
xfetch --theme <name>Override the active theme for this run.
xfetch --modules <list>, --only <list>Override the rendered module list with a comma-separated key list.
xfetch --jsonEmit machine-readable JSON with schema_version: 1.
xfetch --get <key>Print only the requested module keys. Repeat the flag or use commas.
xfetch --template <file>, --format <string>Render the fetch with a template using {{key}} placeholders.
xfetch --color auto|always|never, --no-colorControl ANSI output. Images are not affected.
xfetch --offlineDisable public IP, catalogs, update checks and logo fetch.
xfetch --no-cacheBypass the package and public-IP cache for this run.
xfetch --quiet, --verboseSuppress informational output or print extra diagnostics.
xfetch --log-level <level>Log level: off, error, warn, info, debug or trace.
xfetch --log-jsonEmit logs as JSON lines on stderr.
xfetch --list-modulesPrint the module keys accepted in modules.
xfetch doctor [--network] [--fix] [--bench] [--image]Diagnose the binary, config, terminal, image protocol, addons and cache. --fix applies safe fixes; --bench adds probe timings; --image previews the logo in a TTY.
xfetch modulesList the module registry (same as --list-modules).
xfetch config validate|print|get|set|unset|reset|edit|backups|prune|backup|restoreValidate, print, read, edit, back up or restore the configuration. See CLI.md.
xfetch completions <shell>Generate shell completions (bash, zsh, fish, elvish, powershell).
xfetch man [--output <path>]Render the man page.
xfetch addon <kind> <action>Unified addon command for plugins, effects, extensions and commands.
xfetch command install|list|info|search|update|rollback|removeManage CLI command addons. See CLI.md.
xfetch run <name> [--] <args>Run an installed command addon; --render uses the core layout.
xfetch theme search|info|install|update|list|set|remove|exportManage local and remote themes. See THEMES.md and CLI.md.
xfetch logo list|previewBrowse and preview the ASCII logo catalog.
xfetch schema [kind]Print JSON Schema documents for machine-readable outputs.
xfetch pathsPrint resolved config, addon, cache, backup and data paths.
xfetch envPrint build, platform, environment, settings and path context.
xfetch build-infoPrint version, target, profile and enabled features.
xfetch self-uninstall [--purge]Remove the binary and optionally config, cache and data.
xfetch --clean-cacheClear the module cache.
xfetch --backupBack up the config currently in use, including its theme and config-roulette routes.
xfetch --restoreRestore the most recent config backup.
xfetch --benchmarkPrint benchmarking info for the info gathering step.
xfetch plugin install|list|remove <name>Manage plugins. See PLUGINS.md.
xfetch extension install|list|remove <name>Manage extensions. See EXTENSIONS.md.
xfetch plugin install|list|update|rollback|remove <name>Manage plugins, including install --all and update --all. See PLUGINS.md.
xfetch extension install|list|update|rollback|remove <name>Manage extensions, including install --all and update --all. See EXTENSIONS.md.
xfetch theme list|set|remove|export <name>Manage themes. See THEMES.md.
xfetch effects install|list|remove <name>Manage intro effects. See EFFECTS.md.
xfetch effects install|list|update|rollback|remove <name>Manage intro effects, including install --all and update --all. See EFFECTS.md.
xfetch wasm inspect|run|witWebAssembly guest tooling. See WASM.md.
xfetch update [--check] [--prebuilt] [--bin-dir <dir>] [--yes]Check for a newer release and install it when the install method is recognized. See INSTALLATION.md.
xfetch update [--check] [--prebuilt] [--bin-dir <dir>] [--yes] [--rollback]Check for a newer release and install it when the install method is recognized, or roll back to the previous binary. See INSTALLATION.md.
diff --git a/docs/DAEMON.md b/docs/DAEMON.md index ee94be4..ca80f8a 100644 --- a/docs/DAEMON.md +++ b/docs/DAEMON.md @@ -48,6 +48,9 @@

The --daemon CLI flag overrides the daemon config value. + The complete flag reference, including --no-daemon-live, + --daemon-live-stop and --daemon-live-reload, is in + CLI.md.

Stopping the daemon

diff --git a/docs/EFFECTS.md b/docs/EFFECTS.md index 443150a..6cf7b6f 100644 --- a/docs/EFFECTS.md +++ b/docs/EFFECTS.md @@ -22,6 +22,17 @@
xfetch effects install ./effects/decrypt
+

+ Installs and updates write an .install.json receipt next to the + effect and keep the previous artifact as .bak for rollback. +

+ +

+ The same actions are available through the unified form + xfetch addon effect <action>. See + CLI.md for the complete command and JSON reference. +

+

Manage installed effects:

xfetch effects list
@@ -29,6 +40,10 @@ xfetch effects list --verbose
 xfetch effects info decrypt
 xfetch effects search <query>
 xfetch effects available
+xfetch effects update decrypt
+xfetch effects update --all
+xfetch effects update --all --check
+xfetch effects rollback decrypt
 xfetch effects remove decrypt

diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 604a791..c04276a 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -21,6 +21,17 @@

xfetch extension install /path/to/xfetch-extension-name
 xfetch extension install my-extension --repo https://github.com/user/extensions.git
+

+ Installs and updates write an .install.json receipt next to the + extension and keep the previous artifact as .bak for rollback. +

+ +

+ The same actions are available through the unified form + xfetch addon extension <action>. See + CLI.md for the complete command and JSON reference. +

+

List, inspect and remove installed extensions:

xfetch extension list
@@ -28,6 +39,10 @@ xfetch extension list --verbose
 xfetch extension info <name>
 xfetch extension search <query>
 xfetch extension available
+xfetch extension update <name>
+xfetch extension update --all
+xfetch extension update --all --check
+xfetch extension rollback <name>
 xfetch extension remove <name>

diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index d499e3e..9d949de 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -91,6 +91,19 @@

irm https://raw.githubusercontent.com/xfetch-cli/xfetch/main/install.ps1 | iex
+

Windows (winget)

+ +

+ xfetch is available from the Windows Package Manager: +

+ +
winget install xfetch
+ +

+ See Windows (winget) below for update and + uninstall commands. +

+

What the Script Does

    @@ -265,6 +278,36 @@ makepkg -si
    +

    Windows (winget)

    + +

    + xfetch is available from the Windows Package Manager. + No Rust toolchain or compilation is needed: +

    + +
    winget install xfetch
    + +

    + To update to a newer release: +

    + +
    winget upgrade xfetch
    + +

    + To uninstall: +

    + +
    winget uninstall xfetch
    + +

    + Note: xfetch detects winget-managed installs and delegates + xfetch update to + winget upgrade --id Xfetch-cli.Xfetch --exact. winget may lag + behind the GitHub release while its manifest is reviewed. +

    + +
    +

    Verifying Installation

    @@ -313,7 +356,7 @@ makepkg -si and gh):

    -
    VERSION=1.0.2
    +
    VERSION=1.0.3
     ASSET="xfetch-${VERSION}-x86_64-unknown-linux-gnu.tar.gz"
     BASE="https://github.com/xfetch-cli/xfetch/releases/download/v${VERSION}"
     
    @@ -395,11 +438,13 @@ xfetch update --yes     # skip the confirmation prompt
    • - Prebuilt installs (install-prebuilt.sh, - typically ~/.local/bin): the matching release asset is - downloaded, verified against the published SHA256SUMS, - extracted and moved over the current executable with an atomic rename. A - single xfetch.bak keeps the previous binary and is + Prebuilt installs (install-prebuilt.sh on + Unix, install-prebuilt.ps1 on Windows): the matching release + asset is downloaded, verified against the published + SHA256SUMS, extracted and moved over the current executable. + Unix uses an atomic rename; Windows uses a self-replace helper because a + running .exe cannot be deleted. A single xfetch.bak + (or xfetch.exe.bak) keeps the previous binary and is overwritten on the next update.
    • @@ -407,16 +452,38 @@ xfetch update --yes # skip the confirmation prompt
    cargo install xfetch-cli --force --locked.
  1. - Package-manager installs and local builds: never replaced; - the command prints the right command instead. + Source builds (install.ps1 or + cargo install --path): rebuilt from the source checkout when + it still exists; otherwise the command prints the rebuild command. They + are never replaced with the crates.io release. +
  2. +
  3. + winget installs (Windows): delegated to + winget upgrade --id Xfetch-cli.Xfetch --exact. xfetch never + overwrites a winget-managed file itself. winget may lag behind the GitHub + release while its manifest is reviewed; in that case the command reports + the pending manifest instead of pretending the update succeeded. +
  4. +
  5. + Manual copies and local builds: never replaced + automatically; the command prints the right command or the release URL.
  6. +

    + The PowerShell installers write + %LOCALAPPDATA%\xfetch\install.json to record whether the binary + is a prebuilt install or a source build. This is what distinguishes a + script-installed binary from a manual copy in the same directory. Existing + installs without a receipt are treated conservatively as unknown. +

    +

    xfetch update --prebuilt --bin-dir <dir> forces the - in-place update of <dir>/xfetch (Unix only). On Windows - the prebuilt path is not available yet; use - cargo install xfetch-cli --force or your installer of choice. + in-place update of <dir>/xfetch (or + <dir>\xfetch.exe on Windows). It refuses to touch + binaries owned by cargo or winget. This is also the escape hatch for a + manually downloaded binary.

    @@ -431,7 +498,12 @@ xfetch update --yes # skip the confirmation prompt See the Uninstallation Guide for detailed instructions.

    -

    Quick uninstall:

    +

    Quick uninstall from the CLI:

    + +
    xfetch self-uninstall
    +xfetch self-uninstall --purge --yes
    + +

    Quick uninstall script:

    curl -fsSL https://raw.githubusercontent.com/xfetch-cli/xfetch/main/uninstall.sh | bash
    @@ -446,6 +518,29 @@ rm -rf ~/.config/xfetch
    +

    Shell Integration

    + +

    + Generate completions for your shell and install the man page: +

    + +
    # Bash
    +xfetch completions bash > ~/.local/share/bash-completion/completions/xfetch
    +
    +# Zsh
    +xfetch completions zsh > ~/.zfunc/_xfetch
    +
    +# Fish
    +xfetch completions fish > ~/.config/fish/completions/xfetch.fish
    +
    +# Man page
    +xfetch man --output ~/.local/share/man/man1/xfetch.1
    + +

    + See CLI.md for the complete command reference, JSON + output and exit codes. +

    +

    Next Steps

      diff --git a/docs/LAYOUTS.md b/docs/LAYOUTS.md index f998eac..8db37e7 100644 --- a/docs/LAYOUTS.md +++ b/docs/LAYOUTS.md @@ -297,6 +297,21 @@ memory: 16 GiB / 32 GiB (50%) "layout": "bottom" } +

      16. Right Layout

      + +

      + Mirrors the classic layout: the info column is on the left and the logo, + ASCII or image, is on the right. +

      + +

      Configuration:

      + +
      {
      +    "layout": "right",
      +    "logo_path": "~/.config/xfetch/logo.png",
      +    "logo_width": 24
      +}
      +

      Module Grouping

      diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 0110aa8..5877030 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -26,11 +26,14 @@ End users should install official plugins by name from the remote repository:

      -
      xfetch plugin install animate-logo
      +
      xfetch plugin install animate-logo
      +xfetch plugin install --all

      The core downloads the plugin source from the official remote, builds it, and installs - the resulting binary into the xfetch plugin directory. + the resulting binary into the xfetch plugin directory. install --all + installs every plugin advertised by the catalog and skips plugins that are already + installed; add --force to reinstall them.

      @@ -48,11 +51,29 @@ XFETCH_PLUGIN_REPO=https://github.com/user/plugins.git xfetch plugin install my- an extra development directory to that search path.

      +

      + Every successful install writes an .install.json receipt next to + the plugin. It records the version, source, repository and artifact hash used + by update. Updates rebuild native plugins from the recorded + source and keep the previous binary as .bak for + rollback. +

      + +

      + The same actions are available through the unified form + xfetch addon plugin <action>. See + CLI.md for the complete command and JSON reference. +

      +

      Manage installed plugins

      xfetch plugin list
       xfetch plugin list --verbose
       xfetch plugin info <name>
      +xfetch plugin update <name>
      +xfetch plugin update --all
      +xfetch plugin update --all --check
      +xfetch plugin rollback <name>
       xfetch plugin search <query>
       xfetch plugin available
       xfetch plugin remove <name>
      diff --git a/docs/THEMES.md b/docs/THEMES.md index cbcaf9f..6f8566b 100644 --- a/docs/THEMES.md +++ b/docs/THEMES.md @@ -136,8 +136,32 @@ xfetch theme remove nord # Export current visual config as a reusable theme file xfetch theme export my-theme + +# Apply a theme to one run without editing the config +xfetch --theme nord + +# Machine-readable theme list +xfetch --json theme list + +# Remote registry: search, inspect, install and update +xfetch theme search dark +xfetch theme info nord +xfetch theme install nord +xfetch theme update --all --check +xfetch theme update nord +

      + All theme commands accept the global --json flag. Remote commands + use the registry at + https://raw.githubusercontent.com/xfetch-cli/themes/main/index.json + (override with XFETCH_THEME_INDEX) and are blocked by + --offline. Installed themes keep a + .<name>.xfetch-meta.json sidecar with version and source. + theme update --check exits 1 when an update is available. See + CLI.md for the JSON shapes and exit codes. +

      +

      Export Behavior

      diff --git a/docs/UNINSTALLATION.md b/docs/UNINSTALLATION.md index a64057f..81bbb00 100644 --- a/docs/UNINSTALLATION.md +++ b/docs/UNINSTALLATION.md @@ -60,3 +60,9 @@ export PATH="$HOME/.local/bin:$PATH"

      irm https://raw.githubusercontent.com/xfetch-cli/xfetch/main/uninstall.ps1 | iex -Args "-Purge"
      + +

      + If you installed with winget, uninstall through winget instead: +

      + +
      winget uninstall xfetch
      diff --git a/docs/WASM.md b/docs/WASM.md index 535ccd7..500d02f 100644 --- a/docs/WASM.md +++ b/docs/WASM.md @@ -1,8 +1,8 @@

      WebAssembly Guests

      - Plugins, effects and extensions can be WebAssembly artifacts instead of native - executables. The core detects them by their binary header, runs them inside a + Plugins, effects, extensions and command addons can be WebAssembly artifacts + instead of native executables. The core detects them by their binary header, runs them inside a sandboxed wasmtime runtime and keeps the same JSON protocol version 1, so the rest of the system (config, timeouts, list/remove commands) is unchanged.

      @@ -23,13 +23,13 @@ Core module wasm32-wasip1 - JSON request on stdin, JSON response on stdout, host calls through the xfetch.host_call import + JSON request on stdin, JSON or plain-text response on stdout, host calls through the xfetch.host_call import Rust, C, Zig, Go, AssemblyScript, ... Component Component model (wasm32) - Export run from the WIT world, typed host imports + Export run from the plugin/effect/extension/command WIT world, typed host imports Python (componentize-py), JavaScript (componentize-js), Rust (wit-bindgen), ... @@ -62,6 +62,11 @@ xfetch plugin install https://example.com/releases/plugin.wasm --sha256 <64-h xfetch effects install ./effects/wasm-matrix xfetch extension install ./extensions/wasm-night-mode +

      + The complete wasm command reference, including JSON output and + exit codes, is in CLI.md. +

      +

      wasm inspect prints a human-readable report or the same data as JSON with --json. wasm run accepts the request diff --git a/install-prebuilt.ps1 b/install-prebuilt.ps1 index 7cc44f0..e00d093 100644 --- a/install-prebuilt.ps1 +++ b/install-prebuilt.ps1 @@ -5,7 +5,7 @@ # # Usage: # irm https://raw.githubusercontent.com/xfetch-cli/xfetch/main/install-prebuilt.ps1 | iex -# .\install-prebuilt.ps1 -Version 1.0.2 -BinDir C:\tools\bin +# .\install-prebuilt.ps1 -Version 1.0.3 -BinDir C:\tools\bin # .\install-prebuilt.ps1 -NoChecksum -NoPath param( @@ -29,6 +29,33 @@ function Fail($Message) { exit 1 } +# Records how this copy was installed. The updater reads it before falling +# back to path heuristics, so a prebuilt install and a manually copied build +# in the same directory are not confused. +function Write-InstallReceipt($Method, $Path, $Version, $Source, $SourcePath, $Repo, $Commit) { + if (-not $env:LOCALAPPDATA) { return } + try { + $receiptDir = Join-Path $env:LOCALAPPDATA "xfetch" + New-Item -ItemType Directory -Path $receiptDir -Force | Out-Null + $receipt = [ordered]@{ + schema_version = 1 + method = $Method + path = $Path + installed_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") + } + if ($Version) { $receipt.version = $Version } + if ($Source) { $receipt.source = $Source } + if ($SourcePath) { $receipt.source_path = $SourcePath } + if ($Repo) { $receipt.repo = $Repo } + if ($Commit) { $receipt.commit = $Commit } + $json = $receipt | ConvertTo-Json -Compress + $utf8 = New-Object System.Text.UTF8Encoding -ArgumentList $false + [System.IO.File]::WriteAllText((Join-Path $receiptDir "install.json"), $json, $utf8) + } catch { + Write-Warn "Could not write install receipt: $($_.Exception.Message)" + } +} + # Windows PowerShell 5.1 defaults to older TLS versions; GitHub needs 1.2. if ($PSVersionTable.PSVersion.Major -lt 6) { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 @@ -128,6 +155,7 @@ try { Fail "Could not install the binary: $($_.Exception.Message)" } Write-Ok "Installed binary: $installPath" + Write-InstallReceipt -Method "prebuilt" -Path $installPath -Version $Version -Source "install-prebuilt.ps1" if (-not $NoPath -and $env:OS -eq "Windows_NT") { $userPath = [Environment]::GetEnvironmentVariable("Path", "User") diff --git a/install-prebuilt.sh b/install-prebuilt.sh index 40cb54a..3fed8bf 100755 --- a/install-prebuilt.sh +++ b/install-prebuilt.sh @@ -3,7 +3,7 @@ # Prebuilt installer — downloads a precompiled binary from GitHub Releases. # Fast: no Rust toolchain, no compilation. Requires only curl (and tar/unzip). # Usage: curl -fsSL https://raw.githubusercontent.com/xfetch-cli/xfetch/main/install-prebuilt.sh | bash -# bash install-prebuilt.sh --version 1.0.2 +# bash install-prebuilt.sh --version 1.0.3 # bash install-prebuilt.sh --prefix /usr/local --yes set -euo pipefail @@ -174,7 +174,7 @@ Options: -y, --yes Automatic yes to all prompts -s, --skip-config Skip generating the default config -q, --quiet Quiet mode (minimal output) - --version Install a specific version (e.g. 1.0.2); default: latest release + --version Install a specific version (e.g. 1.0.3); default: latest release --require-signature Abort when cosign is unavailable or the signature bundle is missing --allow-unsigned Accept a signed release that is missing SHA256SUMS.bundle (SHA-256 only; not recommended) @@ -200,7 +200,7 @@ Examples: curl -fsSL ${REPO_RAW}/install-prebuilt.sh | bash # Install a specific version - curl -fsSL ${REPO_RAW}/install-prebuilt.sh | bash -s -- --version 1.0.2 + curl -fsSL ${REPO_RAW}/install-prebuilt.sh | bash -s -- --version 1.0.3 # Non-interactive install bash install-prebuilt.sh --yes @@ -255,7 +255,7 @@ parse_args() { # ────────────────────────────────────────────── # A release version must be a dotted numeric version, optionally with a -# prerelease suffix (1.0.2, 1.0.2-rc1, 1.0.2-rc.2). Anything else is rejected +# prerelease suffix (1.0.3, 1.0.3-rc1, 1.0.3-rc.2). Anything else is rejected # instead of being silently coerced. validate_version() { printf '%s\n' "${1:-}" \ @@ -268,7 +268,7 @@ resolve_asset_url() { release_url="${REPO_API}/releases/latest" else validate_version "${VERSION}" \ - || die "Invalid --version '${VERSION}': expected a dotted numeric release such as 1.0.2." + || die "Invalid --version '${VERSION}': expected a dotted numeric release such as 1.0.3." release_url="${REPO_API}/releases/tags/v${VERSION}" fi diff --git a/install.ps1 b/install.ps1 index 66e2efc..7f164ee 100644 --- a/install.ps1 +++ b/install.ps1 @@ -8,6 +8,32 @@ param( Write-Host "Installing xfetch..." -ForegroundColor Cyan +# Records that this binary came from a source checkout. The updater uses it +# to distinguish a path install from `cargo install xfetch-cli`. +function Write-InstallReceipt($Method, $Path, $Version, $Source, $SourcePath, $Repo, $Commit) { + if (-not $env:LOCALAPPDATA) { return } + try { + $receiptDir = Join-Path $env:LOCALAPPDATA "xfetch" + New-Item -ItemType Directory -Path $receiptDir -Force | Out-Null + $receipt = [ordered]@{ + schema_version = 1 + method = $Method + path = $Path + installed_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") + } + if ($Version) { $receipt.version = $Version } + if ($Source) { $receipt.source = $Source } + if ($SourcePath) { $receipt.source_path = $SourcePath } + if ($Repo) { $receipt.repo = $Repo } + if ($Commit) { $receipt.commit = $Commit } + $json = $receipt | ConvertTo-Json -Compress + $utf8 = New-Object System.Text.UTF8Encoding -ArgumentList $false + [System.IO.File]::WriteAllText((Join-Path $receiptDir "install.json"), $json, $utf8) + } catch { + Write-Host "Could not write install receipt: $($_.Exception.Message)" -ForegroundColor Yellow + } +} + # Check for Rust/Cargo if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { if ($Yes -or [Console]::IsInputRedirected) { @@ -78,6 +104,17 @@ if ($UserPath -notlike "*$CargoBin*") { Write-Host "Added $CargoBin to user PATH." -ForegroundColor Green } +# Record the source build before the temporary clone is removed. +$InstalledVersion = "" +try { + $InstalledVersion = ((& xfetch --version 2>$null) -replace '^xfetch(?:-cli)?\s+', '').Trim() +} catch {} +$Commit = "" +try { + $Commit = (git -C $TempDir rev-parse HEAD 2>$null).Trim() +} catch {} +Write-InstallReceipt -Method "source" -Path (Join-Path $CargoBin "xfetch.exe") -Version $InstalledVersion -Source "install.ps1" -Repo $RepoUrl -Commit $Commit + # Setup Config $ConfigDir = Join-Path $env:APPDATA "xfetch" Write-Host "Setting up default config..." -ForegroundColor Cyan diff --git a/scripts/test-cli-sandbox.sh b/scripts/test-cli-sandbox.sh new file mode 100755 index 0000000..30dd64c --- /dev/null +++ b/scripts/test-cli-sandbox.sh @@ -0,0 +1,473 @@ +#!/usr/bin/env bash +# End-to-end CLI sandbox test. Usage: scripts/test-cli-sandbox.sh [xfetch-binary] +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BIN="${1:-$SCRIPT_DIR/../target/release/xfetch}" +if [ ! -x "$BIN" ]; then + echo "xfetch binary not found at $BIN" + echo "Run: cargo build --release --locked" + exit 1 +fi +SB="$(mktemp -d /tmp/xfetch-cli-sandbox.XXXXXX)" +export HOME="$SB/home" +export XDG_CONFIG_HOME="$SB/config" +export XDG_CACHE_HOME="$SB/cache" +export XDG_STATE_HOME="$SB/state" +OUT="$SB/out.txt" +ERR="$SB/err.txt" +mkdir -p "$HOME" "$XDG_CONFIG_HOME/xfetch" "$XDG_CACHE_HOME" "$XDG_STATE_HOME" + +pass=0 +fail_count=0 +fail() { echo "FAIL: $*"; fail_count=$((fail_count + 1)); } +ok() { pass=$((pass + 1)); } +run() { "$@" >"$OUT" 2>"$ERR" || { fail "command failed: $*"; cat "$OUT" "$ERR"; }; } +expect() { + local want=$1; shift + "$@" >"$OUT" 2>"$ERR" + local got=$? + if [ "$got" -ne "$want" ]; then + fail "expected exit $want, got $got: $*" + cat "$OUT" "$ERR" + else + ok + fi +} +contains() { grep -q -- "$1" "$OUT" || fail "missing '$1' in output of $2"; } +json_check() { python3 -c "$1" <"$OUT" || fail "json check failed: $1"; } + +# Basic info and discovery. +run "$BIN" --version +contains "xfetch 1.0.3" 1 +run "$BIN" --help +contains "--backup" help +contains "--restore" help +run "$BIN" --list-layouts +contains "right" layouts +run "$BIN" --json --list-layouts +json_check 'import json,sys; v=json.load(sys.stdin); assert "right" in v' +run "$BIN" --list-modules +contains "packages:" modules +run "$BIN" modules +contains "packages:" modules +run "$BIN" --json modules +json_check 'import json,sys; v=json.load(sys.stdin); assert any(x["key"]=="os" for x in v)' +run "$BIN" --json --list-modules +json_check 'import json,sys; v=json.load(sys.stdin); assert any(x["key"]=="os" for x in v)' + +# Output modes. +run "$BIN" --json +json_check 'import json,sys; v=json.load(sys.stdin); assert v["schema_version"]==1 and v["name"]=="xfetch"' +run "$BIN" --json --benchmark +json_check 'import json,sys; v=json.load(sys.stdin); assert "benchmark" in v' +run "$BIN" --get os +[ -n "$(cat "$OUT")" ] || fail "--get os returned no value" +run "$BIN" --get os,cpu +contains "os=" get-os-cpu +contains "cpu=" get-os-cpu +run "$BIN" --json --get os,cpu +json_check 'import json,sys; v=json.load(sys.stdin); assert v["schema_version"]==1 and "values" in v and "os" in v["values"]' +run "$BIN" --format 'os={{os}} name={{name}} version={{version}} missing={{nope}}' +contains "os=" format +contains "name=xfetch" format +contains "version=1.0.3" format +printf 'kernel={{kernel}}\n' >"$SB/template.txt" +run "$BIN" --template "$SB/template.txt" +contains "kernel=" template + +# Runtime overrides. +run "$BIN" --layout right --json +json_check 'import json,sys; v=json.load(sys.stdin); assert v["schema_version"]==1' +run "$BIN" --modules os,kernel --json +json_check 'import json,sys; v=json.load(sys.stdin); assert v["schema_version"]==1' +run "$BIN" --color never --modules os,kernel +[ ! -s "$OUT" ] || ! grep -q $'\x1b' "$OUT" || fail "ANSI found with --color never" +run "$BIN" --color always --modules os,kernel +grep -q $'\x1b' "$OUT" || fail "no ANSI with --color always" +run "$BIN" --no-cache --json +run "$BIN" --offline --json +run "$BIN" --quiet --json +run "$BIN" --verbose --get os +grep -q "config:" "$ERR" || fail "verbose diagnostics missing" + +# Conflicts and stable errors. +expect 2 "$BIN" --json --format '{{os}}' +expect 2 "$BIN" --get os --format '{{os}}' +expect 2 "$BIN" --layout nope --get os +expect 3 "$BIN" --theme nope --get os +expect 7 "$BIN" --get nope +expect 4 "$BIN" --offline plugin search logo +expect 6 "$BIN" --offline update --check + +# Configuration commands and simple backup/restore. +CONFIG="$XDG_CONFIG_HOME/xfetch/config.jsonc" +printf '{\n // keep me\n "layout": "section",\n "modules": ["os", "kernel"]\n}\n' >"$CONFIG" +run "$BIN" config validate +contains "Config OK" validate +run "$BIN" --json config validate +json_check 'import json,sys; v=json.load(sys.stdin); assert v["valid"] is True' +run "$BIN" config print --raw +contains "// keep me" print-raw +run "$BIN" config print +contains '"layout"' print +run "$BIN" --json config print +json_check 'import json,sys; v=json.load(sys.stdin); assert "layout" in v' +run "$BIN" config get layout +contains "section" get-layout +run "$BIN" --json config get modules +json_check 'import json,sys; v=json.load(sys.stdin); assert isinstance(v,list) and "os" in v' +run "$BIN" --quiet config set layout right +[ -s "$OUT" ] && fail "quiet config set printed output" +grep -q '"layout": "right"' "$CONFIG" || fail "config set did not write layout right" +[ "$(grep -c '// keep me' "$CONFIG")" -eq 1 ] || fail "comment lost" +run "$BIN" --json config set colors.cpu red +json_check 'import json,sys; v=json.load(sys.stdin); assert v["key"]=="colors.cpu"' + +# Backup/restore: simple config. +run "$BIN" --json --backup +json_check 'import json,sys; v=json.load(sys.stdin); assert v["schema_version"]==1 and v["path"].endswith(".json")' +BACKUP_SIMPLE=$(python3 -c 'import json,sys; print(json.load(open("/dev/stdin"))["path"])' <"$OUT") +printf '{ "layout": "minimal" }\n' >"$CONFIG" +run "$BIN" --json --restore +json_check 'import json,sys; v=json.load(sys.stdin); assert v["restored"]' +grep -q '"layout": "right"' "$CONFIG" || fail "restore did not restore layout right" +run "$BIN" config backup --output "$SB/backups" +mkdir -p "$SB/backups" 2>/dev/null || true +[ -f "$BACKUP_SIMPLE" ] || fail "simple backup file missing" +printf '{ "layout": "tree" }\n' >"$CONFIG" +LATEST_DIR="$XDG_CONFIG_HOME/xfetch/backups" +run "$BIN" config restore --from "$LATEST_DIR" +grep -q '"layout": "right"' "$CONFIG" || fail "restore from dir did not restore layout right" +printf '{ "layout": "tree" }\n' >"$CONFIG" +run "$BIN" config restore --dry-run +contains "Would restore" dry-run +grep -q '"layout": "tree"' "$CONFIG" || fail "dry-run wrote to the config" + +# Themes. +THEMES="$XDG_CONFIG_HOME/xfetch/themes" +mkdir -p "$THEMES" +printf '{ "layout": "compact", "colors": { "os": "Cyan" } }\n' >"$THEMES/nord.jsonc" +run "$BIN" --json theme list +json_check 'import json,sys; v=json.load(sys.stdin); assert any(t["name"]=="nord" for t in v["themes"])' +run "$BIN" --json theme set nord +json_check 'import json,sys; v=json.load(sys.stdin); assert v["status"]=="set"' +run "$BIN" --theme nord --json +json_check 'import json,sys; v=json.load(sys.stdin); assert v["schema_version"]==1' +run "$BIN" --theme nord --json config print +json_check 'import json,sys; v=json.load(sys.stdin); assert v["layout"]=="compact"' +run "$BIN" --layout right --json config print +json_check 'import json,sys; v=json.load(sys.stdin); assert v["layout"]=="right"' +run "$BIN" --json theme export exported +json_check 'import json,sys; v=json.load(sys.stdin); assert v["status"]=="exported"' +run "$BIN" --json theme remove exported +json_check 'import json,sys; v=json.load(sys.stdin); assert v["status"]=="removed"' + +# Doctor. +run "$BIN" doctor +contains "checks:" doctor +run "$BIN" --json doctor +json_check 'import json,sys; v=json.load(sys.stdin); assert v["schema_version"]==1 and v["checks"]' +run "$BIN" --quiet doctor +! grep -q "\[environment\]" "$OUT" || fail "quiet doctor printed OK checks" + + +# Diagnostics commands and structured logging. +run "$BIN" paths +contains "config" paths +run "$BIN" --json paths +json_check 'import json,sys; v=json.load(sys.stdin); assert v["config"] and v["config_dir"]' +run "$BIN" env +contains "platform" env +run "$BIN" --json env +json_check 'import json,sys; v=json.load(sys.stdin); assert v["build"] and v["settings"]' +run "$BIN" build-info +contains "wasm support" build-info +run "$BIN" --json build-info +json_check 'import json,sys; v=json.load(sys.stdin); assert v["features"]["encryption"] is True' +run "$BIN" --json doctor --bench +json_check 'import json,sys; v=json.load(sys.stdin); assert "benchmark" in v' +run "$BIN" doctor --image +run "$BIN" --log-json --log-level debug --verbose --get os +python3 - "$ERR" <<'PY' +import json, sys +lines=[line for line in open(sys.argv[1]) if line.strip()] +assert lines, "no structured logs" +for line in lines: + data=json.loads(line) + assert data["level"] == "debug" + assert data["target"] == "xfetch" +PY +[ $? -eq 0 ] || fail "structured log validation failed" +run "$BIN" --log-level off --verbose --get os +[ ! -s "$ERR" ] || fail "log level off still wrote logs" +run "$BIN" help plugin install +contains "Install an addon" help-plugin-install + +# Shell integration. +for shell in bash zsh fish elvish powershell; do + run "$BIN" completions "$shell" + [ -s "$OUT" ] || fail "empty completions for $shell" +done +run "$BIN" man +contains ".TH xfetch" man +run "$BIN" man --output "$SB/xfetch.1" +[ -s "$SB/xfetch.1" ] || fail "man output file missing" + +# Wasm tooling (no-op/error paths). +run "$BIN" wasm wit +contains "xfetch:runtime" wasm-wit +printf 'not wasm\n' >"$SB/not-wasm.bin" +expect 5 "$BIN" wasm inspect "$SB/not-wasm.bin" +expect 5 "$BIN" wasm run "$SB/not-wasm.bin" + +# Addon commands. +run "$BIN" --json addon plugin list +json_check 'import json,sys; v=json.load(sys.stdin); assert v["schema_version"]==1 and isinstance(v["addons"],list)' +run "$BIN" --json plugin list +json_check 'import json,sys; v=json.load(sys.stdin); assert isinstance(v["addons"],list)' +expect 4 "$BIN" --offline addon plugin search logo +expect 4 "$BIN" --offline --json addon plugin install --all +expect 5 "$BIN" --json addon plugin rollback x +expect 5 "$BIN" --json addon plugin remove x + +# JSON addon rollback/remove against a fake installed native artifact. +PLUGINS="$XDG_CONFIG_HOME/xfetch/plugins" +mkdir -p "$PLUGINS" +printf 'new' >"$PLUGINS/xfetch-plugin-test" +printf 'old' >"$PLUGINS/xfetch-plugin-test.bak" +run "$BIN" --json addon plugin rollback test +json_check 'import json,sys; v=json.load(sys.stdin); assert v["status"]=="rolled-back"' +[ "$(cat "$PLUGINS/xfetch-plugin-test")" = "old" ] || fail "rollback did not restore the backup" +run "$BIN" --json addon plugin remove test +json_check 'import json,sys; v=json.load(sys.stdin); assert v["status"]=="removed"' +[ ! -e "$PLUGINS/xfetch-plugin-test" ] || fail "remove did not delete the artifact" + +# Daemon/cache no-op paths. +run "$BIN" --json --daemon-stop +json_check 'import json,sys; v=json.load(sys.stdin); assert "stopped" in v' +run "$BIN" --json --daemon-live-stop +json_check 'import json,sys; v=json.load(sys.stdin); assert "stopped" in v' +run "$BIN" --json --clean-cache +json_check 'import json,sys; v=json.load(sys.stdin); assert v["cleaned"] is True' + +# Config-roulette: routes + theme + fake extension. +ROULETTE="$XDG_CONFIG_HOME/xfetch/roulette" +mkdir -p "$ROULETTE/a" "$ROULETTE/b" +printf '{ "layout": "compact", "colors": { "os": "Cyan" } }\n' >"$THEMES/theme.jsonc" +printf '{ "layout": "compact", "theme": "theme", "modules": ["os"] }\n' >"$ROULETTE/a/config.jsonc" +printf '{ "layout": "tree", "modules": ["kernel"] }\n' >"$ROULETTE/b/config.jsonc" +printf '[{"_name":"a","path":"%s/a/config.jsonc"},{"_name":"b","path":"%s/b/config.jsonc"}]\n' "$ROULETTE" "$ROULETTE" >"$ROULETTE/routes.json" +printf '{\n "config_providers": [\n { "extension": "config-roulette", "args": { "routes": "%s/routes.json" } }\n ]\n}\n' "$ROULETTE" >"$XDG_CONFIG_HOME/xfetch/config.jsonc" +EXT="$XDG_CONFIG_HOME/xfetch/extensions" +mkdir -p "$EXT" +cat >"$EXT/xfetch-extension-config-roulette" <<'PY' +#!/usr/bin/env python3 +import json, os, sys +request = json.load(sys.stdin) +args = request.get("args") or {} +routes = os.path.expanduser(args.get("routes", "~/.config/xfetch/routes.json")) +with open(routes, encoding="utf-8") as handle: + routes_data = json.load(handle) +choice = routes_data[0] +path = os.path.expanduser(choice["path"]) +if not os.path.isabs(path): + path = os.path.join(os.path.dirname(routes), path) +with open(path, encoding="utf-8") as handle: + config = json.load(handle) +json.dump({"config": config}, sys.stdout) +PY +chmod +x "$EXT/xfetch-extension-config-roulette" +run "$BIN" --json config print +json_check 'import json,sys; v=json.load(sys.stdin); assert v["layout"]=="compact"' +run "$BIN" config backup --output "$SB/roulette-backup.json" +python3 - "$SB/roulette-backup.json" <<'PY' >"$SB/roulette-roles.txt" +import json, sys +b=json.load(open(sys.argv[1])) +roles=[f["role"] for f in b["files"]] +print(",".join(roles)) +assert "config" in roles +assert "routes" in roles +assert "theme" in roles +assert roles.count("route-config") == 2 +print("ROULETTE_OK") +PY +grep -q "ROULETTE_OK" "$SB/roulette-roles.txt" || fail "roulette backup roles check failed" +# Restore the roulette bundle after deleting its files. +rm -f "$XDG_CONFIG_HOME/xfetch/config.jsonc" "$ROULETTE/routes.json" "$ROULETTE/a/config.jsonc" "$ROULETTE/b/config.jsonc" "$THEMES/theme.jsonc" +run "$BIN" config restore --from "$SB/roulette-backup.json" +[ -f "$XDG_CONFIG_HOME/xfetch/config.jsonc" ] || fail "roulette config not restored" +[ -f "$ROULETTE/routes.json" ] || fail "roulette routes not restored" +[ -f "$ROULETTE/a/config.jsonc" ] || fail "route a not restored" +[ -f "$ROULETTE/b/config.jsonc" ] || fail "route b not restored" +[ -f "$THEMES/theme.jsonc" ] || fail "theme not restored" + + +# Template conditionals and loops. +run "$BIN" --format '{{#if kernel}}kernel-ok{{else}}no-kernel{{/if}} {{#unless nope}}unless-ok{{/unless}} {{#each gpu}}[{{this}}]{{else}}none{{/each}}' +contains "kernel-ok" template-if +contains "unless-ok" template-unless + +# Config editing: array set, unset, edit and reset. +EDIT_CFG="$SB/edit-config.jsonc" +printf '{ "layout": "section" }\n' >"$EDIT_CFG" +run "$BIN" --config "$EDIT_CFG" config set modules.0 os +run "$BIN" --config "$EDIT_CFG" config set modules.1 kernel +grep -q '"kernel"' "$EDIT_CFG" || fail "config set modules.1 failed" +run "$BIN" --config "$EDIT_CFG" config unset modules.1 +! grep -q '"kernel"' "$EDIT_CFG" || fail "config unset modules.1 failed" +cat >"$SB/editor.sh" <<'SH' +#!/bin/sh +printf '{ "layout": "minimal" }\n' > "$1" +SH +chmod +x "$SB/editor.sh" +env EDITOR="$SB/editor.sh" "$BIN" --config "$EDIT_CFG" config edit >"$OUT" 2>"$ERR" || fail "config edit failed" +grep -q '"minimal"' "$EDIT_CFG" || fail "config edit did not apply the editor output" +run "$BIN" --config "$EDIT_CFG" config reset --yes +grep -q 'xfetch configuration' "$EDIT_CFG" || fail "config reset did not write defaults" +run "$BIN" --json config backups +json_check 'import json,sys; v=json.load(sys.stdin); assert v["backups"]' +run "$BIN" --json --backup +run "$BIN" --json config prune --keep 0 +json_check 'import json,sys; v=json.load(sys.stdin); assert v["deleted"]' + +# Backup compression, encryption, addon assets and root remapping. +ROOT_CFG="$SB/root/xfetch/config.jsonc" +mkdir -p "$SB/root/xfetch/themes" "$SB/compressed" +printf '{ "layout": "compact" }\n' >"$SB/root/xfetch/themes/nord.jsonc" +printf '{ "theme": "%s/root/xfetch/themes/nord.jsonc" }\n' "$SB" >"$ROOT_CFG" +run "$BIN" --config "$ROOT_CFG" config backup --output "$SB/compressed" --compress +ls "$SB/compressed"/*.json.gz >/dev/null 2>&1 || fail "compressed backup missing" +printf 'secret' >"$SB/pass.txt" +run "$BIN" --config "$ROOT_CFG" config backup --output "$SB/encrypted.enc" --encrypt --passphrase-file "$SB/pass.txt" +[ -s "$SB/encrypted.enc" ] || fail "encrypted backup missing" +printf '{ "layout": "tree" }\n' >"$ROOT_CFG" +rm -f "$SB/root/xfetch/themes/nord.jsonc" +run "$BIN" --config "$ROOT_CFG" config restore --from "$SB/encrypted.enc" --passphrase-file "$SB/pass.txt" +grep -q 'theme' "$ROOT_CFG" || fail "encrypted restore did not restore the config" +grep -q 'compact' "$SB/root/xfetch/themes/nord.jsonc" || fail "encrypted restore did not restore the theme" +PLUGIN_ARTIFACT="$XDG_CONFIG_HOME/xfetch/plugins/xfetch-plugin-json-test" +printf 'artifact' >"$PLUGIN_ARTIFACT" +run "$BIN" --config "$ROOT_CFG" config backup --output "$SB/with-addons.json" --with-addons +python3 - "$SB/with-addons.json" <<'PY' +import json, sys +b=json.load(open(sys.argv[1])) +roles=[f["role"] for f in b["files"]] +assert "addon-artifact" in roles, roles +print("ADDON_ASSET_OK") +PY +run "$BIN" --config "$ROOT_CFG" config backup --output "$SB/root-backup.json" +rm -rf "$SB/root" +run "$BIN" config restore --from "$SB/root-backup.json" --root "$SB/root-restored" +[ -f "$SB/root-restored/config.jsonc" ] || fail "root remapped config missing" +[ -f "$SB/root-restored/themes/nord.jsonc" ] || fail "root remapped theme missing" + +# Doctor --fix generates a missing config. +FIX_XDG="$SB/fix-config" +env XDG_CONFIG_HOME="$FIX_XDG" HOME="$SB/fix-home" XDG_CACHE_HOME="$SB/fix-cache" "$BIN" --json doctor --fix >"$OUT" 2>"$ERR" || fail "doctor --fix failed" +json_check 'import json,sys; v=json.load(sys.stdin); assert v["fixes"]' +[ -f "$FIX_XDG/xfetch/config.jsonc" ] || fail "doctor --fix did not generate a config" + +# Schema output. +run "$BIN" --json schema fetch +json_check 'import json,sys; v=json.load(sys.stdin); assert v["title"]=="xfetch fetch output"' + +# Core binary rollback. +RB="$SB/rollback-bin" +mkdir -p "$RB" +printf 'new' >"$RB/xfetch" +printf 'old' >"$RB/xfetch.bak" +run "$BIN" --json update --rollback --bin-dir "$RB" +json_check 'import json,sys; v=json.load(sys.stdin); assert v["status"]=="rolled-back"' +[ "$(cat "$RB/xfetch")" = "old" ] || fail "update rollback failed" + +# Remote theme and logo catalogs via a local HTTP server. +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +if [ -d "$REPO_ROOT/../logos" ] && [ -d "$REPO_ROOT/../themes" ]; then + HTTP_ROOT="$SB/http-root" + mkdir -p "$HTTP_ROOT" + ln -s "$REPO_ROOT/../logos" "$HTTP_ROOT/logos" + ln -s "$REPO_ROOT/../themes" "$HTTP_ROOT/themes" + python3 - "$HTTP_ROOT/themes/index.json" "$HTTP_ROOT/themes/index.local.json" <<'PY' +import json, sys +src, dst = sys.argv[1], sys.argv[2] +data = json.load(open(src)) +base = "http://127.0.0.1:8765/themes" +for entry in data.get("themes", []): + entry["source"] = entry["source"].replace("https://raw.githubusercontent.com/xfetch-cli/themes/main", base) +json.dump(data, open(dst, "w")) +PY + python3 -m http.server 8765 --directory "$HTTP_ROOT" >"$SB/http.log" 2>&1 & + HTTP_PID=$! + trap 'kill "$HTTP_PID" 2>/dev/null || true' EXIT + export XFETCH_LOGOS_URL="http://127.0.0.1:8765/logos" + export XFETCH_THEME_INDEX="http://127.0.0.1:8765/themes/index.local.json" + sleep 1 + run "$BIN" --json logo list + json_check 'import json,sys; v=json.load(sys.stdin); assert v["logos"]' + run "$BIN" logo preview arch + [ -s "$OUT" ] || fail "logo preview was empty" + expect 4 "$BIN" --offline logo list + run "$BIN" --json theme search nord + json_check 'import json,sys; v=json.load(sys.stdin); assert any(t["name"]=="nord" for t in v["themes"])' + run "$BIN" --json theme install nord --force + json_check 'import json,sys; v=json.load(sys.stdin); assert v["status"]=="installed"' + run "$BIN" --json theme info nord + json_check 'import json,sys; v=json.load(sys.stdin); assert v["theme"]["name"]=="nord"' + run "$BIN" --json theme update --all --check + json_check 'import json,sys; v=json.load(sys.stdin); assert "themes" in v' + run "$BIN" --json theme remove nord + json_check 'import json,sys; v=json.load(sys.stdin); assert v["status"]=="removed"' + expect 4 "$BIN" --offline theme search nord +fi + + +# Command addons. +CMDS_REPO="$(cd "$SCRIPT_DIR/.." && pwd)/../commands" +if [ -d "$CMDS_REPO/commands/github" ]; then + run "$BIN" command install "$CMDS_REPO/commands/github" + contains "Installed wasm command 'github'" command-install + run "$BIN" --json command list + json_check 'import json,sys; v=json.load(sys.stdin); assert any(a["name"]=="github" for a in v["addons"])' + run "$BIN" command info github + contains "github" command-info +fi + +# Fake native command for the runner path. +CMDS_DIR="$XDG_CONFIG_HOME/xfetch/commands" +mkdir -p "$CMDS_DIR" +cat >"$CMDS_DIR/xfetch-command-echo" <<'SH' +#!/bin/sh +printf 'repo: %s\nauthor: test\n' "$1" +SH +chmod +x "$CMDS_DIR/xfetch-command-echo" +run "$BIN" --json command list +json_check 'import json,sys; v=json.load(sys.stdin); assert any(a["name"]=="echo" for a in v["addons"])' +run "$BIN" run echo -- owner/repo +contains "repo: owner/repo" command-run +run "$BIN" --json run echo -- owner/repo +json_check 'import json,sys; v=json.load(sys.stdin); assert v["command"]=="echo" and v["lines"]' +expect 5 "$BIN" run missing -- value +run "$BIN" command remove echo + +# Conflicts. +expect 2 "$BIN" --backup --restore +expect 2 "$BIN" --backup modules + +# Self-uninstall a copied binary. +UNINSTALL_BIN="$SB/uninstall-bin" +mkdir -p "$UNINSTALL_BIN" +cp "$BIN" "$UNINSTALL_BIN/xfetch" +env XDG_CONFIG_HOME="$SB/uninstall-config" HOME="$SB/uninstall-home" XDG_CACHE_HOME="$SB/uninstall-cache" \ + "$UNINSTALL_BIN/xfetch" --json self-uninstall --purge --yes >"$OUT" 2>"$ERR" || fail "self-uninstall failed" +json_check 'import json,sys; v=json.load(sys.stdin); assert v["status"]=="uninstalled"' +[ ! -e "$UNINSTALL_BIN/xfetch" ] || fail "self-uninstall did not remove the binary" + +# Conflicts. +expect 2 "$BIN" --backup --restore +expect 2 "$BIN" --backup modules + +echo +echo "SANDBOX=$SB" +echo "PASS=$pass FAIL=$fail_count" +[ "$fail_count" -eq 0 ] diff --git a/src/addons/catalog.rs b/src/addons/catalog.rs index 513a2a6..dd4ba54 100644 --- a/src/addons/catalog.rs +++ b/src/addons/catalog.rs @@ -18,6 +18,8 @@ const PLUGIN_CATALOG: &str = "https://raw.githubusercontent.com/xfetch-cli/plugi const EFFECT_CATALOG: &str = "https://raw.githubusercontent.com/xfetch-cli/effects/main/index.json"; const EXTENSION_CATALOG: &str = "https://raw.githubusercontent.com/xfetch-cli/extensions/main/index.json"; +const COMMAND_CATALOG: &str = + "https://raw.githubusercontent.com/xfetch-cli/commands/main/index.json"; /// One addon advertised by a repository catalog. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -39,10 +41,22 @@ pub struct CatalogEntry { pub guest_runtime: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub capabilities: Option, + /// Repository URL override; the installer falls back to its family default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo: Option, + /// Optional tag, branch or commit to build from. + #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")] + pub reference: Option, + /// SDK version used by the addon, for diagnostics and receipts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sdk_version: Option, + /// Minimum xfetch core version required by the addon. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_xfetch_version: Option, } impl CatalogEntry { - fn install_name(&self) -> &str { + pub fn install_name(&self) -> &str { self.install.as_deref().unwrap_or(&self.name) } @@ -88,6 +102,19 @@ pub fn search(kind: AddonKind, query: &str) -> Result, String> .collect()) } +/// Returns every entry in the catalog for `kind`. +pub fn all(kind: AddonKind) -> Result, String> { + Ok(load(kind)?.entries) +} + +/// Finds one catalog entry by its package name or install name. +pub fn find(kind: AddonKind, name: &str) -> Result, String> { + Ok(load(kind)? + .entries + .into_iter() + .find(|entry| entry.name == name || entry.install_name() == name)) +} + /// Prints search results as a readable list or JSON. pub fn print_search(kind: AddonKind, query: Option<&str>, json: bool) -> Result<(), String> { let query = query.unwrap_or(""); @@ -134,6 +161,7 @@ fn install_command(kind: AddonKind, name: &str) -> String { AddonKind::Plugin => format!("xfetch plugin install {}", name), AddonKind::Effect => format!("xfetch effects install {}", name), AddonKind::Extension => format!("xfetch extension install {}", name), + AddonKind::Command => format!("xfetch command install {}", name), } } @@ -160,11 +188,15 @@ fn catalog_source(kind: AddonKind) -> String { AddonKind::Plugin => ("XFETCH_PLUGIN_INDEX", PLUGIN_CATALOG), AddonKind::Effect => ("XFETCH_EFFECT_INDEX", EFFECT_CATALOG), AddonKind::Extension => ("XFETCH_EXTENSION_INDEX", EXTENSION_CATALOG), + AddonKind::Command => ("XFETCH_COMMAND_INDEX", COMMAND_CATALOG), }; std::env::var(env_name).unwrap_or_else(|_| default.to_string()) } fn fetch_remote(source: &str) -> Result { + if crate::runtime::offline() { + return Err("network access is disabled by --offline".to_string()); + } let mut response = ureq::get(source) .config() .timeout_global(Some(Duration::from_secs(15))) @@ -214,7 +246,11 @@ mod tests { "version": "0.2.0", "kind": "info_provider", "runtime": "native", - "path": "plugins/weather" + "path": "plugins/weather", + "repo": "https://github.com/xfetch-cli/plugins.git", + "ref": "v0.2.0", + "sdk_version": "0.3.0", + "min_xfetch_version": "1.0.3" } ] } @@ -228,6 +264,16 @@ mod tests { assert!(catalog.entries[0].matches("WASM")); assert!(!catalog.entries[0].matches("weather")); assert_eq!(catalog.entries[1].install_name(), "weather"); + assert_eq!( + catalog.entries[1].repo.as_deref(), + Some("https://github.com/xfetch-cli/plugins.git") + ); + assert_eq!(catalog.entries[1].reference.as_deref(), Some("v0.2.0")); + assert_eq!(catalog.entries[1].sdk_version.as_deref(), Some("0.3.0")); + assert_eq!( + catalog.entries[1].min_xfetch_version.as_deref(), + Some("1.0.3") + ); } #[test] diff --git a/src/addons/mod.rs b/src/addons/mod.rs index f02a3ba..4bcdabd 100644 --- a/src/addons/mod.rs +++ b/src/addons/mod.rs @@ -8,4 +8,6 @@ pub mod catalog; pub mod install; pub mod model; +pub mod receipt; pub mod report; +pub mod update; diff --git a/src/addons/model.rs b/src/addons/model.rs index 0b3c8b3..0b3a77a 100644 --- a/src/addons/model.rs +++ b/src/addons/model.rs @@ -14,12 +14,13 @@ use crate::wasm::detect::is_wasm_file; use crate::wasm::manifest::{self, Manifest, ManifestSource}; use std::path::{Path, PathBuf}; -/// The three installable addon families supported by the core. +/// The four installable addon families supported by the core. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AddonKind { Plugin, Effect, Extension, + Command, } impl AddonKind { @@ -29,6 +30,7 @@ impl AddonKind { Self::Plugin => "plugin", Self::Effect => "effect", Self::Extension => "extension", + Self::Command => "command", } } @@ -38,6 +40,7 @@ impl AddonKind { Self::Plugin => "plugins", Self::Effect => "effects", Self::Extension => "extensions", + Self::Command => "commands", } } @@ -47,6 +50,7 @@ impl AddonKind { Self::Plugin => "xfetch-plugin-", Self::Effect => "xfetch-effect-", Self::Extension => "xfetch-extension-", + Self::Command => "xfetch-command-", } } @@ -56,6 +60,7 @@ impl AddonKind { Self::Plugin => default_plugin_dir(), Self::Effect => default_effect_dir(), Self::Extension => default_extension_dir(), + Self::Command => crate::commands::default_command_dir(), } } @@ -65,6 +70,7 @@ impl AddonKind { Self::Plugin => plugin_binary_name(name), Self::Effect => effect_binary_name(name), Self::Extension => extension_binary_name(name), + Self::Command => crate::commands::command_binary_name(name), } } @@ -74,6 +80,7 @@ impl AddonKind { Self::Plugin => plugin_wasm_name(name), Self::Effect => effect_wasm_name(name), Self::Extension => extension_wasm_name(name), + Self::Command => crate::commands::command_wasm_name(name), } } @@ -83,9 +90,29 @@ impl AddonKind { Self::Plugin => plugin_manifest_name(name), Self::Effect => effect_manifest_name(name), Self::Extension => extension_manifest_name(name), + Self::Command => crate::commands::command_manifest_name(name), } } + /// Install receipt name for `name`. + pub fn receipt_name(self, name: &str) -> String { + format!("{}{}.install.json", self.prefix(), name) + } + + /// Install receipt path for `name`. + pub fn receipt_path(self, name: &str) -> PathBuf { + self.directory().join(self.receipt_name(name)) + } + + /// Backup path used by update/rollback for an installed artifact. + pub fn backup_path(self, path: &Path) -> PathBuf { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("addon"); + path.with_file_name(format!("{}.bak", file_name)) + } + /// Finds the installed native or wasm artifact in the default directory. pub fn installed_path(self, name: &str) -> Option { let directory = self.directory(); @@ -123,6 +150,7 @@ pub struct AddonEntry { pub runtime: AddonRuntime, pub manifest: Option, pub manifest_source: Option, + pub receipt: Option, pub enabled: bool, } @@ -147,6 +175,7 @@ impl AddonEntry { _ => Some(loaded.manifest.clone()), }); let manifest_source = loaded.map(|loaded| loaded.source.as_str().to_string()); + let receipt = crate::addons::receipt::InstallReceipt::load(kind, name); Self { kind, name: name.to_string(), @@ -154,6 +183,7 @@ impl AddonEntry { runtime, manifest, manifest_source, + receipt, enabled: is_enabled(kind, name, config), } } @@ -170,6 +200,11 @@ impl AddonEntry { self.manifest .as_ref() .and_then(|manifest| manifest.version.as_deref()) + .or_else(|| { + self.receipt + .as_ref() + .and_then(|receipt| receipt.version.as_deref()) + }) } pub fn description(&self) -> Option<&str> { @@ -250,6 +285,9 @@ pub fn config_snippet(kind: AddonKind, name: &str, manifest_kind: Option<&str>) format!(" {{ \"extension\": \"{name}\" }}"), "]".to_string(), ], + AddonKind::Command => vec![format!( + "No config needed; run it with: xfetch run {name} -- " + )], } } @@ -288,6 +326,7 @@ fn fallback_kind(kind: AddonKind) -> &'static str { AddonKind::Plugin => "plugin", AddonKind::Effect => "effect", AddonKind::Extension => "config_provider", + AddonKind::Command => "command", } } @@ -344,6 +383,7 @@ fn is_enabled(kind: AddonKind, name: &str, config: &Config) -> bool { .config_providers .iter() .any(|provider| provider.extension == name), + AddonKind::Command => true, } } diff --git a/src/addons/receipt.rs b/src/addons/receipt.rs new file mode 100644 index 0000000..b7ed5f2 --- /dev/null +++ b/src/addons/receipt.rs @@ -0,0 +1,241 @@ +//! Installed-addon metadata used by `plugin/effects/extension update`. +//! +//! Native addons are built from source and have no embedded version, so the +//! installer records how they were installed in a sidecar receipt. The updater +//! uses that receipt to decide where to rebuild from and which version is +//! currently installed. + +use crate::addons::catalog::CatalogEntry; +use crate::addons::model::AddonKind; +use crate::hash::sha256_hex; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Receipt schema accepted by this version. +pub const SCHEMA_VERSION: u32 = 1; + +/// Metadata written after a successful addon install. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct InstallReceipt { + pub schema_version: u32, + pub kind: String, + pub name: String, + pub runtime: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sdk_version: Option, + pub source: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")] + pub reference: Option, + #[serde(default)] + pub installed_at: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha256: Option, +} + +impl InstallReceipt { + /// Location of the receipt for an installed addon. + pub fn path(kind: AddonKind, name: &str) -> PathBuf { + kind.receipt_path(name) + } + + /// Loads the receipt for `name`, if present and valid. + pub fn load(kind: AddonKind, name: &str) -> Option { + Self::load_from(&Self::path(kind, name)) + } + + /// Loads and validates a receipt from an explicit path. + pub fn load_from(path: &Path) -> Option { + let text = fs::read_to_string(path).ok()?; + let receipt: Self = serde_json::from_str(&text).ok()?; + (receipt.schema_version == SCHEMA_VERSION).then_some(receipt) + } + + /// Writes the receipt atomically. + pub fn write(&self, kind: AddonKind, name: &str) -> Result<(), String> { + self.write_to(&Self::path(kind, name)) + } + + /// Writes the receipt atomically to an explicit path. + pub fn write_to(&self, path: &Path) -> Result<(), String> { + let bytes = serde_json::to_vec_pretty(self) + .map_err(|err| format!("Failed to serialize install receipt: {}", err))?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("Failed to create '{}': {}", parent.display(), err))?; + } + crate::atomic_fs::write_atomic(path, &bytes) + .map_err(|err| format!("Failed to write '{}': {}", path.display(), err)) + } +} + +/// Records the source and installed version after a successful install. +/// +/// `source` is `catalog`, `local`, `repo`, `url` or `manual`. `source_path` +/// stores a local directory or URL; `repo` is the repository URL. A catalog +/// entry, when available, supplies the version, SDK and reference. +pub fn record( + kind: AddonKind, + name: &str, + source: &str, + repo: Option<&str>, + source_path: Option<&str>, + catalog: Option<&CatalogEntry>, +) -> Result<(), String> { + let path = kind.installed_path(name).ok_or_else(|| { + format!( + "Cannot write install receipt: {} '{}' is not installed", + kind.label(), + name + ) + })?; + let runtime = if crate::wasm::is_wasm_file(&path) { + "wasm" + } else { + "native" + }; + + let previous = InstallReceipt::load(kind, name); + let wasm_version = if runtime == "wasm" { + crate::wasm::manifest::load(&path) + .ok() + .and_then(|loaded| loaded.manifest.version) + } else { + None + }; + let version = catalog + .and_then(|entry| entry.version.clone()) + .or(wasm_version) + .or_else(|| { + previous + .as_ref() + .and_then(|receipt| receipt.version.clone()) + }); + let sdk_version = catalog + .and_then(|entry| entry.sdk_version.clone()) + .or_else(|| { + previous + .as_ref() + .and_then(|receipt| receipt.sdk_version.clone()) + }); + let reference = catalog + .and_then(|entry| entry.reference.clone()) + .or_else(|| { + previous + .as_ref() + .and_then(|receipt| receipt.reference.clone()) + }); + let repo = repo + .map(str::to_string) + .or_else(|| catalog.and_then(|entry| entry.repo.clone())) + .or_else(|| previous.as_ref().and_then(|receipt| receipt.repo.clone())); + let stored_path = if matches!(source, "local" | "url" | "repo") { + source_path + .map(str::to_string) + .or_else(|| previous.as_ref().and_then(|receipt| receipt.path.clone())) + .or_else(|| catalog.and_then(|entry| entry.path.clone())) + } else { + catalog + .and_then(|entry| entry.path.clone()) + .or_else(|| source_path.map(str::to_string)) + .or_else(|| previous.as_ref().and_then(|receipt| receipt.path.clone())) + }; + let sha256 = fs::read(&path).ok().map(|bytes| sha256_hex(&bytes)); + + let receipt = InstallReceipt { + schema_version: SCHEMA_VERSION, + kind: kind.label().to_string(), + name: name.to_string(), + runtime: runtime.to_string(), + version, + sdk_version, + source: source.to_string(), + repo, + path: stored_path, + reference, + installed_at: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0), + sha256, + }; + receipt.write(kind, name) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(tag: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("xfetch-receipt-{}-{}", tag, std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("create scratch"); + dir + } + + #[test] + fn parses_optional_fields() { + let text = r#"{ + "schema_version": 1, + "kind": "plugin", + "name": "weather", + "runtime": "native", + "source": "catalog", + "repo": "https://github.com/xfetch-cli/plugins.git", + "path": "plugins/weather", + "ref": "v0.2.0", + "version": "0.2.0", + "sdk_version": "0.3.0", + "installed_at": 100, + "sha256": "abc" + }"#; + let receipt: InstallReceipt = serde_json::from_str(text).expect("parse"); + assert_eq!(receipt.name, "weather"); + assert_eq!(receipt.reference.as_deref(), Some("v0.2.0")); + assert_eq!(receipt.sdk_version.as_deref(), Some("0.3.0")); + } + + #[test] + fn writes_and_reads_a_receipt() { + let dir = scratch("roundtrip"); + let path = dir.join("install.json"); + let receipt = InstallReceipt { + schema_version: SCHEMA_VERSION, + kind: "effect".to_string(), + name: "decrypt".to_string(), + runtime: "native".to_string(), + version: Some("0.2.0".to_string()), + sdk_version: None, + source: "local".to_string(), + repo: None, + path: Some("/tmp/effects/decrypt".to_string()), + reference: None, + installed_at: 1, + sha256: None, + }; + receipt.write_to(&path).expect("write"); + assert_eq!(InstallReceipt::load_from(&path), Some(receipt)); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn rejects_other_schema_versions() { + let dir = scratch("schema"); + let path = dir.join("install.json"); + fs::write( + &path, + r#"{"schema_version":2,"kind":"plugin","name":"x","runtime":"native","source":"local"}"#, + ) + .expect("write"); + assert!(InstallReceipt::load_from(&path).is_none()); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/src/addons/report.rs b/src/addons/report.rs index be7a373..41e0055 100644 --- a/src/addons/report.rs +++ b/src/addons/report.rs @@ -2,6 +2,7 @@ use crate::addons::model::{AddonEntry, AddonKind}; use crate::config::Config; +use crate::runtime; /// Prints the installed addon list. The default layout is unchanged for /// scripts; `verbose` adds kind, runtime, version, capabilities and state. @@ -65,6 +66,15 @@ pub fn print_info(entry: &AddonEntry) { if let Some(source) = &entry.manifest_source { println!("Manifest: {}", source); } + if let Some(receipt) = &entry.receipt { + println!("Install source: {} ({})", receipt.source, receipt.runtime); + if let Some(repo) = &receipt.repo { + println!("Repository: {}", repo); + } + if let Some(reference) = &receipt.reference { + println!("Reference: {}", reference); + } + } println!("Enabled: {}", if entry.enabled { "yes" } else { "no" }); println!("Capabilities:"); @@ -84,6 +94,9 @@ pub fn print_info(entry: &AddonEntry) { /// Prints the config snippet and declared capabilities after a successful /// install. The installer has already printed the artifact path. pub fn print_install_guidance(kind: AddonKind, name: &str) { + if runtime::quiet() { + return; + } let path = kind .installed_path(name) .unwrap_or_else(|| kind.directory().join(kind.wasm_name(name))); @@ -112,5 +125,6 @@ fn title(kind: AddonKind) -> &'static str { AddonKind::Plugin => "Plugin", AddonKind::Effect => "Effect", AddonKind::Extension => "Extension", + AddonKind::Command => "Command", } } diff --git a/src/addons/update.rs b/src/addons/update.rs new file mode 100644 index 0000000..689a07f --- /dev/null +++ b/src/addons/update.rs @@ -0,0 +1,511 @@ +//! Shared install-all, update and rollback flow for the three addon families. +//! +//! Native addons are still built from source. Update reads the install receipt +//! written by the installer, compares the recorded version with the catalog and +//! rebuilds from the original source. The previous artifact is copied to +//! `.bak` before replacement; a failed build leaves it untouched. + +use crate::addons::catalog::{self, CatalogEntry}; +use crate::addons::model::AddonKind; +use crate::addons::receipt::{self, InstallReceipt}; +use crate::runtime; +use semver::Version; +use serde::Serialize; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Options for `plugin/effects/extension update`. +#[derive(Debug, Default)] +pub struct UpdateOptions { + /// Update every installed addon of this family. + pub all: bool, + /// Report only; do not install. + pub check: bool, + /// Reinstall even when the catalog version is not newer. + pub force: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct UpdateCheck { + pub name: String, + pub installed: Option, + pub available: Option, + pub update_available: bool, + pub compatible: bool, + pub min_xfetch_version: Option, +} + +/// Machine-readable update check used by `--json`. +pub fn check(kind: AddonKind, name: Option<&str>, all: bool) -> Result, String> { + if all && name.is_some() { + return Err("Use either an addon name or --all, not both.".to_string()); + } + if !all && name.is_none() { + return Err(format!("Specify a {} name or use --all.", kind.label())); + } + + let targets = if all { + installed_names(kind)? + } else { + vec![name.expect("validated above").to_string()] + }; + let entries = catalog::all(kind)?; + let mut checks = Vec::new(); + + for target in targets { + let entry = entries + .iter() + .find(|entry| entry.name == target || entry.install_name() == target) + .cloned(); + let receipt = InstallReceipt::load(kind, &target); + let installed = receipt + .as_ref() + .and_then(|receipt| receipt.version.clone()) + .or_else(|| installed_manifest_version(kind, &target)); + let available = entry.as_ref().and_then(|entry| entry.version.clone()); + let compatible = entry.as_ref().map(meets_min_core).unwrap_or(true); + let update_available = compatible && is_newer(installed.as_deref(), available.as_deref()); + checks.push(UpdateCheck { + name: target, + installed, + available, + update_available, + compatible, + min_xfetch_version: entry.and_then(|entry| entry.min_xfetch_version), + }); + } + + Ok(checks) +} + +/// Installs every addon advertised by the catalog, skipping installed ones +/// unless `force` is set. Returns the names that were installed. +pub fn install_all(kind: AddonKind, force: bool, json: bool) -> Result, String> { + let entries = catalog::all(kind)?; + if entries.is_empty() { + if !json { + runtime::info(format!( + "No {} available in the catalog.", + kind.plural_label() + )); + } + return Ok(Vec::new()); + } + + let mut installed = 0usize; + let mut skipped = 0usize; + let mut installed_names = Vec::new(); + for entry in entries { + let name = entry.install_name().to_string(); + if !force && kind.installed_path(&name).is_some() { + if !json { + runtime::info(format!( + "Skipping {} '{}' (already installed).", + kind.label(), + name + )); + } + skipped += 1; + continue; + } + + install_one(kind, &name, entry.repo.as_deref(), None)?; + receipt::record( + kind, + &name, + "catalog", + entry.repo.as_deref(), + entry.path.as_deref(), + Some(&entry), + )?; + if !json { + runtime::info(format!("Installed {} '{}'.", kind.label(), name)); + } + installed += 1; + installed_names.push(name); + } + + if !json { + runtime::info(format!( + "Installed {} {}; skipped {} already installed.", + installed, + kind.plural_label(), + skipped + )); + } + Ok(installed_names) +} + +/// Updates one addon, or every installed addon when `--all` is set. +/// +/// Returns `true` when at least one update was available (used by `--check`). +pub fn run(kind: AddonKind, name: Option<&str>, options: &UpdateOptions) -> Result { + if options.all && name.is_some() { + return Err("Use either an addon name or --all, not both.".to_string()); + } + if !options.all && name.is_none() { + return Err(format!("Specify a {} name or use --all.", kind.label())); + } + + let targets = if options.all { + installed_names(kind)? + } else { + vec![name.expect("validated above").to_string()] + }; + if targets.is_empty() { + runtime::info(format!("No {} installed.", kind.plural_label())); + return Ok(false); + } + + let entries = catalog::all(kind)?; + let mut any = false; + + for target in targets { + let entry = entries + .iter() + .find(|entry| entry.name == target || entry.install_name() == target) + .cloned(); + let receipt = InstallReceipt::load(kind, &target); + let installed_version = receipt + .as_ref() + .and_then(|receipt| receipt.version.clone()) + .or_else(|| installed_manifest_version(kind, &target)); + + let available_version = entry.as_ref().and_then(|entry| entry.version.as_deref()); + if !options.force && !is_newer(installed_version.as_deref(), available_version) { + runtime::info(format!( + "{} '{}' is up to date ({}).", + title(kind), + target, + installed_version.as_deref().unwrap_or("unknown") + )); + continue; + } + + if let Some(entry) = &entry + && !meets_min_core(entry) + { + runtime::info(format!( + "{} '{}' requires xfetch {}, skipping.", + title(kind), + target, + entry.min_xfetch_version.as_deref().unwrap_or("?") + )); + continue; + } + + any = true; + let from = installed_version.as_deref().unwrap_or("unknown"); + let to = available_version.unwrap_or("unknown"); + if options.check { + runtime::info(format!("{} '{}': {} -> {}", title(kind), target, from, to)); + continue; + } + + if receipt.is_none() && !options.force { + eprintln!( + "{} '{}' has no install receipt; re-run with --force to rebuild it.", + title(kind), + target + ); + continue; + } + + let source = match resolve_source(kind, &target, receipt.as_ref(), entry.as_ref()) { + Ok(source) => source, + Err(err) => { + eprintln!("{} '{}' skipped: {}", title(kind), target, err); + continue; + } + }; + + backup_installed(kind, &target)?; + if let Err(err) = source.install(kind, &target) { + eprintln!( + "{} '{}' update failed: {}. Keeping the previous installation.", + title(kind), + target, + err + ); + continue; + } + + receipt::record( + kind, + &target, + source.label(), + source.repo(), + source.path(), + entry.as_ref(), + )?; + runtime::info(format!( + "Updated {} '{}' ({} -> {}).", + kind.label(), + target, + from, + to + )); + } + + Ok(any) +} + +/// Restores the previous artifact saved by the last update. +pub fn rollback(kind: AddonKind, name: &str) -> Result<(), String> { + let path = kind + .installed_path(name) + .ok_or_else(|| format!("{} '{}' is not installed.", title(kind), name))?; + let backup = backup_path(&path); + if !backup.is_file() { + return Err(format!( + "No previous version for {} '{}' (expected '{}').", + kind.label(), + name, + backup.display() + )); + } + crate::atomic_fs::copy_atomic(&backup, &path) + .map_err(|err| format!("Failed to restore '{}': {}", path.display(), err))?; + + if crate::wasm::is_wasm_file(&path) { + restore_if_present(kind, &kind.directory().join(kind.manifest_name(name)))?; + } + restore_if_present(kind, &kind.receipt_path(name))?; + + Ok(()) +} + +fn restore_if_present(kind: AddonKind, path: &Path) -> Result<(), String> { + let backup = kind.backup_path(path); + if backup.is_file() { + crate::atomic_fs::copy_atomic(&backup, path) + .map_err(|err| format!("Failed to restore '{}': {}", path.display(), err))?; + } + Ok(()) +} + +fn installed_names(kind: AddonKind) -> Result, String> { + let entries = match kind { + AddonKind::Plugin => crate::plugins::list_plugins() + .map(|entries| entries.into_iter().map(|(name, _)| name).collect())?, + AddonKind::Effect => crate::effects::list_effects() + .map(|entries| entries.into_iter().map(|(name, _)| name).collect())?, + AddonKind::Extension => crate::extensions::list_extensions() + .map(|entries| entries.into_iter().map(|(name, _)| name).collect())?, + AddonKind::Command => crate::commands::list_commands() + .map(|entries| entries.into_iter().map(|(name, _)| name).collect())?, + }; + Ok(entries) +} + +fn installed_manifest_version(kind: AddonKind, name: &str) -> Option { + let path = kind.installed_path(name)?; + if !crate::wasm::is_wasm_file(&path) { + return None; + } + crate::wasm::manifest::load(&path) + .ok() + .and_then(|loaded| loaded.manifest.version) +} + +fn is_newer(installed: Option<&str>, available: Option<&str>) -> bool { + let Some(available) = available else { + return false; + }; + let Some(installed) = installed else { + return true; + }; + match ( + Version::parse(installed.trim_start_matches('v')), + Version::parse(available.trim_start_matches('v')), + ) { + (Ok(installed), Ok(available)) => available > installed, + _ => installed != available, + } +} + +fn meets_min_core(entry: &CatalogEntry) -> bool { + let Some(minimum) = entry.min_xfetch_version.as_deref() else { + return true; + }; + let (Ok(current), Ok(minimum)) = ( + Version::parse(env!("CARGO_PKG_VERSION")), + Version::parse(minimum.trim_start_matches('v')), + ) else { + return true; + }; + current >= minimum +} + +fn install_one( + kind: AddonKind, + source: &str, + repo: Option<&str>, + sha256: Option<&str>, +) -> Result<(), String> { + match kind { + AddonKind::Plugin => crate::plugins::install_plugin(source, repo, sha256), + AddonKind::Effect => crate::effects::install_effect(source, repo, sha256), + AddonKind::Extension => crate::extensions::install_extension(source, repo, sha256), + AddonKind::Command => crate::commands::install_command(source, repo, sha256), + } +} + +/// Where the next version should be built from. +enum UpdateSource { + Catalog(Box), + Local(String), + Url(String), + Repo { name: String, repo: Option }, +} + +impl UpdateSource { + fn install(&self, kind: AddonKind, name: &str) -> Result<(), String> { + match self { + Self::Catalog(entry) => { + install_one(kind, entry.install_name(), entry.repo.as_deref(), None) + } + Self::Local(path) => install_one(kind, path, None, None), + Self::Url(url) => install_one(kind, url, None, None), + Self::Repo { + name: source_name, + repo, + } => install_one(kind, source_name, repo.as_deref(), None).or_else(|_| { + // Some receipts store the installed name separately from the + // package name; retry with the requested name. + install_one(kind, name, repo.as_deref(), None) + }), + } + } + + fn label(&self) -> &'static str { + match self { + Self::Catalog(_) => "catalog", + Self::Local(_) => "local", + Self::Url(_) => "url", + Self::Repo { .. } => "repo", + } + } + + fn repo(&self) -> Option<&str> { + match self { + Self::Catalog(entry) => entry.repo.as_deref(), + Self::Repo { repo, .. } => repo.as_deref(), + Self::Local(_) | Self::Url(_) => None, + } + } + + fn path(&self) -> Option<&str> { + match self { + Self::Catalog(entry) => entry.path.as_deref(), + Self::Local(path) | Self::Url(path) => Some(path), + Self::Repo { name, .. } => Some(name), + } + } +} + +fn resolve_source( + _kind: AddonKind, + name: &str, + receipt: Option<&InstallReceipt>, + entry: Option<&CatalogEntry>, +) -> Result { + if let Some(receipt) = receipt { + match receipt.source.as_str() { + "local" => { + let path = receipt + .path + .clone() + .ok_or_else(|| "receipt has no local path".to_string())?; + return Ok(UpdateSource::Local(path)); + } + "url" => { + let url = receipt + .path + .clone() + .ok_or_else(|| "receipt has no URL".to_string())?; + return Ok(UpdateSource::Url(url)); + } + "repo" => { + return Ok(UpdateSource::Repo { + name: receipt.path.clone().unwrap_or_else(|| name.to_string()), + repo: receipt.repo.clone(), + }); + } + _ => {} + } + } + + if let Some(entry) = entry { + return Ok(UpdateSource::Catalog(Box::new(entry.clone()))); + } + + Ok(UpdateSource::Repo { + name: name.to_string(), + repo: receipt.and_then(|receipt| receipt.repo.clone()), + }) +} + +fn backup_installed(kind: AddonKind, name: &str) -> Result<(), String> { + let Some(path) = kind.installed_path(name) else { + return Ok(()); + }; + let backup = backup_path(&path); + fs::copy(&path, &backup) + .map_err(|err| format!("Failed to back up '{}': {}", path.display(), err))?; + + if crate::wasm::is_wasm_file(&path) { + backup_if_present(kind, &kind.directory().join(kind.manifest_name(name)))?; + } + backup_if_present(kind, &kind.receipt_path(name))?; + Ok(()) +} + +fn backup_if_present(kind: AddonKind, path: &Path) -> Result<(), String> { + if path.is_file() { + fs::copy(path, kind.backup_path(path)) + .map_err(|err| format!("Failed to back up '{}': {}", path.display(), err))?; + } + Ok(()) +} + +fn backup_path(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("addon"); + path.with_file_name(format!("{}.bak", file_name)) +} + +fn title(kind: AddonKind) -> &'static str { + match kind { + AddonKind::Plugin => "Plugin", + AddonKind::Effect => "Effect", + AddonKind::Extension => "Extension", + AddonKind::Command => "Command", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn semver_comparison_handles_versions_and_fallbacks() { + assert!(is_newer(Some("0.2.0"), Some("0.3.0"))); + assert!(!is_newer(Some("0.3.0"), Some("0.2.0"))); + assert!(!is_newer(Some("0.3.0"), Some("0.3.0"))); + assert!(is_newer(None, Some("0.3.0"))); + assert!(!is_newer(Some("0.3.0"), None)); + assert!(is_newer(Some("dev"), Some("release"))); + } + + #[test] + fn backup_path_is_next_to_the_artifact() { + let path = Path::new("/tmp/xfetch-plugin-weather"); + assert_eq!( + backup_path(path), + PathBuf::from("/tmp/xfetch-plugin-weather.bak") + ); + } +} diff --git a/src/cli.rs b/src/cli.rs deleted file mode 100644 index 8a93443..0000000 --- a/src/cli.rs +++ /dev/null @@ -1,258 +0,0 @@ -use clap::{Parser, Subcommand}; - -#[derive(Parser, Debug)] -#[command( - author, - version, - about, - long_about = None, - after_help = "Examples:\n xfetch\n xfetch --config ~/.config/xfetch/config.jsonc\n xfetch --gen-config\n xfetch --gen-config --layout tree\n xfetch --list-layouts\n xfetch --clean-cache\n xfetch --daemon\n xfetch --daemon-stop\n xfetch --no-daemon-live\n xfetch --daemon-live-stop\n xfetch --daemon-live-reload\n xfetch update --check\n xfetch update\n xfetch plugin install animate-logo\n xfetch plugin list --verbose\n xfetch plugin info animate-logo\n xfetch plugin search logo\n xfetch plugin remove animate-logo\n xfetch effects install decrypt\n xfetch effects list --verbose\n xfetch effects info decrypt\n xfetch effects available\n xfetch effects remove decrypt\n xfetch extension install config-roulette\n xfetch extension list --verbose\n xfetch extension info config-roulette\n xfetch extension search night\n xfetch extension remove config-roulette\n xfetch wasm inspect ./plugin.wasm\n xfetch wasm run ./plugin.wasm --request '{\"version\":1,\"kind\":\"info_provider\"}'\n xfetch wasm wit\n xfetch theme list\n xfetch theme set dracula\n xfetch theme remove dracula\n xfetch theme export my-theme" -)] -pub struct Cli { - #[command(subcommand)] - pub command: Option, - - #[arg(short, long, global = true)] - pub config: Option, - - #[arg(long, global = true)] - pub gen_config: bool, - - /// Logo to embed in the generated config, overriding the detected - /// OS/distro (e.g. `--logo arch`, `--logo windows-11`). Only used with - /// `--gen-config`; requires network access to the logos catalog. - #[arg(long, global = true)] - pub logo: Option, - - /// Layout for the generated config (e.g. `section`, `tree`, `compact`). - /// Only used with `--gen-config`; defaults to `section`. Run - /// `xfetch --list-layouts` to see every accepted name. - #[arg(long, global = true)] - pub layout: Option, - - /// Print the layout names accepted by `--layout` and exit. - #[arg(long, global = true)] - pub list_layouts: bool, - - #[arg(long, global = true)] - pub clean_cache: bool, - - #[arg(long, global = true)] - pub benchmark: bool, - - #[arg(long, global = true)] - pub daemon: bool, - - #[arg(long, global = true)] - pub daemon_stop: bool, - - /// Disable the live stats daemon (`daemon_live` in config) from the - /// terminal. - #[arg(long, global = true)] - pub no_daemon_live: bool, - - /// Stop the running live stats daemon. - #[arg(long, global = true)] - pub daemon_live_stop: bool, - - /// Force hot reload in the live stats daemon (equivalent to - /// `"daemon_live_reload": true` in the config). - #[arg(long, global = true)] - pub daemon_live_reload: bool, -} - -#[derive(Subcommand, Debug)] -pub enum Commands { - #[command(alias = "plugins")] - Plugin { - #[command(subcommand)] - action: PluginCommands, - }, - #[command(alias = "extensions")] - Extension { - #[command(subcommand)] - action: ExtensionCommands, - }, - Theme { - #[command(subcommand)] - action: ThemeCommands, - }, - #[command(alias = "effect")] - Effects { - #[command(subcommand)] - action: EffectCommands, - }, - /// WebAssembly guest tooling: inspect artifacts, run them with a raw JSON - /// request and print the component protocol. - Wasm { - #[command(subcommand)] - action: WasmCommands, - }, - /// Check for a newer xfetch release and install it. - Update { - /// Only check; exit with code 1 when a newer release exists. - #[arg(long)] - check: bool, - /// Install the prebuilt release in place, even for local builds - /// (Unix only). - #[arg(long)] - prebuilt: bool, - /// Directory holding the xfetch binary to replace (with --prebuilt). - #[arg(long, value_name = "DIR")] - bin_dir: Option, - /// Assume yes for confirmation prompts. - #[arg(long, short = 'y')] - yes: bool, - }, -} - -#[derive(Subcommand, Debug)] -pub enum PluginCommands { - Install { - path: String, - #[arg(long, short)] - repo: Option, - /// Expected SHA-256 (hex) of a downloaded wasm artifact. - #[arg(long, value_name = "HEX")] - sha256: Option, - /// Override the installed name for a direct wasm file or URL. - #[arg(long)] - name: Option, - /// Sidecar manifest to attach to a direct wasm file or URL. - #[arg(long, value_name = "FILE")] - manifest: Option, - }, - List { - /// Show kind, runtime, version, capabilities and enabled state. - #[arg(long, short)] - verbose: bool, - }, - Info { - name: String, - }, - /// Search the remote plugin catalog. Omit the query to list everything. - #[command(alias = "available")] - Search { - query: Option, - /// Emit the matching entries as JSON. - #[arg(long)] - json: bool, - }, - Remove { - name: String, - }, -} - -#[derive(Subcommand, Debug)] -pub enum ExtensionCommands { - Install { - path: String, - #[arg(long, short)] - repo: Option, - /// Expected SHA-256 (hex) of a downloaded wasm artifact. - #[arg(long, value_name = "HEX")] - sha256: Option, - /// Override the installed name for a direct wasm file or URL. - #[arg(long)] - name: Option, - /// Sidecar manifest to attach to a direct wasm file or URL. - #[arg(long, value_name = "FILE")] - manifest: Option, - }, - List { - /// Show kind, runtime, version, capabilities and enabled state. - #[arg(long, short)] - verbose: bool, - }, - Info { - name: String, - }, - /// Search the remote extension catalog. Omit the query to list everything. - #[command(alias = "available")] - Search { - query: Option, - /// Emit the matching entries as JSON. - #[arg(long)] - json: bool, - }, - Remove { - name: String, - }, -} - -#[derive(Subcommand, Debug)] -pub enum ThemeCommands { - List, - Set { name: String }, - Remove { name: String }, - Export { name: String }, -} - -#[derive(Subcommand, Debug)] -pub enum EffectCommands { - Install { - path: String, - #[arg(long, short)] - repo: Option, - /// Expected SHA-256 (hex) of a downloaded wasm artifact. - #[arg(long, value_name = "HEX")] - sha256: Option, - /// Override the installed name for a direct wasm file or URL. - #[arg(long)] - name: Option, - /// Sidecar manifest to attach to a direct wasm file or URL. - #[arg(long, value_name = "FILE")] - manifest: Option, - }, - List { - /// Show kind, runtime, version, capabilities and enabled state. - #[arg(long, short)] - verbose: bool, - }, - Info { - name: String, - }, - /// Search the remote effect catalog. Omit the query to list everything. - #[command(alias = "available")] - Search { - query: Option, - /// Emit the matching entries as JSON. - #[arg(long)] - json: bool, - }, - Remove { - name: String, - }, -} - -#[derive(Subcommand, Debug)] -pub enum WasmCommands { - /// Print the resolved kind, manifest, capabilities and limits of an - /// artifact without executing it. - Inspect { - path: String, - /// Emit the report as JSON instead of a human-readable summary. - #[arg(long)] - json: bool, - }, - /// Execute a wasm guest with a raw JSON request and print its response. - /// Useful for authors and for scripted end-to-end checks. - Run { - path: String, - /// JSON request passed to the guest (defaults to `{}`). - #[arg(long)] - request: Option, - /// Read the JSON request from a file instead of `--request`. - #[arg(long, value_name = "PATH")] - request_file: Option, - /// Safety timeout in seconds; overrides the manifest limit. - #[arg(long)] - timeout: Option, - /// Guest contract used to select the component world. - #[arg(long, value_parser = ["plugin", "effect", "extension"], default_value = "plugin")] - kind: String, - }, - /// Print the WIT package for component guests (componentize-py, - /// componentize-js, wit-bindgen, ...). - Wit, -} diff --git a/src/cli/addon_cmd.rs b/src/cli/addon_cmd.rs new file mode 100644 index 0000000..25ab0f9 --- /dev/null +++ b/src/cli/addon_cmd.rs @@ -0,0 +1,336 @@ +use crate::addons::install::infer_name; +use crate::addons::model::{AddonEntry, AddonKind}; +use crate::addons::{self, catalog}; +use crate::cli::args::{AddonCommands, Cli}; +use crate::cli::output; +use crate::cli::output::{print_addon_info_json, print_addon_list_json, print_json}; +use crate::config::{ConfigOverrides, load_config_with_overrides}; +use crate::effects::{install_effect, list_effects, remove_effect}; +use crate::error::XFetchError; +use crate::extensions::{install_extension, list_extensions, remove_extension}; +use crate::plugins::{install_plugin, list_plugins, remove_plugin}; +use crate::runtime; + +pub fn run( + kind: AddonKind, + action: AddonCommands, + cli: &Cli, + overrides: &ConfigOverrides, + config_path: Option, +) -> Result<(), XFetchError> { + let config = || load_config_with_overrides(config_path.clone(), overrides); + + match action { + AddonCommands::Install { + path, + repo, + sha256, + name, + manifest, + all, + force, + } => install_addon( + kind, + AddonInstallRequest { + path, + repo, + sha256, + name, + manifest, + all, + force, + }, + cli.json, + ), + AddonCommands::Update { + name, + all, + check, + force, + } => update_addon(kind, name, all, check, force, cli.json), + AddonCommands::Rollback { name } => { + addons::update::rollback(kind, &name).map_err(XFetchError::Addon)?; + if cli.json { + return output::print_json(&serde_json::json!({ + "schema_version": output::JSON_SCHEMA_VERSION, + "status": "rolled-back", + "kind": kind.label(), + "name": name, + })); + } + runtime::info(format!("Rolled back {} '{}'.", kind.label(), name)); + Ok(()) + } + AddonCommands::List { verbose } => { + let installed = list_installed(kind)?; + let config = config(); + let entries = installed + .into_iter() + .map(|(name, path)| AddonEntry::load(kind, &name, &path, &config)) + .collect::>(); + if cli.json { + print_addon_list_json(kind, &entries) + } else { + addons::report::print_list(kind, &entries, verbose); + Ok(()) + } + } + AddonCommands::Info { name } => { + let path = find_installed(kind, &name).ok_or_else(|| { + XFetchError::NotFound(format!("{} '{}' is not installed.", kind.label(), name)) + })?; + let config = config(); + let entry = AddonEntry::load(kind, &name, &path, &config); + if cli.json { + print_addon_info_json(&entry) + } else { + addons::report::print_info(&entry); + Ok(()) + } + } + AddonCommands::Search { query } => { + catalog::print_search(kind, query.as_deref(), cli.json).map_err(XFetchError::Network) + } + AddonCommands::Remove { name } => { + remove_addon(kind, &name)?; + if cli.json { + return output::print_json(&serde_json::json!({ + "schema_version": output::JSON_SCHEMA_VERSION, + "status": "removed", + "kind": kind.label(), + "name": name, + })); + } + runtime::info(format!("Removed {} '{}'", kind.label(), name)); + Ok(()) + } + } +} + +fn list_installed(kind: AddonKind) -> Result, XFetchError> { + match kind { + AddonKind::Plugin => list_plugins(), + AddonKind::Effect => list_effects(), + AddonKind::Extension => list_extensions(), + AddonKind::Command => crate::commands::list_commands(), + } + .map_err(XFetchError::Addon) +} + +fn find_installed(kind: AddonKind, name: &str) -> Option { + kind.installed_path(name).or_else(|| match kind { + AddonKind::Plugin => crate::plugins::find_plugin_binary(name), + AddonKind::Effect => crate::effects::find_effect_binary(name), + AddonKind::Extension => crate::extensions::find_extension_binary(name), + AddonKind::Command => crate::commands::find_command_binary(name), + }) +} + +fn remove_addon(kind: AddonKind, name: &str) -> Result<(), XFetchError> { + match kind { + AddonKind::Plugin => remove_plugin(name), + AddonKind::Effect => remove_effect(name), + AddonKind::Extension => remove_extension(name), + AddonKind::Command => crate::commands::remove_command(name), + } + .map_err(XFetchError::Addon) +} + +struct AddonInstallRequest { + path: Option, + repo: Option, + sha256: Option, + name: Option, + manifest: Option, + all: bool, + force: bool, +} + +fn install_addon( + kind: AddonKind, + request: AddonInstallRequest, + json: bool, +) -> Result<(), XFetchError> { + let AddonInstallRequest { + path, + repo, + sha256, + name, + manifest, + all, + force, + } = request; + if runtime::offline() + && (all + || repo.is_some() + || path + .as_deref() + .is_some_and(|value| is_url(value) || !std::path::Path::new(value).exists())) + { + return Err(XFetchError::Network( + "network access is disabled by --offline".to_string(), + )); + } + if all { + if path.is_some() || name.is_some() || manifest.is_some() || sha256.is_some() { + return Err(XFetchError::Usage( + "--all cannot be combined with a path, --name, --manifest or --sha256".to_string(), + )); + } + let names = addons::update::install_all(kind, force, json).map_err(XFetchError::Addon)?; + if json { + let installed: Vec = names + .iter() + .map(|name| { + serde_json::json!({ + "name": name, + "path": kind + .installed_path(name) + .map(|path| path.display().to_string()) + .unwrap_or_default(), + }) + }) + .collect(); + return output::print_json(&serde_json::json!({ + "schema_version": output::JSON_SCHEMA_VERSION, + "status": "installed", + "kind": kind.label(), + "installed": installed, + })); + } + return Ok(()); + } + + let path = path.ok_or_else(|| { + XFetchError::Usage(format!( + "Specify a {} path/name or use --all.", + kind.label() + )) + })?; + let direct = name.is_some() || manifest.is_some(); + let installed = if direct { + if repo.is_some() { + return Err(XFetchError::Usage( + "--name/--manifest apply to direct wasm files or URLs; omit --repo".to_string(), + )); + } + addons::install::install_direct( + kind, + &path, + sha256.as_deref(), + name.as_deref(), + manifest.as_deref(), + ) + .map_err(XFetchError::Addon)? + } else { + match kind { + AddonKind::Plugin => install_plugin(&path, repo.as_deref(), sha256.as_deref()), + AddonKind::Effect => install_effect(&path, repo.as_deref(), sha256.as_deref()), + AddonKind::Extension => install_extension(&path, repo.as_deref(), sha256.as_deref()), + AddonKind::Command => { + crate::commands::install_command(&path, repo.as_deref(), sha256.as_deref()) + } + } + .map_err(XFetchError::Addon)?; + infer_name(kind, &path, None).map_err(XFetchError::Addon)? + }; + + let source = if direct { + if is_url(&path) { "url" } else { "local" } + } else if is_url(&path) { + "url" + } else if std::path::Path::new(&path).exists() { + "local" + } else if addons::catalog::find(kind, &installed) + .ok() + .flatten() + .is_some() + { + "catalog" + } else { + "repo" + }; + let catalog_entry = if source == "catalog" { + addons::catalog::find(kind, &installed).ok().flatten() + } else { + None + }; + addons::receipt::record( + kind, + &installed, + source, + repo.as_deref(), + Some(&path), + catalog_entry.as_ref(), + ) + .map_err(XFetchError::Addon)?; + if json { + return output::print_json(&serde_json::json!({ + "schema_version": output::JSON_SCHEMA_VERSION, + "status": "installed", + "kind": kind.label(), + "name": installed, + "path": kind + .installed_path(&installed) + .map(|path| path.display().to_string()) + .unwrap_or_default(), + "source": source, + })); + } + addons::report::print_install_guidance(kind, &installed); + Ok(()) +} + +fn update_addon( + kind: AddonKind, + name: Option, + all: bool, + check: bool, + force: bool, + json: bool, +) -> Result<(), XFetchError> { + if json && !check { + return Err(XFetchError::Usage( + "--json for addon updates requires --check.".to_string(), + )); + } + if runtime::offline() { + return Err(XFetchError::Network( + "network access is disabled by --offline".to_string(), + )); + } + + if json { + let checks = + addons::update::check(kind, name.as_deref(), all).map_err(XFetchError::Network)?; + return print_json(&serde_json::json!({ + "schema_version": crate::cli::output::JSON_SCHEMA_VERSION, + "kind": kind.label(), + "checks": checks, + })); + } + + let options = addons::update::UpdateOptions { all, check, force }; + let available = + addons::update::run(kind, name.as_deref(), &options).map_err(XFetchError::Addon)?; + if check && available { + return Err(XFetchError::Exit(crate::error::EXIT_GENERIC)); + } + Ok(()) +} + +fn is_url(value: &str) -> bool { + value.starts_with("http://") || value.starts_with("https://") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_urls() { + assert!(is_url("https://example.com/a.wasm")); + assert!(is_url("http://example.com/a.wasm")); + assert!(!is_url("/tmp/a.wasm")); + } +} diff --git a/src/cli/args.rs b/src/cli/args.rs new file mode 100644 index 0000000..9ba0121 --- /dev/null +++ b/src/cli/args.rs @@ -0,0 +1,528 @@ +use clap::{Parser, Subcommand, ValueEnum}; + +#[derive(Parser, Debug)] +#[command( + name = "xfetch", + author, + version, + about, + long_about = None, + after_help = "Examples:\n xfetch\n xfetch --json\n xfetch --get os,cpu,memory\n xfetch --modules os,kernel,cpu --layout compact\n xfetch --config ~/.config/xfetch/config.jsonc\n xfetch --gen-config\n xfetch --gen-config --layout tree\n xfetch --list-layouts\n xfetch --list-modules\n xfetch --clean-cache\n xfetch --daemon\n xfetch --daemon-stop\n xfetch doctor\n xfetch doctor --json --network\n xfetch modules --json\n xfetch config validate\n xfetch config print\n xfetch config get layout\n xfetch config set layout right\n xfetch completions zsh\n xfetch man\n xfetch addon plugin install animate-logo\n xfetch plugin install animate-logo\n xfetch plugin install --all\n xfetch plugin update --all --check\n xfetch plugin rollback animate-logo\n xfetch plugin list --verbose\n xfetch plugin info animate-logo\n xfetch plugin search logo\n xfetch plugin remove animate-logo\n xfetch effects install decrypt\n xfetch extension install config-roulette\n xfetch wasm inspect ./plugin.wasm\n xfetch wasm run ./plugin.wasm --request '{\"version\":1,\"kind\":\"info_provider\"}'\n xfetch wasm wit\n xfetch theme list\n xfetch theme set dracula\n xfetch theme remove dracula\n xfetch theme export my-theme\n xfetch update --check\n xfetch update" +)] +pub struct Cli { + #[command(subcommand)] + pub command: Option, + + #[arg(short, long, global = true)] + pub config: Option, + + #[arg(long, global = true)] + pub gen_config: bool, + + /// Logo to embed in the generated config, overriding the detected + /// OS/distro (e.g. `--logo arch`, `--logo windows-11`). Only used with + /// `--gen-config`; requires network access to the logos catalog. + #[arg(long, global = true)] + pub logo: Option, + + /// Layout name. Overrides the configured layout at runtime; with + /// `--gen-config` it selects the generated layout instead. + #[arg(long, global = true)] + pub layout: Option, + + /// Theme name. Overrides the configured theme at runtime. + #[arg(long, global = true)] + pub theme: Option, + + /// Print the layout names accepted by `--layout` and exit. + #[arg(long, global = true)] + pub list_layouts: bool, + + /// Print the module keys accepted in the config and exit. + #[arg(long, global = true)] + pub list_modules: bool, + + #[arg(long, global = true)] + pub clean_cache: bool, + + /// Back up the config currently in use, including its theme and + /// config-roulette routes. + #[arg(long, global = true)] + pub backup: bool, + + /// Restore the most recent config backup. + #[arg(long, global = true)] + pub restore: bool, + + #[arg(long, global = true)] + pub benchmark: bool, + + #[arg(long, global = true)] + pub daemon: bool, + + #[arg(long, global = true)] + pub daemon_stop: bool, + + /// Disable the live stats daemon (`daemon_live` in config) from the + /// terminal. + #[arg(long, global = true)] + pub no_daemon_live: bool, + + /// Stop the running live stats daemon. + #[arg(long, global = true)] + pub daemon_live_stop: bool, + + /// Force hot reload in the live stats daemon (equivalent to + /// `"daemon_live_reload": true` in the config). + #[arg(long, global = true)] + pub daemon_live_reload: bool, + + /// Emit machine-readable JSON. + #[arg(long, global = true)] + pub json: bool, + + /// Print only specific module keys. Repeat the flag or use commas: + /// `--get os,cpu --get memory`. + #[arg(long = "get", global = true, value_delimiter = ',', action = clap::ArgAction::Append)] + pub get: Vec, + + /// Override the rendered module list with a comma-separated key list. + #[arg(long = "modules", alias = "only", global = true)] + pub modules: Option, + + /// Control color output. + #[arg(long, global = true, value_enum, default_value_t = ColorChoice::Auto)] + pub color: ColorChoice, + + /// Disable colors; same as `--color never`. + #[arg(long, global = true)] + pub no_color: bool, + + /// Disable network access (public IP, catalogs, update checks, logo fetch). + #[arg(long, global = true)] + pub offline: bool, + + /// Bypass the package/public-IP cache for this run. + #[arg(long, global = true)] + pub no_cache: bool, + + /// Suppress informational output. + #[arg(long, global = true)] + pub quiet: bool, + + /// Print extra diagnostics. + #[arg(long, global = true)] + pub verbose: bool, + + /// Log level: off, error, warn, info, debug or trace. Overrides + /// --quiet/--verbose. + #[arg(long, global = true, value_enum)] + pub log_level: Option, + + /// Emit logs as JSON lines on stderr. + #[arg(long, global = true)] + pub log_json: bool, + + /// Render the fetched modules with a template file (`{{key}}`). + #[arg(long, global = true)] + pub template: Option, + + /// Render the fetched modules with an inline template (`{{key}}`). + #[arg(long, global = true)] + pub format: Option, +} + +#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum ColorChoice { + #[default] + Auto, + Always, + Never, +} + +#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum LogLevel { + Off, + Error, + Warn, + #[default] + Info, + Debug, + Trace, +} + +#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum SchemaKind { + Fetch, + Backup, + Addon, + Update, + Doctor, + Config, + #[default] + All, +} + +#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +pub enum AddonKindArg { + Plugin, + Effect, + Extension, + Command, +} + +#[derive(Subcommand, Debug)] +pub enum Commands { + /// Manage plugins. + #[command(alias = "plugins")] + Plugin { + #[command(subcommand)] + action: AddonCommands, + }, + /// Manage config-provider extensions. + #[command(alias = "extensions")] + Extension { + #[command(subcommand)] + action: AddonCommands, + }, + /// Manage CLI command addons. + #[command(alias = "commands")] + Command { + #[command(subcommand)] + action: AddonCommands, + }, + /// Run an installed command addon. + Run { + /// Installed command name. + name: String, + /// Render the output through the core layout and logo. + #[arg(long)] + render: bool, + /// Arguments passed to the command. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Manage local and remote themes. + Theme { + #[command(subcommand)] + action: ThemeCommands, + }, + /// Browse and preview the ASCII logo catalog. + Logo { + #[command(subcommand)] + action: LogoCommands, + }, + /// Manage intro effects. + #[command(alias = "effect")] + Effects { + #[command(subcommand)] + action: AddonCommands, + }, + /// Manage any addon kind with one command family. + Addon { + #[arg(value_enum)] + kind: AddonKindArg, + #[command(subcommand)] + action: AddonCommands, + }, + /// WebAssembly guest tooling: inspect artifacts, run them with a raw JSON + /// request and print the component protocol. + Wasm { + #[command(subcommand)] + action: WasmCommands, + }, + /// Check for a newer xfetch release and install it. + Update { + /// Only check; exit with code 1 when a newer release exists. + #[arg(long)] + check: bool, + /// Install the prebuilt release in place, even for local builds + /// (Unix and Windows). + #[arg(long)] + prebuilt: bool, + /// Directory holding the xfetch binary to replace (with --prebuilt). + #[arg(long, value_name = "DIR")] + bin_dir: Option, + /// Assume yes for confirmation prompts. + #[arg(long, short = 'y')] + yes: bool, + /// Restore the previous binary from the update backup. + #[arg(long)] + rollback: bool, + }, + /// Diagnose the installation, configuration, terminal and addons. + Doctor { + /// Also check remote catalog and update endpoints. + #[arg(long)] + network: bool, + /// Apply safe automatic fixes for common problems. + #[arg(long)] + fix: bool, + /// Run the info collection benchmark and include it in the report. + #[arg(long)] + bench: bool, + /// Print a preview of the configured logo/image in a TTY. + #[arg(long)] + image: bool, + }, + /// List the module keys accepted in the config. + Modules, + /// Inspect or edit the configuration. + Config { + #[command(subcommand)] + action: ConfigCommands, + }, + /// Generate shell completions. + Completions { + #[arg(value_enum)] + shell: clap_complete::Shell, + }, + /// Render the man page. + Man { + /// Write the page to this path instead of stdout. + #[arg(long, value_name = "PATH")] + output: Option, + }, + /// Print the JSON Schema for the CLI machine-readable outputs. + Schema { + #[arg(value_enum, default_value_t = SchemaKind::All)] + kind: SchemaKind, + }, + /// Print the resolved config, addon, cache and data paths. + Paths, + /// Print the platform, terminal, environment and runtime settings. + Env, + /// Print build information and enabled features. + #[command(name = "build-info")] + BuildInfo, + /// Remove the xfetch binary and optionally its config, cache and data. + #[command(name = "self-uninstall")] + SelfUninstall { + /// Also remove config, cache and data. + #[arg(long)] + purge: bool, + /// Skip the confirmation prompt. + #[arg(long, short)] + yes: bool, + }, +} + +#[derive(Subcommand, Debug)] +pub enum AddonCommands { + /// Install an addon from a catalog name, local path, repository, wasm file or URL. + Install { + path: Option, + #[arg(long, short)] + repo: Option, + /// Expected SHA-256 (hex) of a downloaded wasm artifact. + #[arg(long, value_name = "HEX")] + sha256: Option, + /// Override the installed name for a direct wasm file or URL. + #[arg(long)] + name: Option, + /// Sidecar manifest to attach to a direct wasm file or URL. + #[arg(long, value_name = "FILE")] + manifest: Option, + /// Install every addon available in the catalog. + #[arg(long)] + all: bool, + /// Reinstall addons that are already installed (with --all). + #[arg(long)] + force: bool, + }, + /// Update an installed addon, or every installed addon with --all. + Update { + name: Option, + /// Update every installed addon. + #[arg(long)] + all: bool, + /// Only check; exit with code 1 when an update is available. + #[arg(long)] + check: bool, + /// Reinstall even when the catalog version is not newer. + #[arg(long)] + force: bool, + }, + /// Restore the previous artifact saved by the last update. + Rollback { name: String }, + /// List installed addons. + List { + /// Show kind, runtime, version, capabilities and enabled state. + #[arg(long, short)] + verbose: bool, + }, + /// Show one installed addon and its configuration snippet. + Info { name: String }, + /// Search the remote catalog. Omit the query to list everything. + #[command(alias = "available")] + Search { query: Option }, + /// Remove an installed addon. + Remove { name: String }, +} + +#[derive(Subcommand, Debug)] +pub enum ThemeCommands { + /// List installed themes. + List, + /// Search the remote theme registry. + Search { query: Option }, + /// Show metadata for one remote theme. + Info { name: String }, + /// Install a theme from the remote registry. + Install { + name: String, + /// Reinstall an already installed theme. + #[arg(long)] + force: bool, + }, + /// Update installed themes from the remote registry. + Update { + name: Option, + /// Update every installed theme present in the registry. + #[arg(long)] + all: bool, + /// Only report; exit 1 when an update is available. + #[arg(long)] + check: bool, + /// Reinstall even when the registry version is not newer. + #[arg(long)] + force: bool, + }, + /// Set the active theme in the config. + Set { name: String }, + /// Remove an installed theme. + Remove { name: String }, + /// Export the current visual config as a reusable theme. + Export { name: String }, +} + +#[derive(Subcommand, Debug)] +pub enum LogoCommands { + /// List logos from the remote catalog. + List { query: Option }, + /// Print one logo from the remote catalog. + Preview { name: String }, +} + +#[derive(Subcommand, Debug)] +pub enum ConfigCommands { + /// Parse the config file and validate its schema. + Validate, + /// Print the resolved config (defaults, theme, extensions and overrides). + Print { + /// Print the config file as stored instead of the resolved config. + #[arg(long)] + raw: bool, + }, + /// Print one value by dotted key path. + Get { key: String }, + /// Set one value. Dotted paths and array indices are supported. + Set { key: String, value: String }, + /// Remove one value by dotted key path. + Unset { key: String }, + /// Replace the config with the built-in defaults after a safety backup. + Reset { + /// Skip the confirmation prompt. + #[arg(long, short)] + yes: bool, + }, + /// Open the config in $VISUAL or $EDITOR after a safety backup. + Edit, + /// List saved config backups. + Backups, + /// Delete old backups, keeping the newest N. + Prune { + /// Number of backups to keep. + #[arg(long, default_value_t = 5)] + keep: usize, + /// Print what would be deleted without removing files. + #[arg(long)] + dry_run: bool, + }, + /// Back up the config currently in use. + Backup { + /// Write to this file or directory instead of the default backup directory. + #[arg(long, value_name = "PATH")] + output: Option, + /// Include installed addon artifacts, manifests and receipts. + #[arg(long)] + with_addons: bool, + /// Compress the backup with gzip. + #[arg(long)] + compress: bool, + /// Encrypt the backup with a passphrase. + #[arg(long)] + encrypt: bool, + /// Read the backup passphrase from this file instead of + /// XFETCH_BACKUP_PASSPHRASE. + #[arg(long, value_name = "PATH")] + passphrase_file: Option, + }, + /// Restore a config backup. + Restore { + /// Backup file (or directory) to restore. Defaults to the latest backup. + #[arg(long, value_name = "PATH")] + from: Option, + /// Print what would be restored without writing files. + #[arg(long)] + dry_run: bool, + /// Remap files under the original config directory into this root. + #[arg(long, value_name = "DIR")] + root: Option, + /// Read the backup passphrase from this file instead of + /// XFETCH_BACKUP_PASSPHRASE. + #[arg(long, value_name = "PATH")] + passphrase_file: Option, + }, +} + +#[derive(Subcommand, Debug)] +pub enum WasmCommands { + /// Print the resolved kind, manifest, capabilities and limits of an + /// artifact without executing it. + Inspect { path: String }, + /// Execute a wasm guest with a raw JSON request and print its response. + /// Useful for authors and for scripted end-to-end checks. + Run { + path: String, + /// JSON request passed to the guest (defaults to `{}`). + #[arg(long)] + request: Option, + /// Read the JSON request from a file instead of `--request`. + #[arg(long, value_name = "PATH")] + request_file: Option, + /// Safety timeout in seconds; overrides the manifest limit. + #[arg(long)] + timeout: Option, + /// Guest contract used to select the component world. + #[arg(long, value_parser = ["plugin", "effect", "extension", "command"], default_value = "plugin")] + kind: String, + }, + /// Print the WIT package for component guests (componentize-py, + /// componentize-js, wit-bindgen, ...). + Wit, +} + +#[cfg(test)] +mod help_tests { + use super::*; + use clap::CommandFactory; + + #[test] + fn all_subcommands_have_descriptions() { + fn walk(command: &clap::Command, path: &str) { + for subcommand in command.get_subcommands() { + if subcommand.get_name() == "help" { + continue; + } + assert!( + subcommand.get_about().is_some(), + "{path} {} is missing a help description", + subcommand.get_name() + ); + walk(subcommand, &format!("{path} {}", subcommand.get_name())); + } + } + walk(&Cli::command(), "xfetch"); + } +} diff --git a/src/cli/backup_cmd.rs b/src/cli/backup_cmd.rs new file mode 100644 index 0000000..96ebb78 --- /dev/null +++ b/src/cli/backup_cmd.rs @@ -0,0 +1,1321 @@ +use crate::addons::model::AddonKind; +use crate::config::{ + Config, ConfigOverrides, config_dir, default_config_path, load_config_with_overrides, + parse_jsonc_str_strict, resolve_theme_path, +}; +use crate::error::XFetchError; +use crate::runtime; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use flate2::Compression; +use flate2::read::GzDecoder; +use flate2::write::GzEncoder; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::fs; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const SCHEMA_VERSION: u32 = 1; +const BACKUP_PREFIX: &str = "xfetch-backup-"; +const SAFETY_PREFIX: &str = "pre-restore-"; +const MAX_ASSET_BYTES: u64 = 8 * 1024 * 1024; +const MAX_ADDON_BYTES: u64 = 64 * 1024 * 1024; +const MAX_TOTAL_ADDON_BYTES: u64 = 512 * 1024 * 1024; +const ENCRYPTION_MAGIC: &[u8] = b"XFBACKUP1"; + +#[derive(Debug, Serialize, Deserialize)] +pub struct BackupBundle { + pub schema_version: u32, + pub created_unix: u64, + #[serde(default)] + pub created_unix_ms: u64, + pub created_utc: String, + pub xfetch_version: String, + pub config_path: String, + pub theme: Option, + pub theme_path: Option, + pub config_providers: Vec, + pub files: Vec, + pub missing: Vec, + pub warnings: Vec, + pub resolved_config: serde_json::Value, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct BackupFile { + pub role: String, + pub path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encoding: Option, + pub content: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MissingFile { + pub role: String, + pub path: String, + pub reason: String, +} + +#[derive(Debug, Deserialize)] +struct Route { + #[serde(rename = "_name", default)] + name: Option, + path: String, +} + +#[derive(Debug)] +struct BackupMeta { + path: PathBuf, + created_unix: u64, + created_unix_ms: u64, + created_utc: String, + files: usize, + missing: usize, + size: u64, + compressed: bool, +} + +pub fn default_backup_dir() -> PathBuf { + config_dir().join("xfetch").join("backups") +} + +pub fn backup( + config_path: Option, + output: Option, + with_addons: bool, + compress: bool, + encrypt: bool, + passphrase_file: Option, + json: bool, +) -> Result<(), XFetchError> { + let bundle = create_bundle(config_path, with_addons)?; + let passphrase = if encrypt { + Some(read_passphrase(passphrase_file.as_deref())?) + } else { + None + }; + let path = write_bundle( + &bundle, + output.as_deref(), + BACKUP_PREFIX, + compress, + encrypt, + passphrase.as_deref(), + )?; + + if json { + return crate::cli::output::print_json(&serde_json::json!({ + "schema_version": SCHEMA_VERSION, + "path": path.display().to_string(), + "files": bundle.files.len(), + "missing": bundle.missing.len(), + "warnings": bundle.warnings, + "with_addons": with_addons, + "compressed": compress, + "encrypted": encrypt, + })); + } + + runtime::info(format!("Backup written to {}", path.display())); + runtime::info(format!( + "Files: {} backed up, {} missing.", + bundle.files.len(), + bundle.missing.len() + )); + Ok(()) +} + +pub fn restore( + config_path: Option, + from: Option, + dry_run: bool, + root: Option, + passphrase_file: Option, + json: bool, +) -> Result<(), XFetchError> { + let backup_path = match from { + Some(value) => { + let path = expand_path(&value); + if path.is_dir() { + latest_in_dir(&path)? + } else { + path + } + } + None => latest_backup()?, + }; + let passphrase = read_optional_passphrase(passphrase_file.as_deref())?; + let bundle = load_bundle(&backup_path, passphrase.as_deref())?; + + let safety = if dry_run { + None + } else { + safety_backup(config_path.clone())? + }; + + let original_root = Path::new(&bundle.config_path) + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + let remap_root = root.as_ref().map(|value| expand_path(value)); + + let mut restored = Vec::new(); + let mut main_target = None; + for file in &bundle.files { + let original = PathBuf::from(&file.path); + let target = if file.role == "config" + && let Some(config_path) = &config_path + { + expand_path(config_path) + } else if let Some(root) = &remap_root + && let Ok(relative) = original.strip_prefix(&original_root) + { + root.join(relative) + } else { + original + }; + + if file.role == "config" { + main_target = Some(target.clone()); + } + restored.push(target.display().to_string()); + if dry_run { + continue; + } + + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|err| { + XFetchError::Config(format!("cannot create '{}': {}", parent.display(), err)) + })?; + } + let bytes = if file.encoding.as_deref() == Some("base64") { + BASE64.decode(file.content.as_bytes()).map_err(|err| { + XFetchError::Config(format!( + "cannot decode '{}' from the backup: {}", + file.path, err + )) + })? + } else { + file.content.as_bytes().to_vec() + }; + crate::atomic_fs::write_atomic(&target, &bytes).map_err(|err| { + XFetchError::Config(format!("cannot write '{}': {}", target.display(), err)) + })?; + } + + let validation = if dry_run { + None + } else { + main_target + .as_ref() + .map(|target| validate_restored_config(target)) + }; + + if json { + return crate::cli::output::print_json(&serde_json::json!({ + "schema_version": SCHEMA_VERSION, + "backup": backup_path.display().to_string(), + "dry_run": dry_run, + "root": root, + "restored": restored, + "missing": bundle.missing, + "safety_backup": safety.as_ref().map(|path| path.display().to_string()), + "validation": validation, + })); + } + + if dry_run { + runtime::info(format!( + "Would restore {} files from {}", + restored.len(), + backup_path.display() + )); + } else { + runtime::info(format!( + "Restored {} files from {}", + restored.len(), + backup_path.display() + )); + } + for path in &restored { + println!(" {path}"); + } + if let Some(safety) = &safety { + runtime::info(format!("Safety backup: {}", safety.display())); + } + if let Some(validation) = &validation + && !validation["valid"].as_bool().unwrap_or(true) + { + runtime::info(format!( + "Warning: the restored config is invalid: {}", + validation["error"].as_str().unwrap_or("unknown error") + )); + } + Ok(()) +} + +pub fn safety_backup(config_path: Option) -> Result, XFetchError> { + match create_bundle(config_path, false) { + Ok(bundle) => match write_bundle(&bundle, None, SAFETY_PREFIX, false, false, None) { + Ok(path) => Ok(Some(path)), + Err(err) => { + runtime::info(format!("Warning: could not write the safety backup: {err}")); + Ok(None) + } + }, + Err(err) => { + runtime::info(format!( + "Warning: could not back up the current config: {err}" + )); + Ok(None) + } + } +} + +pub fn list_backups(json: bool) -> Result<(), XFetchError> { + let mut backups = collect_meta(&default_backup_dir())?; + backups.sort_by_key(|meta| std::cmp::Reverse((meta.created_unix_ms, meta.created_unix))); + + if json { + let entries: Vec = backups + .iter() + .map(|meta| { + serde_json::json!({ + "path": meta.path.display().to_string(), + "created_unix": meta.created_unix, + "created_utc": meta.created_utc, + "files": meta.files, + "missing": meta.missing, + "size": meta.size, + "compressed": meta.compressed, + }) + }) + .collect(); + return crate::cli::output::print_json(&serde_json::json!({ + "schema_version": SCHEMA_VERSION, + "backups": entries, + })); + } + + if backups.is_empty() { + println!("No backups found."); + return Ok(()); + } + println!( + "{:<20} {:<8} {:<8} {:<10} PATH", + "CREATED", "FILES", "MISSING", "SIZE" + ); + for meta in &backups { + println!( + "{:<20} {:<8} {:<8} {:<10} {}", + meta.created_utc, + meta.files, + meta.missing, + format_size(meta.size), + meta.path.display() + ); + } + Ok(()) +} + +pub fn prune(keep: usize, dry_run: bool, json: bool) -> Result<(), XFetchError> { + let dir = default_backup_dir(); + let mut backups = collect_meta(&dir)?; + backups.retain(|meta| { + meta.path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(BACKUP_PREFIX)) + }); + backups.sort_by_key(|meta| std::cmp::Reverse((meta.created_unix_ms, meta.created_unix))); + + let deleted: Vec<&BackupMeta> = backups.iter().skip(keep).collect(); + if !dry_run { + for meta in &deleted { + fs::remove_file(&meta.path).map_err(|err| { + XFetchError::Config(format!("cannot delete '{}': {}", meta.path.display(), err)) + })?; + } + } + + if json { + return crate::cli::output::print_json(&serde_json::json!({ + "schema_version": SCHEMA_VERSION, + "kept": backups.len().saturating_sub(deleted.len()), + "deleted": deleted + .iter() + .map(|meta| meta.path.display().to_string()) + .collect::>(), + "dry_run": dry_run, + })); + } + + if deleted.is_empty() { + runtime::info("No backups to prune."); + } else if dry_run { + runtime::info(format!("Would delete {} old backups.", deleted.len())); + for meta in &deleted { + println!(" {}", meta.path.display()); + } + } else { + runtime::info(format!("Deleted {} old backups.", deleted.len())); + for meta in &deleted { + println!(" {}", meta.path.display()); + } + } + Ok(()) +} + +fn collect_meta(dir: &Path) -> Result, XFetchError> { + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => { + return Err(XFetchError::Config(format!( + "cannot read '{}': {}", + dir.display(), + err + ))); + } + }; + + let mut metas = Vec::new(); + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + if !(name.starts_with(BACKUP_PREFIX) || name.starts_with(SAFETY_PREFIX)) { + continue; + } + if !name.ends_with(".json") && !name.ends_with(".json.gz") { + continue; + } + let metadata = entry.metadata().ok(); + let size = metadata.as_ref().map(|meta| meta.len()).unwrap_or(0); + let compressed = name.ends_with(".gz"); + match load_bundle(&path, None) { + Ok(bundle) => metas.push(BackupMeta { + path, + created_unix: bundle.created_unix, + created_unix_ms: if bundle.created_unix_ms == 0 { + bundle.created_unix * 1000 + } else { + bundle.created_unix_ms + }, + created_utc: bundle.created_utc, + files: bundle.files.len(), + missing: bundle.missing.len(), + size, + compressed, + }), + Err(_) => { + let created = metadata + .and_then(|meta| meta.modified().ok()) + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + metas.push(BackupMeta { + path, + created_unix: created, + created_unix_ms: created * 1000, + created_utc: format_utc(created), + files: 0, + missing: 0, + size, + compressed, + }); + } + } + } + Ok(metas) +} + +fn validate_restored_config(path: &Path) -> serde_json::Value { + match fs::read_to_string(path) { + Ok(content) => match parse_jsonc_str_strict(&content) { + Ok(value) => match serde_json::from_value::(value) { + Ok(_) => serde_json::json!({"valid": true}), + Err(err) => serde_json::json!({"valid": false, "error": err.to_string()}), + }, + Err(err) => serde_json::json!({"valid": false, "error": err}), + }, + Err(err) => serde_json::json!({"valid": false, "error": err.to_string()}), + } +} + +fn create_bundle( + config_path: Option, + with_addons: bool, +) -> Result { + let config_path = config_path + .map(|value| expand_path(&value)) + .unwrap_or_else(default_config_path); + + if !config_path.is_file() { + return Err(XFetchError::Config(format!( + "no config file found at '{}'", + config_path.display() + ))); + } + + let main_content = fs::read_to_string(&config_path).map_err(|err| { + XFetchError::Config(format!("cannot read '{}': {}", config_path.display(), err)) + })?; + + let mut files = Vec::new(); + let mut missing = Vec::new(); + let mut warnings = Vec::new(); + let mut seen = HashSet::new(); + + add_file_text( + &mut files, + &mut seen, + "config", + &config_path, + None, + &main_content, + ); + + let mut config: Option = None; + match parse_jsonc_str_strict(&main_content) { + Ok(value) => match serde_json::from_value(value) { + Ok(parsed) => config = Some(parsed), + Err(err) => warnings.push(format!("config schema could not be read: {err}")), + }, + Err(err) => warnings.push(format!("config could not be parsed: {err}")), + } + + let theme_path = config + .as_ref() + .and_then(|config| config.theme.as_ref()) + .and_then(|theme| resolve_theme_path(theme)) + .map(|path| path.display().to_string()); + + let mut providers = Vec::new(); + if let Some(config) = &config { + collect_config_references( + config, + &config_path, + &mut files, + &mut missing, + &mut warnings, + &mut seen, + &mut providers, + 0, + ); + } + + if with_addons { + collect_addons(&mut files, &mut missing, &mut warnings, &mut seen); + } + + let resolved = load_config_with_overrides( + Some(config_path.to_string_lossy().into_owned()), + &ConfigOverrides::default(), + ); + let resolved_config = serde_json::to_value(&resolved).unwrap_or_else(|_| serde_json::json!({})); + let created_unix = now_unix(); + let created_unix_ms = now_unix_ms(); + + Ok(BackupBundle { + schema_version: SCHEMA_VERSION, + created_unix, + created_unix_ms, + created_utc: format_utc(created_unix), + xfetch_version: env!("CARGO_PKG_VERSION").to_string(), + config_path: config_path.display().to_string(), + theme: config.as_ref().and_then(|config| config.theme.clone()), + theme_path, + config_providers: providers, + files, + missing, + warnings, + resolved_config, + }) +} + +#[allow(clippy::too_many_arguments)] +fn collect_config_references( + config: &Config, + config_path: &Path, + files: &mut Vec, + missing: &mut Vec, + warnings: &mut Vec, + seen: &mut HashSet, + providers: &mut Vec, + depth: usize, +) { + if depth > 5 { + warnings.push("config provider nesting is too deep; stopping".to_string()); + return; + } + + collect_theme(config, files, missing, seen); + collect_config_asset(config, files, missing, warnings, seen); + + for provider in &config.config_providers { + if !providers.iter().any(|name| name == &provider.extension) { + providers.push(provider.extension.clone()); + } + + let routes = provider + .args + .as_ref() + .and_then(|args| args.get("routes")) + .and_then(|value| value.as_str()) + .map(str::to_string) + .or_else(|| { + provider + .extension + .to_ascii_lowercase() + .contains("roulette") + .then(|| "~/.config/xfetch/routes.json".to_string()) + }); + + if let Some(routes) = routes { + let routes_path = resolve_relative(config_path.parent(), &routes); + match fs::read_to_string(&routes_path) { + Ok(content) => { + add_file_text( + files, + seen, + "routes", + &routes_path, + Some(&provider.extension), + &content, + ); + match serde_json::from_str::>(&content) { + Ok(route_list) => { + let base = routes_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(); + for route in route_list { + let path = resolve_relative(Some(&base), &route.path); + match fs::read_to_string(&path) { + Ok(content) => { + let is_new = !seen.contains(&path.display().to_string()); + add_file_text( + files, + seen, + "route-config", + &path, + route.name.as_deref(), + &content, + ); + if !is_new { + continue; + } + match parse_jsonc_str_strict(&content) { + Ok(value) => { + match serde_json::from_value::(value) { + Ok(route_config) => { + collect_config_references( + &route_config, + &path, + files, + missing, + warnings, + seen, + providers, + depth + 1, + ); + } + Err(err) => warnings.push(format!( + "route config '{}' schema could not be read: {err}", + path.display() + )), + } + } + Err(err) => warnings.push(format!( + "route config '{}' could not be parsed: {err}", + path.display() + )), + } + } + Err(err) => missing.push(MissingFile { + role: "route-config".to_string(), + path: path.display().to_string(), + reason: err.to_string(), + }), + } + } + } + Err(err) => warnings.push(format!( + "routes file '{}' could not be parsed: {}", + routes_path.display(), + err + )), + } + } + Err(err) => missing.push(MissingFile { + role: "routes".to_string(), + path: routes_path.display().to_string(), + reason: err.to_string(), + }), + } + } + + if let Some(args) = &provider.args { + collect_arg_paths(args, config_path.parent(), files, missing, warnings, seen); + } + } +} + +fn collect_arg_paths( + value: &serde_json::Value, + base: Option<&Path>, + files: &mut Vec, + missing: &mut Vec, + warnings: &mut Vec, + seen: &mut HashSet, +) { + match value { + serde_json::Value::String(text) if looks_like_path(text) => { + let path = resolve_relative(base, text); + if path.is_file() { + add_asset_file(files, missing, warnings, seen, "provider-file", &path, None); + } + } + serde_json::Value::Array(items) => { + for item in items { + collect_arg_paths(item, base, files, missing, warnings, seen); + } + } + serde_json::Value::Object(map) => { + for (key, item) in map { + if key == "routes" { + continue; + } + collect_arg_paths(item, base, files, missing, warnings, seen); + } + } + _ => {} + } +} + +fn looks_like_path(value: &str) -> bool { + if value.starts_with("http://") || value.starts_with("https://") { + return false; + } + value.starts_with('~') + || value.starts_with('/') + || value.starts_with("./") + || value.starts_with("../") + || [ + ".json", ".jsonc", ".txt", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", + ".tiff", ".svg", + ] + .iter() + .any(|extension| value.ends_with(extension)) +} + +fn collect_theme( + config: &Config, + files: &mut Vec, + missing: &mut Vec, + seen: &mut HashSet, +) { + let Some(theme) = &config.theme else { + return; + }; + if let Some(path) = resolve_theme_path(theme) { + match fs::read_to_string(&path) { + Ok(content) => add_file_text(files, seen, "theme", &path, Some(theme), &content), + Err(err) => missing.push(MissingFile { + role: "theme".to_string(), + path: path.display().to_string(), + reason: err.to_string(), + }), + } + } else { + missing.push(MissingFile { + role: "theme".to_string(), + path: theme.clone(), + reason: "theme not found".to_string(), + }); + } +} + +fn collect_config_asset( + config: &Config, + files: &mut Vec, + missing: &mut Vec, + warnings: &mut Vec, + seen: &mut HashSet, +) { + let (value, role) = if let Some(path) = &config.logo_path { + (Some(path.as_str()), "logo") + } else if let Some(path) = &config.ascii { + (Some(path.as_str()), "ascii") + } else { + (None, "asset") + }; + let Some(value) = value else { + return; + }; + let path = expand_path(value); + if path.is_file() { + add_asset_file(files, missing, warnings, seen, role, &path, None); + } else { + missing.push(MissingFile { + role: role.to_string(), + path: path.display().to_string(), + reason: "file not found".to_string(), + }); + } +} + +fn collect_addons( + files: &mut Vec, + missing: &mut Vec, + warnings: &mut Vec, + seen: &mut HashSet, +) { + let mut total = 0u64; + for kind in [ + AddonKind::Plugin, + AddonKind::Effect, + AddonKind::Extension, + AddonKind::Command, + ] { + let installed = match kind { + AddonKind::Plugin => crate::plugins::list_plugins(), + AddonKind::Effect => crate::effects::list_effects(), + AddonKind::Extension => crate::extensions::list_extensions(), + AddonKind::Command => crate::commands::list_commands(), + }; + let installed = match installed { + Ok(installed) => installed, + Err(err) => { + warnings.push(format!("could not list {}: {}", kind.plural_label(), err)); + continue; + } + }; + + for (name, path) in installed { + let label = format!("{}:{}", kind.label(), name); + if let Ok(metadata) = fs::metadata(&path) { + if metadata.len() > MAX_ADDON_BYTES { + warnings.push(format!( + "skipping {} artifact ({} > {} bytes)", + label, + metadata.len(), + MAX_ADDON_BYTES + )); + } else if total + metadata.len() > MAX_TOTAL_ADDON_BYTES { + warnings.push(format!( + "skipping {} artifact (addon backup size limit reached)", + label + )); + } else if let Ok(bytes) = fs::read(&path) { + total += bytes.len() as u64; + add_file_bytes(files, seen, "addon-artifact", &path, Some(&label), &bytes); + } + } + + let manifest = kind.directory().join(kind.manifest_name(&name)); + if manifest.is_file() + && let Ok(content) = fs::read_to_string(&manifest) + { + add_file_text( + files, + seen, + "addon-manifest", + &manifest, + Some(&label), + &content, + ); + } + let receipt = kind.receipt_path(&name); + if receipt.is_file() + && let Ok(content) = fs::read_to_string(&receipt) + { + add_file_text( + files, + seen, + "addon-receipt", + &receipt, + Some(&label), + &content, + ); + } else { + missing.push(MissingFile { + role: "addon-receipt".to_string(), + path: receipt.display().to_string(), + reason: "receipt not found".to_string(), + }); + } + } + } +} + +fn add_asset_file( + files: &mut Vec, + missing: &mut Vec, + warnings: &mut Vec, + seen: &mut HashSet, + role: &str, + path: &Path, + name: Option<&str>, +) { + if seen.contains(&path.display().to_string()) { + return; + } + let metadata = match fs::metadata(path) { + Ok(metadata) => metadata, + Err(err) => { + missing.push(MissingFile { + role: role.to_string(), + path: path.display().to_string(), + reason: err.to_string(), + }); + return; + } + }; + if metadata.len() > MAX_ASSET_BYTES { + warnings.push(format!( + "skipping asset '{}' ({} > {} bytes)", + path.display(), + metadata.len(), + MAX_ASSET_BYTES + )); + return; + } + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(err) => { + missing.push(MissingFile { + role: role.to_string(), + path: path.display().to_string(), + reason: err.to_string(), + }); + return; + } + }; + if is_text_asset(path) + && let Ok(text) = String::from_utf8(bytes.clone()) + { + add_file_text(files, seen, role, path, name, &text); + return; + } + add_file_bytes(files, seen, role, path, name, &bytes); +} + +fn is_text_asset(path: &Path) -> bool { + let extension = path + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + !matches!( + extension.as_str(), + "png" | "jpg" | "jpeg" | "gif" | "bmp" | "ico" | "webp" | "tiff" + ) +} + +fn add_file_text( + files: &mut Vec, + seen: &mut HashSet, + role: &str, + path: &Path, + name: Option<&str>, + content: &str, +) { + let key = path.display().to_string(); + if seen.insert(key.clone()) { + files.push(BackupFile { + role: role.to_string(), + path: key, + name: name.map(str::to_string), + encoding: None, + content: content.to_string(), + }); + } +} + +fn add_file_bytes( + files: &mut Vec, + seen: &mut HashSet, + role: &str, + path: &Path, + name: Option<&str>, + bytes: &[u8], +) { + let key = path.display().to_string(); + if seen.insert(key.clone()) { + files.push(BackupFile { + role: role.to_string(), + path: key, + name: name.map(str::to_string), + encoding: Some("base64".to_string()), + content: BASE64.encode(bytes), + }); + } +} + +fn write_bundle( + bundle: &BackupBundle, + output: Option<&str>, + prefix: &str, + compress: bool, + encrypt: bool, + passphrase: Option<&str>, +) -> Result { + let path = backup_target(output, prefix, compress, encrypt)?; + let text = serde_json::to_string_pretty(bundle) + .map_err(|err| XFetchError::Config(format!("cannot serialize backup: {err}")))?; + let mut bytes = if compress { + gzip_compress(text.as_bytes()) + .map_err(|err| XFetchError::Config(format!("cannot compress backup: {err}")))? + } else { + text.into_bytes() + }; + if encrypt { + let passphrase = passphrase.ok_or_else(|| { + XFetchError::Usage( + "encrypted backups need a passphrase via --passphrase-file or XFETCH_BACKUP_PASSPHRASE" + .to_string(), + ) + })?; + bytes = encrypt_bytes(&bytes, passphrase) + .map_err(|err| XFetchError::Config(format!("cannot encrypt backup: {err}")))?; + } + crate::atomic_fs::write_atomic(&path, &bytes).map_err(|err| { + XFetchError::Config(format!("cannot write '{}': {}", path.display(), err)) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600)); + } + Ok(path) +} + +fn backup_target( + output: Option<&str>, + prefix: &str, + compress: bool, + encrypt: bool, +) -> Result { + let created = now_unix(); + let extension = if encrypt { + if compress { + ".json.gz.age" + } else { + ".json.age" + } + } else if compress { + ".json.gz" + } else { + ".json" + }; + match output { + Some(value) => { + let path = expand_path(value); + if path.is_dir() || value.ends_with('/') || value.ends_with(std::path::MAIN_SEPARATOR) { + fs::create_dir_all(&path).map_err(|err| { + XFetchError::Config(format!("cannot create '{}': {}", path.display(), err)) + })?; + Ok(unique_path(&path, prefix, created, extension)) + } else { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| { + XFetchError::Config(format!( + "cannot create '{}': {}", + parent.display(), + err + )) + })?; + } + Ok(path) + } + } + None => { + let dir = default_backup_dir(); + fs::create_dir_all(&dir).map_err(|err| { + XFetchError::Config(format!("cannot create '{}': {}", dir.display(), err)) + })?; + Ok(unique_path(&dir, prefix, created, extension)) + } + } +} + +fn unique_path(dir: &Path, prefix: &str, created: u64, extension: &str) -> PathBuf { + let mut candidate = dir.join(format!("{prefix}{created}{extension}")); + let mut counter = 1; + while candidate.exists() { + candidate = dir.join(format!("{prefix}{created}-{counter}{extension}")); + counter += 1; + } + candidate +} + +fn latest_backup() -> Result { + latest_in_dir(&default_backup_dir()) +} + +fn latest_in_dir(dir: &Path) -> Result { + let mut metas = collect_meta(dir)?; + metas.retain(|meta| { + meta.path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(BACKUP_PREFIX)) + }); + metas.sort_by_key(|meta| std::cmp::Reverse((meta.created_unix_ms, meta.created_unix))); + metas + .into_iter() + .next() + .map(|meta| meta.path) + .ok_or_else(|| XFetchError::NotFound(format!("no backups found in '{}'", dir.display()))) +} + +fn load_bundle(path: &Path, passphrase: Option<&str>) -> Result { + let mut bytes = fs::read(path) + .map_err(|err| XFetchError::Config(format!("cannot read '{}': {}", path.display(), err)))?; + if bytes.starts_with(ENCRYPTION_MAGIC) { + let passphrase = passphrase.ok_or_else(|| { + XFetchError::Config( + "this backup is encrypted; pass --passphrase-file or set XFETCH_BACKUP_PASSPHRASE" + .to_string(), + ) + })?; + bytes = decrypt_bytes(&bytes, passphrase).map_err(|err| { + XFetchError::Config(format!("cannot decrypt '{}': {}", path.display(), err)) + })?; + } + let bytes = if bytes.starts_with(&[0x1f, 0x8b]) { + gzip_decompress(&bytes).map_err(|err| { + XFetchError::Config(format!("cannot decompress '{}': {}", path.display(), err)) + })? + } else { + bytes + }; + let bundle: BackupBundle = serde_json::from_slice(&bytes).map_err(|err| { + XFetchError::Config(format!( + "'{}' is not a valid xfetch backup: {}", + path.display(), + err + )) + })?; + if bundle.schema_version != SCHEMA_VERSION { + return Err(XFetchError::Config(format!( + "unsupported backup schema version {} (expected {})", + bundle.schema_version, SCHEMA_VERSION + ))); + } + Ok(bundle) +} + +fn gzip_compress(bytes: &[u8]) -> std::io::Result> { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(bytes)?; + encoder.finish() +} + +fn gzip_decompress(bytes: &[u8]) -> std::io::Result> { + let mut decoder = GzDecoder::new(bytes); + let mut output = Vec::new(); + decoder.read_to_end(&mut output)?; + Ok(output) +} + +fn read_optional_passphrase(file: Option<&str>) -> Result, XFetchError> { + if let Some(file) = file { + let text = fs::read_to_string(file).map_err(|err| { + XFetchError::Config(format!("cannot read passphrase '{}': {}", file, err)) + })?; + let passphrase = text.trim_end_matches(['\r', '\n']).to_string(); + if passphrase.is_empty() { + return Err(XFetchError::Config( + "the passphrase file is empty".to_string(), + )); + } + return Ok(Some(passphrase)); + } + match std::env::var("XFETCH_BACKUP_PASSPHRASE") { + Ok(passphrase) if !passphrase.is_empty() => Ok(Some(passphrase)), + _ => Ok(None), + } +} + +fn read_passphrase(file: Option<&str>) -> Result { + read_optional_passphrase(file)?.ok_or_else(|| { + XFetchError::Usage("set XFETCH_BACKUP_PASSPHRASE or pass --passphrase-file".to_string()) + }) +} + +fn encrypt_bytes(bytes: &[u8], passphrase: &str) -> Result, String> { + use argon2::{Algorithm, Argon2, Params, Version}; + use chacha20poly1305::aead::{Aead, KeyInit}; + use chacha20poly1305::{XChaCha20Poly1305, XNonce}; + + let mut salt = [0u8; 16]; + let mut nonce = [0u8; 24]; + getrandom::getrandom(&mut salt).map_err(|err| err.to_string())?; + getrandom::getrandom(&mut nonce).map_err(|err| err.to_string())?; + + let params = Params::new(19 * 1024, 2, 1, Some(32)).map_err(|err| err.to_string())?; + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut key = [0u8; 32]; + argon + .hash_password_into(passphrase.as_bytes(), &salt, &mut key) + .map_err(|err| err.to_string())?; + + let cipher = XChaCha20Poly1305::new_from_slice(&key).map_err(|err| err.to_string())?; + let nonce = XNonce::try_from(&nonce[..]).map_err(|_| "invalid nonce".to_string())?; + let ciphertext = cipher + .encrypt(&nonce, bytes) + .map_err(|err| err.to_string())?; + + let mut output = + Vec::with_capacity(ENCRYPTION_MAGIC.len() + salt.len() + nonce.len() + ciphertext.len()); + output.extend_from_slice(ENCRYPTION_MAGIC); + output.extend_from_slice(&salt); + output.extend_from_slice(&nonce); + output.extend_from_slice(&ciphertext); + Ok(output) +} + +fn decrypt_bytes(bytes: &[u8], passphrase: &str) -> Result, String> { + use argon2::{Algorithm, Argon2, Params, Version}; + use chacha20poly1305::aead::{Aead, KeyInit}; + use chacha20poly1305::{XChaCha20Poly1305, XNonce}; + + if !bytes.starts_with(ENCRYPTION_MAGIC) { + return Err("not an encrypted xfetch backup".to_string()); + } + let header = ENCRYPTION_MAGIC.len(); + let salt_end = header + 16; + let nonce_end = salt_end + 24; + if bytes.len() < nonce_end + 16 { + return Err("encrypted backup is truncated".to_string()); + } + let salt = &bytes[header..salt_end]; + let nonce = &bytes[salt_end..nonce_end]; + let ciphertext = &bytes[nonce_end..]; + + let params = Params::new(19 * 1024, 2, 1, Some(32)).map_err(|err| err.to_string())?; + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut key = [0u8; 32]; + argon + .hash_password_into(passphrase.as_bytes(), salt, &mut key) + .map_err(|err| err.to_string())?; + + let cipher = XChaCha20Poly1305::new_from_slice(&key).map_err(|err| err.to_string())?; + let nonce = XNonce::try_from(nonce).map_err(|_| "invalid nonce".to_string())?; + cipher + .decrypt(&nonce, ciphertext) + .map_err(|_| "decryption failed".to_string()) +} + +fn resolve_relative(base: Option<&Path>, value: &str) -> PathBuf { + let expanded = expand_path(value); + if expanded.is_absolute() { + return expanded; + } + if let Some(base) = base { + let candidate = base.join(&expanded); + if candidate.exists() { + return candidate; + } + } + expanded +} + +fn expand_path(value: &str) -> PathBuf { + if value == "~" { + return dirs::home_dir().unwrap_or_else(|| PathBuf::from(value)); + } + if let Some(rest) = value.strip_prefix("~/") + && let Some(home) = dirs::home_dir() + { + return home.join(rest); + } + PathBuf::from(value) +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +fn now_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +fn format_utc(secs: u64) -> String { + let days = (secs / 86_400) as i64; + let remainder = secs % 86_400; + let (year, month, day) = civil_from_days(days); + let hour = remainder / 3_600; + let minute = (remainder % 3_600) / 60; + let second = remainder % 60; + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z") +} + +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = year + i64::from(month <= 2); + (year, month as u32, day as u32) +} + +fn format_size(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + let mut size = bytes as f64; + let mut unit = 0usize; + while size >= 1024.0 && unit + 1 < UNITS.len() { + size /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes} B") + } else { + format!("{size:.1} {}", UNITS[unit]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_utc_matches_a_known_timestamp() { + assert_eq!(format_utc(0), "1970-01-01T00:00:00Z"); + assert_eq!(format_utc(1_600_000_000), "2020-09-13T12:26:40Z"); + } + + #[test] + fn expand_path_handles_home_and_relative_paths() { + assert!(expand_path("~/x").is_absolute()); + assert_eq!(expand_path("relative"), PathBuf::from("relative")); + } + + #[test] + fn text_and_binary_files_roundtrip_through_base64() { + let original = b"\x00\x01hello"; + let encoded = BASE64.encode(original); + assert_eq!(BASE64.decode(encoded).unwrap(), original); + } +} diff --git a/src/cli/build_info_cmd.rs b/src/cli/build_info_cmd.rs new file mode 100644 index 0000000..3f48e2b --- /dev/null +++ b/src/cli/build_info_cmd.rs @@ -0,0 +1,62 @@ +use crate::cli::output; +use crate::error::XFetchError; +use serde_json::{Value, json}; + +pub fn collect() -> Value { + json!({ + "schema_version": output::JSON_SCHEMA_VERSION, + "name": "xfetch", + "package": env!("CARGO_PKG_NAME"), + "version": env!("CARGO_PKG_VERSION"), + "target": { + "os": std::env::consts::OS, + "arch": std::env::consts::ARCH, + "family": std::env::consts::FAMILY, + }, + "profile": if cfg!(debug_assertions) { "debug" } else { "release" }, + "features": { + "wasm": cfg!(feature = "wasm"), + "update": cfg!(feature = "update"), + "encryption": true, + "color": true, + }, + "rust_version": option_env!("CARGO_PKG_RUST_VERSION") + .filter(|value| !value.is_empty()) + .unwrap_or("unknown"), + }) +} + +pub fn run(json: bool) -> Result<(), XFetchError> { + let value = collect(); + if json { + return output::print_json(&value); + } + + println!("name: {}", value["name"].as_str().unwrap_or("?")); + println!( + "package: {}", + value["package"].as_str().unwrap_or("?") + ); + println!( + "version: {}", + value["version"].as_str().unwrap_or("?") + ); + println!( + "target: {} {} ({})", + value["target"]["os"].as_str().unwrap_or("?"), + value["target"]["arch"].as_str().unwrap_or("?"), + value["target"]["family"].as_str().unwrap_or("?") + ); + println!( + "profile: {}", + value["profile"].as_str().unwrap_or("?") + ); + println!("wasm support: {}", value["features"]["wasm"]); + println!("update support: {}", value["features"]["update"]); + println!("encryption: {}", value["features"]["encryption"]); + println!( + "rust version: {}", + value["rust_version"].as_str().unwrap_or("unknown") + ); + Ok(()) +} diff --git a/src/cli/completions.rs b/src/cli/completions.rs new file mode 100644 index 0000000..e6fabe4 --- /dev/null +++ b/src/cli/completions.rs @@ -0,0 +1,54 @@ +use crate::cli::args::Cli; +use crate::error::XFetchError; +use clap::CommandFactory; +use clap_complete::Shell; +use std::io::Write; + +pub fn completions(shell: Shell) -> Result<(), XFetchError> { + let mut command = Cli::command(); + let mut buffer = Vec::new(); + clap_complete::generate(shell, &mut command, "xfetch", &mut buffer); + let mut stdout = std::io::stdout(); + match stdout.write_all(&buffer).and_then(|()| stdout.flush()) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe => Ok(()), + Err(err) => Err(XFetchError::Fatal(format!( + "failed to write completions: {err}" + ))), + } +} + +pub fn man(output: Option) -> Result<(), XFetchError> { + let mut buffer = Vec::new(); + clap_mangen::Man::new(Cli::command()) + .render(&mut buffer) + .map_err(|err| XFetchError::Fatal(format!("failed to render man page: {err}")))?; + + match output { + Some(path) => { + let path = std::path::PathBuf::from(path); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|err| { + XFetchError::Fatal(format!("cannot create '{}': {}", parent.display(), err)) + })?; + } + crate::atomic_fs::write_atomic(&path, &buffer).map_err(|err| { + XFetchError::Fatal(format!("cannot write '{}': {}", path.display(), err)) + })?; + crate::runtime::info(format!("Wrote man page to {}", path.display())); + } + None => { + let mut stdout = std::io::stdout(); + match stdout.write_all(&buffer).and_then(|()| stdout.flush()) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe => {} + Err(err) => { + return Err(XFetchError::Fatal(format!( + "failed to write man page: {err}" + ))); + } + } + } + } + Ok(()) +} diff --git a/src/cli/config_cmd.rs b/src/cli/config_cmd.rs new file mode 100644 index 0000000..06336ba --- /dev/null +++ b/src/cli/config_cmd.rs @@ -0,0 +1,610 @@ +use crate::cli::args::{Cli, ConfigCommands}; +use crate::cli::output::print_json; +use crate::config::{ + Config, ConfigOverrides, default_config_path, load_config_with_overrides, + parse_jsonc_file_strict, parse_jsonc_str_strict, resolve_theme_path, +}; +use crate::error::XFetchError; +use crate::runtime; +use serde_json::Value; +use std::fs; +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub fn run( + action: ConfigCommands, + cli: &Cli, + overrides: &ConfigOverrides, + config_path: Option, +) -> Result<(), XFetchError> { + let path = config_path + .clone() + .map(PathBuf::from) + .unwrap_or_else(default_config_path); + + match action { + ConfigCommands::Validate => validate(&path, cli, config_path.is_some()), + ConfigCommands::Print { raw } => { + print_config(&path, raw, cli, overrides, config_path.clone()) + } + ConfigCommands::Get { key } => get_value(&path, &key, cli, overrides, config_path), + ConfigCommands::Set { key, value } => set_value(&path, &key, &value, cli), + ConfigCommands::Unset { key } => unset_value(&path, &key, cli), + ConfigCommands::Reset { yes } => reset_config(&path, yes, cli), + ConfigCommands::Edit => edit_config(&path, cli), + ConfigCommands::Backups => crate::cli::backup_cmd::list_backups(cli.json), + ConfigCommands::Prune { keep, dry_run } => { + crate::cli::backup_cmd::prune(keep, dry_run, cli.json) + } + ConfigCommands::Backup { + output, + with_addons, + compress, + encrypt, + passphrase_file, + } => crate::cli::backup_cmd::backup( + config_path, + output, + with_addons, + compress, + encrypt, + passphrase_file, + cli.json, + ), + ConfigCommands::Restore { + from, + dry_run, + root, + passphrase_file, + } => crate::cli::backup_cmd::restore( + config_path, + from, + dry_run, + root, + passphrase_file, + cli.json, + ), + } +} + +fn validate(path: &Path, cli: &Cli, explicit: bool) -> Result<(), XFetchError> { + if !path.exists() { + if explicit { + return Err(XFetchError::Config(format!( + "config file '{}' does not exist", + path.display() + ))); + } + if cli.json { + return print_json(&serde_json::json!({ + "schema_version": 1, + "valid": true, + "path": path.display().to_string(), + "exists": false, + })); + } + runtime::info(format!( + "Config not found at {}; defaults will be used.", + path.display() + )); + return Ok(()); + } + + let value = parse_jsonc_file_strict(path).map_err(XFetchError::Config)?; + let config: Config = serde_json::from_value(value) + .map_err(|err| XFetchError::Config(format!("invalid config schema: {err}")))?; + + if let Some(theme) = &config.theme + && resolve_theme_path(theme).is_none() + { + return Err(XFetchError::Config(format!( + "theme '{}' referenced by the config was not found", + theme + ))); + } + + if cli.json { + return print_json(&serde_json::json!({ + "schema_version": 1, + "valid": true, + "path": path.display().to_string(), + "exists": true, + })); + } + println!("Config OK: {}", path.display()); + Ok(()) +} + +fn print_config( + path: &Path, + raw: bool, + cli: &Cli, + overrides: &ConfigOverrides, + config_path: Option, +) -> Result<(), XFetchError> { + if raw { + let content = fs::read_to_string(path).map_err(|err| { + XFetchError::Config(format!("cannot read '{}': {}", path.display(), err)) + })?; + print!("{content}"); + if !content.ends_with('\n') { + println!(); + } + return Ok(()); + } + + let config = load_config_with_overrides(config_path, overrides); + let value = serde_json::to_value(&config) + .map_err(|err| XFetchError::Config(format!("failed to serialize config: {err}")))?; + if cli.json { + return print_json(&value); + } + let text = serde_json::to_string_pretty(&value) + .map_err(|err| XFetchError::Config(format!("failed to serialize config: {err}")))?; + println!("{text}"); + Ok(()) +} + +fn get_value( + path: &Path, + key: &str, + cli: &Cli, + overrides: &ConfigOverrides, + config_path: Option, +) -> Result<(), XFetchError> { + let config = load_config_with_overrides(config_path, overrides); + let value = serde_json::to_value(&config) + .map_err(|err| XFetchError::Config(format!("failed to serialize config: {err}")))?; + let found = lookup(&value, key).ok_or_else(|| { + XFetchError::NotFound(format!( + "config key '{}' was not found in {}", + key, + path.display() + )) + })?; + + if cli.json { + return print_json(found); + } + match found { + Value::String(text) => println!("{text}"), + other => { + let text = serde_json::to_string_pretty(other).unwrap_or_default(); + println!("{text}"); + } + } + Ok(()) +} + +fn set_value(path: &Path, key: &str, value: &str, cli: &Cli) -> Result<(), XFetchError> { + let content = if path.exists() { + fs::read_to_string(path).map_err(|err| { + XFetchError::Config(format!("cannot read '{}': {}", path.display(), err)) + })? + } else { + "{\n}\n".to_string() + }; + + let parsed_value = + serde_json::from_str::(value).unwrap_or_else(|_| Value::String(value.to_string())); + let new_json = serde_json::to_string(&parsed_value) + .map_err(|err| XFetchError::Config(format!("failed to serialize the value: {err}")))?; + let segments = crate::cli::jsonc_edit::parse_path(key); + + let updated = if let Some(updated) = + crate::cli::jsonc_edit::replace_value_preserving(&content, &segments, &new_json) + { + updated + } else if !key.contains('.') + && let Value::String(text) = &parsed_value + && let Some(updated) = set_top_level_string(&content, key, text) + { + updated + } else { + let mut root = parse_jsonc_str_strict(&content).map_err(XFetchError::Config)?; + set_path(&mut root, key, parsed_value)?; + serde_json::to_string_pretty(&root) + .map_err(|err| XFetchError::Config(format!("failed to serialize config: {err}")))? + }; + + let updated_value = parse_jsonc_str_strict(&updated).map_err(XFetchError::Config)?; + serde_json::from_value::(updated_value).map_err(|err| { + XFetchError::Config(format!( + "the new value would make the config invalid: {err}" + )) + })?; + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| { + XFetchError::Config(format!("cannot create '{}': {}", parent.display(), err)) + })?; + } + crate::atomic_fs::write_atomic(path, updated.as_bytes()).map_err(|err| { + XFetchError::Config(format!("cannot write '{}': {}", path.display(), err)) + })?; + + if cli.json { + return print_json(&serde_json::json!({ + "schema_version": 1, + "path": path.display().to_string(), + "key": key, + "value": value, + })); + } + runtime::info(format!("Set {} = {}", key, value)); + Ok(()) +} + +fn unset_value(path: &Path, key: &str, cli: &Cli) -> Result<(), XFetchError> { + let content = fs::read_to_string(path) + .map_err(|err| XFetchError::Config(format!("cannot read '{}': {}", path.display(), err)))?; + let segments = crate::cli::jsonc_edit::parse_path(key); + + let line_removed = remove_top_level_key(&content, key); + let updated = if let Some(updated) = line_removed + && parse_jsonc_str_strict(&updated).is_ok() + { + updated + } else { + let mut root = parse_jsonc_str_strict(&content).map_err(XFetchError::Config)?; + remove_path(&mut root, &segments)?; + serde_json::to_string_pretty(&root) + .map_err(|err| XFetchError::Config(format!("failed to serialize config: {err}")))? + }; + + let updated_value = parse_jsonc_str_strict(&updated).map_err(XFetchError::Config)?; + serde_json::from_value::(updated_value).map_err(|err| { + XFetchError::Config(format!( + "removing the value would make the config invalid: {err}" + )) + })?; + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| { + XFetchError::Config(format!("cannot create '{}': {}", parent.display(), err)) + })?; + } + crate::atomic_fs::write_atomic(path, updated.as_bytes()).map_err(|err| { + XFetchError::Config(format!("cannot write '{}': {}", path.display(), err)) + })?; + + if cli.json { + return print_json(&serde_json::json!({ + "schema_version": 1, + "path": path.display().to_string(), + "key": key, + "status": "unset", + })); + } + runtime::info(format!("Unset {key}")); + Ok(()) +} + +fn remove_top_level_key(content: &str, key: &str) -> Option { + let double = format!("\"{}\"", key); + let single = format!("'{}'", key); + let bare = format!("{}:", key); + let mut changed = false; + let mut output = String::new(); + for line in content.lines() { + let trimmed = line.trim_start(); + let is_key = trimmed.starts_with(&double) + || trimmed.starts_with(&single) + || trimmed.starts_with(&bare); + if !changed && is_key { + changed = true; + continue; + } + output.push_str(line); + output.push('\n'); + } + changed.then_some(output) +} + +fn remove_path( + root: &mut Value, + segments: &[crate::cli::jsonc_edit::Segment], +) -> Result<(), XFetchError> { + use crate::cli::jsonc_edit::Segment; + if segments.is_empty() { + return Err(XFetchError::Usage("empty config key".to_string())); + } + + let mut current = root; + for segment in &segments[..segments.len() - 1] { + current = match (current, segment) { + (Value::Object(map), Segment::Key(key)) => map.get_mut(key).ok_or_else(|| { + XFetchError::NotFound(format!("config key '{}' was not found", key)) + })?, + (Value::Array(items), Segment::Index(index)) => { + items.get_mut(*index).ok_or_else(|| { + XFetchError::NotFound(format!("array index {index} was not found")) + })? + } + _ => { + return Err(XFetchError::NotFound( + "config path was not found".to_string(), + )); + } + }; + } + + match (current, segments.last().expect("checked above")) { + (Value::Object(map), Segment::Key(key)) => map + .remove(key) + .map(|_| ()) + .ok_or_else(|| XFetchError::NotFound(format!("config key '{}' was not found", key))), + (Value::Array(items), Segment::Index(index)) => { + if *index < items.len() { + items.remove(*index); + Ok(()) + } else { + Err(XFetchError::NotFound(format!( + "array index {index} was not found" + ))) + } + } + _ => Err(XFetchError::NotFound( + "config path was not found".to_string(), + )), + } +} + +fn reset_config(path: &Path, yes: bool, cli: &Cli) -> Result<(), XFetchError> { + if path.exists() && !yes { + if std::io::stdin().is_terminal() { + eprint!( + "Replace '{}' with the built-in defaults? [y/N]: ", + path.display() + ); + let mut answer = String::new(); + std::io::stdin() + .read_line(&mut answer) + .map_err(|err| XFetchError::Fatal(format!("cannot read confirmation: {err}")))?; + if !matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") { + return Ok(()); + } + } else { + return Err(XFetchError::Usage( + "refusing to reset the config without --yes in non-interactive mode".to_string(), + )); + } + } + + let safety = crate::cli::backup_cmd::safety_backup(Some(path.display().to_string()))?; + crate::config::write_default_config(path).map_err(XFetchError::Config)?; + + if cli.json { + return print_json(&serde_json::json!({ + "schema_version": 1, + "path": path.display().to_string(), + "status": "reset", + "safety_backup": safety.as_ref().map(|path| path.display().to_string()), + })); + } + runtime::info(format!("Config reset to defaults: {}", path.display())); + if let Some(safety) = safety { + runtime::info(format!("Safety backup: {}", safety.display())); + } + Ok(()) +} + +fn edit_config(path: &Path, cli: &Cli) -> Result<(), XFetchError> { + let safety = crate::cli::backup_cmd::safety_backup(Some(path.display().to_string()))?; + let editor = std::env::var("VISUAL") + .or_else(|_| std::env::var("EDITOR")) + .map_err(|_| XFetchError::Usage("set $VISUAL or $EDITOR to edit the config".to_string()))?; + let status = Command::new(&editor) + .arg(path) + .status() + .map_err(|err| XFetchError::Fatal(format!("cannot run '{}': {err}", editor)))?; + if !status.success() { + return Err(XFetchError::Fatal(format!( + "editor '{}' exited with status {}", + editor, status + ))); + } + + validate_config_file(path)?; + if cli.json { + return print_json(&serde_json::json!({ + "schema_version": 1, + "path": path.display().to_string(), + "status": "edited", + "safety_backup": safety.as_ref().map(|path| path.display().to_string()), + })); + } + runtime::info(format!("Config edited and validated: {}", path.display())); + if let Some(safety) = safety { + runtime::info(format!("Safety backup: {}", safety.display())); + } + Ok(()) +} + +fn validate_config_file(path: &Path) -> Result<(), XFetchError> { + let content = fs::read_to_string(path) + .map_err(|err| XFetchError::Config(format!("cannot read '{}': {}", path.display(), err)))?; + let value = parse_jsonc_str_strict(&content).map_err(XFetchError::Config)?; + serde_json::from_value::(value) + .map(|_| ()) + .map_err(|err| XFetchError::Config(format!("invalid config schema: {err}"))) +} + +fn lookup<'a>(value: &'a Value, key: &str) -> Option<&'a Value> { + let mut current = value; + for part in key.split('.') { + current = match current { + Value::Object(map) => map.get(part)?, + Value::Array(items) => items.get(part.parse::().ok()?)?, + _ => return None, + }; + } + Some(current) +} + +fn set_path(root: &mut Value, key: &str, value: Value) -> Result<(), XFetchError> { + let segments = crate::cli::jsonc_edit::parse_path(key); + if segments.is_empty() { + return Err(XFetchError::Usage("empty config key".to_string())); + } + set_path_segments(root, &segments, value) +} + +fn set_path_segments( + current: &mut Value, + segments: &[crate::cli::jsonc_edit::Segment], + value: Value, +) -> Result<(), XFetchError> { + use crate::cli::jsonc_edit::Segment; + if segments.is_empty() { + *current = value; + return Ok(()); + } + + match &segments[0] { + Segment::Key(name) => { + if !current.is_object() { + *current = Value::Object(serde_json::Map::new()); + } + let object = current.as_object_mut().expect("object just ensured"); + let entry = object.entry(name.clone()).or_insert(Value::Null); + set_path_segments(entry, &segments[1..], value) + } + Segment::Index(index) => { + if !current.is_array() { + *current = Value::Array(Vec::new()); + } + let items = current.as_array_mut().expect("array just ensured"); + if *index > items.len() { + return Err(XFetchError::Usage(format!( + "array index {index} is out of range (length {})", + items.len() + ))); + } + if *index == items.len() { + items.push(Value::Null); + } + set_path_segments(&mut items[*index], &segments[1..], value) + } + } +} + +/// Replaces a top-level quoted string without touching comments or the rest of +/// the document. Returns `None` when the key cannot be edited in place. +fn set_top_level_string(content: &str, key: &str, value: &str) -> Option { + let mut found = false; + let mut result = String::new(); + let double = format!("\"{}\"", key); + let single = format!("'{}'", key); + let bare = format!("{}:", key); + + for line in content.lines() { + let trimmed = line.trim_start(); + let is_key = trimmed.starts_with(&double) + || trimmed.starts_with(&single) + || trimmed.starts_with(&bare); + if !found && is_key { + if let Some(colon) = line.find(':') { + let head = &line[colon + 1..]; + let trimmed_head = head.trim_start(); + let skip = head.len() - trimmed_head.len(); + if let Some(quote) = trimmed_head.chars().next() + && (quote == '"' || quote == '\'') + { + let open = colon + 1 + skip; + if let Some(relative) = line[open + 1..].find(quote) { + let close = open + 1 + relative; + result.push_str(&line[..open]); + result.push('"'); + result.push_str(value); + result.push('"'); + result.push_str(&line[close + 1..]); + found = true; + result.push('\n'); + continue; + } + } + } + if !found { + let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect(); + result.push_str(&indent); + result.push('"'); + result.push_str(key); + result.push_str("\": \""); + result.push_str(value); + result.push('"'); + found = true; + result.push('\n'); + continue; + } + } + result.push_str(line); + result.push('\n'); + } + + if found { + return Some(result); + } + + let index = content.find('{')?; + let rest = &content[index + 1..]; + let rest_trimmed = rest.trim_start(); + let needs_comma = !(rest_trimmed.is_empty() || rest_trimmed.starts_with('}')); + let mut output = String::with_capacity(content.len() + key.len() + value.len() + 8); + output.push_str(&content[..=index]); + output.push_str("\n \""); + output.push_str(key); + output.push_str("\": \""); + output.push_str(value); + output.push('"'); + if needs_comma { + output.push(','); + } + output.push_str(rest); + Some(output) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lookup_supports_dotted_paths_and_indices() { + let value = serde_json::json!({"colors": {"cpu": "red"}, "modules": ["os", "cpu"]}); + assert_eq!( + lookup(&value, "colors.cpu"), + Some(&Value::String("red".to_string())) + ); + assert_eq!( + lookup(&value, "modules.1"), + Some(&Value::String("cpu".to_string())) + ); + assert_eq!(lookup(&value, "modules.9"), None); + assert_eq!(lookup(&value, "missing"), None); + } + + #[test] + fn set_top_level_string_preserves_comments() { + let content = "{\n // keep me\n \"layout\": \"section\",\n \"theme\": \"nord\"\n}\n"; + let updated = set_top_level_string(content, "layout", "right").unwrap(); + assert!(updated.contains("// keep me")); + assert!(updated.contains("\"layout\": \"right\"")); + assert!(updated.contains("\"theme\": \"nord\"")); + } + + #[test] + fn set_top_level_string_inserts_a_missing_key() { + let content = "{\n \"theme\": \"nord\"\n}\n"; + let updated = set_top_level_string(content, "layout", "right").unwrap(); + assert!(updated.contains("\"layout\": \"right\"")); + assert!(updated.contains("\"theme\": \"nord\"")); + } + + #[test] + fn set_top_level_string_handles_an_empty_object() { + let updated = set_top_level_string("{\n}\n", "layout", "right").unwrap(); + let parsed = crate::config::parse_jsonc_str_strict(&updated).expect("valid jsonc"); + assert_eq!(parsed["layout"], "right"); + } +} diff --git a/src/cli/dispatch.rs b/src/cli/dispatch.rs new file mode 100644 index 0000000..297f612 --- /dev/null +++ b/src/cli/dispatch.rs @@ -0,0 +1,183 @@ +use crate::addons::model::AddonKind; +use crate::cli::args::{AddonKindArg, Cli, Commands}; +use crate::cli::{ + Context, addon_cmd, backup_cmd, build_info_cmd, completions, config_cmd, doctor, env_cmd, + fetch, logo_cmd, modules, overrides, paths_cmd, run_cmd, schema_cmd, settings, theme_cmd, + uninstall_cmd, update_cmd, wasm_cmd, +}; +use crate::error::XFetchError; +use crate::runtime; + +pub fn run(mut cli: Cli) -> Result<(), XFetchError> { + settings::init(&cli); + overrides::validate(&cli)?; + let overrides = overrides::build(&cli); + let ctx = Context { + config_path: cli.config.clone(), + overrides, + }; + let json = cli.json; + + if cli.backup { + return backup_cmd::backup(cli.config.clone(), None, false, false, false, None, json); + } + + if cli.restore { + return backup_cmd::restore(cli.config.clone(), None, false, None, None, json); + } + + if cli.list_layouts { + if json { + return crate::cli::output::print_json(&crate::ui::LAYOUT_NAMES); + } + for name in crate::ui::LAYOUT_NAMES { + println!("{name}"); + } + return Ok(()); + } + + if cli.list_modules { + return modules::print_modules(json); + } + + if cli.clean_cache { + return crate::cache::clean() + .map_err(|err| XFetchError::CleanCache(err.to_string())) + .map(|()| { + if json { + let _ = crate::cli::output::print_json(&serde_json::json!({"cleaned": true})); + } else { + runtime::info("Cache cleaned."); + } + }); + } + + if cli.daemon_stop { + let stopped = crate::ui::stop_daemon(); + if json { + return crate::cli::output::print_json(&serde_json::json!({"stopped": stopped})); + } + runtime::info(if stopped { + "Daemon stopped." + } else { + "No daemon running." + }); + return Ok(()); + } + + if cli.daemon_live_stop { + let stopped = crate::ui::stop_live_daemon(); + if json { + return crate::cli::output::print_json(&serde_json::json!({"stopped": stopped})); + } + runtime::info(if stopped { + "Live daemon stopped." + } else { + "No live daemon running." + }); + return Ok(()); + } + + if cli.gen_config { + let path = crate::config::generate_config( + cli.config.clone(), + cli.logo.as_deref(), + cli.layout.as_deref(), + ) + .map_err(|err| XFetchError::GenConfig(err.to_string()))?; + if json { + return crate::cli::output::print_json(&serde_json::json!({ + "path": path.display().to_string(), + })); + } + runtime::info(format!("Generated config: {}", path.display())); + runtime::info("Run xfetch to see the new layout."); + return Ok(()); + } + + match cli.command.take() { + Some(Commands::Plugin { action }) => addon_cmd::run( + AddonKind::Plugin, + action, + &cli, + &ctx.overrides, + ctx.config_path.clone(), + ), + Some(Commands::Extension { action }) => addon_cmd::run( + AddonKind::Extension, + action, + &cli, + &ctx.overrides, + ctx.config_path.clone(), + ), + Some(Commands::Effects { action }) => addon_cmd::run( + AddonKind::Effect, + action, + &cli, + &ctx.overrides, + ctx.config_path.clone(), + ), + Some(Commands::Addon { kind, action }) => addon_cmd::run( + addon_kind(kind), + action, + &cli, + &ctx.overrides, + ctx.config_path.clone(), + ), + Some(Commands::Command { action }) => addon_cmd::run( + AddonKind::Command, + action, + &cli, + &ctx.overrides, + ctx.config_path.clone(), + ), + Some(Commands::Run { name, render, args }) => { + run_cmd::run(&name, &args, render, cli.json, &ctx) + } + Some(Commands::Theme { action }) => theme_cmd::run(action, &cli, &ctx), + Some(Commands::Logo { action }) => logo_cmd::run(action, cli.json), + Some(Commands::Wasm { action }) => wasm_cmd::run(action, &cli), + Some(Commands::Update { + check, + prebuilt, + bin_dir, + yes, + rollback, + }) => update_cmd::run(check, prebuilt, bin_dir, yes, rollback, &cli), + Some(Commands::Doctor { + network, + fix, + bench, + image, + }) => doctor::run( + network, + fix, + bench, + image, + cli.json, + ctx.config_path.clone(), + &ctx.overrides, + ), + Some(Commands::Modules) => modules::print_modules(json), + Some(Commands::Config { action }) => { + config_cmd::run(action, &cli, &ctx.overrides, ctx.config_path.clone()) + } + Some(Commands::Completions { shell }) => completions::completions(shell), + Some(Commands::Man { output }) => completions::man(output), + Some(Commands::Schema { kind }) => schema_cmd::run(kind), + Some(Commands::Paths) => paths_cmd::run(ctx.config_path.clone(), cli.json), + Some(Commands::Env) => env_cmd::run(ctx.config_path.clone(), cli.json), + Some(Commands::BuildInfo) => build_info_cmd::run(cli.json), + Some(Commands::SelfUninstall { purge, yes }) => uninstall_cmd::run(purge, yes, cli.json), + None => fetch::run(&cli, &ctx), + } +} + +fn addon_kind(kind: AddonKindArg) -> AddonKind { + match kind { + AddonKindArg::Plugin => AddonKind::Plugin, + AddonKindArg::Effect => AddonKind::Effect, + AddonKindArg::Extension => AddonKind::Extension, + AddonKindArg::Command => AddonKind::Command, + } +} diff --git a/src/cli/doctor/mod.rs b/src/cli/doctor/mod.rs new file mode 100644 index 0000000..06d79f9 --- /dev/null +++ b/src/cli/doctor/mod.rs @@ -0,0 +1,713 @@ +use crate::addons::model::AddonKind; +pub mod platform; +use crate::config::{ + Config, ConfigOverrides, default_config_path, load_config_with_overrides, + parse_jsonc_file_strict, resolve_theme_path, +}; +use crate::error::XFetchError; +use crate::runtime; +use crate::ui; +use platform::PlatformInfo; +use serde::Serialize; +use std::io::IsTerminal; +use std::time::Duration; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Status { + Ok, + Warn, + Fail, +} + +impl Status { + fn label(self) -> &'static str { + match self { + Status::Ok => "ok", + Status::Warn => "warn", + Status::Fail => "fail", + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct Check { + pub category: String, + pub name: String, + pub status: Status, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Fix { + pub action: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DoctorReport { + pub schema_version: u32, + pub version: &'static str, + pub platform: PlatformInfo, + pub checks: Vec, + pub fixes: Vec, + pub benchmark: Vec, +} + +pub fn run( + network: bool, + fix: bool, + bench: bool, + image: bool, + json: bool, + config_path: Option, + overrides: &ConfigOverrides, +) -> Result<(), XFetchError> { + let (mut report, config) = build_report(network, fix, config_path, overrides); + if bench { + let (_info, benchmark) = crate::info::Info::with_config(&config, true); + report.benchmark = benchmark; + } + let failures = report + .checks + .iter() + .filter(|check| check.status == Status::Fail) + .count(); + + if json { + crate::cli::output::print_json(&report)?; + } else { + print_human(&report); + if image && std::io::stdout().is_terminal() { + println!(); + println!("[image]"); + if let Err(err) = crate::ui::preview_logo(&config) { + crate::runtime::warn(format!("image preview failed: {err}")); + } + } + } + + if failures > 0 { + return Err(XFetchError::Exit(crate::error::EXIT_GENERIC)); + } + Ok(()) +} + +fn build_report( + network: bool, + fix: bool, + config_path: Option, + overrides: &ConfigOverrides, +) -> (DoctorReport, Config) { + let mut checks = Vec::new(); + let mut fixes = Vec::new(); + check_environment(&mut checks); + let config = check_configuration(&mut checks, &mut fixes, fix, config_path, overrides); + check_terminal(&mut checks, &config); + check_addons(&mut checks); + check_cache(&mut checks, &mut fixes, fix); + if network { + check_network(&mut checks); + } + + ( + DoctorReport { + schema_version: crate::cli::output::JSON_SCHEMA_VERSION, + version: env!("CARGO_PKG_VERSION"), + platform: platform::collect(), + checks, + fixes, + benchmark: Vec::new(), + }, + config, + ) +} + +fn push( + checks: &mut Vec, + category: &str, + name: &str, + status: Status, + detail: impl Into, +) { + checks.push(Check { + category: category.to_string(), + name: name.to_string(), + status, + detail: detail.into(), + }); +} + +fn check_environment(checks: &mut Vec) { + let profile = if cfg!(debug_assertions) { + "debug" + } else { + "release" + }; + push( + checks, + "environment", + "version", + Status::Ok, + format!("xfetch {} ({profile})", env!("CARGO_PKG_VERSION")), + ); + + match std::env::current_exe() { + Ok(path) => push( + checks, + "environment", + "binary", + if path.is_file() { + Status::Ok + } else { + Status::Fail + }, + path.display().to_string(), + ), + Err(err) => push( + checks, + "environment", + "binary", + Status::Warn, + format!("cannot resolve the running binary: {err}"), + ), + } + + for (name, status) in [ + ("TERM", Status::Ok), + ("TERM_PROGRAM", Status::Ok), + ("KITTY_WINDOW_ID", Status::Ok), + ("COLORTERM", Status::Ok), + ("XDG_SESSION_TYPE", Status::Ok), + ("XDG_CURRENT_DESKTOP", Status::Ok), + ("SHELL", Status::Ok), + ] { + if let Ok(value) = std::env::var(name) { + push(checks, "environment", name, status, value); + } + } +} + +fn check_configuration( + checks: &mut Vec, + fixes: &mut Vec, + fix: bool, + config_path: Option, + overrides: &ConfigOverrides, +) -> Config { + let path = config_path + .as_ref() + .map(std::path::PathBuf::from) + .unwrap_or_else(default_config_path); + + if path.exists() { + match parse_jsonc_file_strict(&path) { + Ok(value) => match serde_json::from_value::(value) { + Ok(_) => push( + checks, + "config", + "file", + Status::Ok, + path.display().to_string(), + ), + Err(err) => push( + checks, + "config", + "schema", + Status::Fail, + format!("invalid config schema: {err}"), + ), + }, + Err(err) => push(checks, "config", "file", Status::Fail, err), + } + } else { + if fix { + match crate::config::write_default_config(&path) { + Ok(()) => { + fixes.push(Fix { + action: "generate-config".to_string(), + detail: path.display().to_string(), + }); + push( + checks, + "config", + "file", + Status::Ok, + format!("{} (generated)", path.display()), + ); + } + Err(err) => push( + checks, + "config", + "file", + Status::Fail, + format!("cannot generate '{}': {}", path.display(), err), + ), + } + } else { + push( + checks, + "config", + "file", + Status::Warn, + format!("{} (not found; defaults are used)", path.display()), + ); + } + } + + let config = load_config_with_overrides(config_path, overrides); + + if let Some(theme) = &config.theme { + let status = if resolve_theme_path(theme).is_some() { + Status::Ok + } else { + Status::Warn + }; + push(checks, "config", "theme", status, theme.clone()); + } else { + push(checks, "config", "theme", Status::Ok, "default".to_string()); + } + + match config.layout.as_deref() { + Some(layout) if ui::is_known_layout(layout) => { + push(checks, "config", "layout", Status::Ok, layout.to_string()) + } + Some(layout) => push( + checks, + "config", + "layout", + Status::Warn, + format!("{layout} (not in --list-layouts; the default is used)"), + ), + None => push( + checks, + "config", + "layout", + Status::Ok, + "default".to_string(), + ), + } + + push( + checks, + "config", + "modules", + Status::Ok, + config.modules.len().to_string(), + ); + push( + checks, + "config", + "cache", + Status::Ok, + if config.disable_cache.unwrap_or(false) { + "disabled".to_string() + } else { + "enabled".to_string() + }, + ); + push( + checks, + "config", + "network probes", + Status::Ok, + if config.disable_ip_fetching.unwrap_or(false) { + "disabled".to_string() + } else { + "enabled".to_string() + }, + ); + + config +} + +fn check_terminal(checks: &mut Vec, config: &Config) { + let is_tty = std::io::stdout().is_terminal(); + push( + checks, + "terminal", + "stdout", + if is_tty { Status::Ok } else { Status::Warn }, + if is_tty { + "tty".to_string() + } else { + "not a tty (images and effects are disabled)".to_string() + }, + ); + + let width = match crossterm::terminal::size() { + Ok((width, height)) if width > 0 && height > 0 => { + push( + checks, + "terminal", + "size", + Status::Ok, + format!("{width}x{height}"), + ); + width as usize + } + _ => { + push( + checks, + "terminal", + "size", + Status::Warn, + "unavailable (falls back to 80x24 geometry)".to_string(), + ); + 80 + } + }; + + let protocol = image_protocol(config); + push(checks, "terminal", "image protocol", Status::Ok, protocol); + + let diagnostics = ui::logo_diagnostics(config, width); + push( + checks, + "terminal", + "logo", + if diagnostics.path_exists { + Status::Ok + } else { + Status::Warn + }, + diagnostics + .path + .clone() + .unwrap_or_else(|| "default".to_string()), + ); + push( + checks, + "terminal", + "logo kind", + Status::Ok, + if diagnostics.is_image { + "image" + } else { + "ascii" + }, + ); + if let Some(requested) = diagnostics.requested_width { + push( + checks, + "terminal", + "logo width requested", + Status::Ok, + requested.to_string(), + ); + } + if let Some(printed) = diagnostics.printed_width { + push( + checks, + "terminal", + "logo width printed", + Status::Ok, + printed.to_string(), + ); + } + if let Some(lines) = diagnostics.ascii_lines { + push( + checks, + "terminal", + "logo lines", + Status::Ok, + lines.to_string(), + ); + } + push( + checks, + "terminal", + "logo gap", + Status::Ok, + diagnostics.gap.to_string(), + ); + if let Some(content_width) = diagnostics.content_width { + let label = if config.layout.as_deref() == Some("right") { + "content width (right)" + } else { + "content width" + }; + push( + checks, + "terminal", + label, + Status::Ok, + content_width.to_string(), + ); + } + if let Some(image_x) = diagnostics.image_x { + push( + checks, + "terminal", + "image column", + Status::Ok, + image_x.to_string(), + ); + } +} + +fn image_protocol(config: &Config) -> &'static str { + if std::env::var_os("KITTY_WINDOW_ID").is_some() && config.logo_kitty.unwrap_or(true) { + return "kitty graphics"; + } + let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default(); + let lc_terminal = std::env::var("LC_TERMINAL").unwrap_or_default(); + for value in [&term_program, &lc_terminal] { + if value.contains("iTerm") + || value.contains("WezTerm") + || value.contains("mintty") + || value.contains("rio") + || value.contains("WarpTerminal") + { + return "iterm inline image"; + } + } + "half blocks" +} + +fn check_addons(checks: &mut Vec) { + for kind in [ + AddonKind::Plugin, + AddonKind::Effect, + AddonKind::Extension, + AddonKind::Command, + ] { + let installed = match kind { + AddonKind::Plugin => crate::plugins::list_plugins(), + AddonKind::Effect => crate::effects::list_effects(), + AddonKind::Extension => crate::extensions::list_extensions(), + AddonKind::Command => crate::commands::list_commands(), + }; + match installed { + Ok(entries) => { + let total = entries.len(); + let receipts = entries + .iter() + .filter(|(name, _)| kind.receipt_path(name).is_file()) + .count(); + let status = if total > 0 && receipts < total { + Status::Warn + } else { + Status::Ok + }; + push( + checks, + "addons", + kind.plural_label(), + status, + format!( + "{total} installed, {receipts} with install receipt, {}", + kind.directory().display() + ), + ); + } + Err(err) => push(checks, "addons", kind.plural_label(), Status::Warn, err), + } + } +} + +fn check_cache(checks: &mut Vec, fixes: &mut Vec, fix: bool) { + let cache = dirs::cache_dir() + .map(|dir| dir.join("xfetch").join("cache.json")) + .unwrap_or_else(|| std::path::PathBuf::from("cache.json")); + if cache.exists() { + let size = std::fs::metadata(&cache) + .map(|meta| meta.len()) + .unwrap_or(0); + let valid = std::fs::read_to_string(&cache) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .is_some(); + if !valid && fix { + match std::fs::remove_file(&cache) { + Ok(()) => { + fixes.push(Fix { + action: "remove-invalid-cache".to_string(), + detail: cache.display().to_string(), + }); + push( + checks, + "cache", + "file", + Status::Ok, + format!("{} (removed invalid cache)", cache.display()), + ); + } + Err(err) => push( + checks, + "cache", + "file", + Status::Fail, + format!("cannot remove '{}': {}", cache.display(), err), + ), + } + } else { + push( + checks, + "cache", + "file", + if valid { Status::Ok } else { Status::Warn }, + format!( + "{} ({size} bytes{})", + cache.display(), + if valid { "" } else { ", invalid JSON" } + ), + ); + } + } else { + push( + checks, + "cache", + "file", + Status::Ok, + format!("{} (not created yet)", cache.display()), + ); + } +} + +fn check_network(checks: &mut Vec) { + if runtime::offline() { + push( + checks, + "network", + "checks", + Status::Warn, + "skipped because --offline is set".to_string(), + ); + return; + } + + let urls = [ + ( + "plugins catalog", + "https://raw.githubusercontent.com/xfetch-cli/plugins/main/index.json", + ), + ( + "effects catalog", + "https://raw.githubusercontent.com/xfetch-cli/effects/main/index.json", + ), + ( + "extensions catalog", + "https://raw.githubusercontent.com/xfetch-cli/extensions/main/index.json", + ), + ( + "update API", + "https://api.github.com/repos/xfetch-cli/xfetch/releases/latest", + ), + ]; + + for (name, url) in urls { + match probe(url) { + Ok(status) if (200..300).contains(&status) => push( + checks, + "network", + name, + Status::Ok, + format!("HTTP {status}"), + ), + Ok(status) => push( + checks, + "network", + name, + Status::Warn, + format!("HTTP {status}"), + ), + Err(err) => push(checks, "network", name, Status::Fail, err), + } + } +} + +fn probe(url: &str) -> Result { + let response = ureq::get(url) + .config() + .timeout_global(Some(Duration::from_secs(4))) + .build() + .call() + .map_err(|err| err.to_string())?; + Ok(response.status().as_u16()) +} + +fn print_human(report: &DoctorReport) { + println!( + "xfetch {} doctor — {} {} ({})", + report.version, report.platform.os, report.platform.arch, report.platform.family + ); + if !runtime::quiet() { + for detail in &report.platform.details { + println!(" {:<22} {}", detail.name, detail.value); + } + } + + let mut current = ""; + for check in &report.checks { + if runtime::quiet() && check.status == Status::Ok { + continue; + } + if check.category != current { + current = &check.category; + println!(); + println!("[{}]", check.category); + } + println!( + " {:<6} {:<24} {}", + check.status.label(), + check.name, + check.detail + ); + } + + if !report.fixes.is_empty() { + println!(); + println!("[fixes]"); + for fix in &report.fixes { + println!(" {:<24} {}", fix.action, fix.detail); + } + } + + if !report.benchmark.is_empty() { + println!(); + println!("[benchmark]"); + for line in &report.benchmark { + println!(" {line}"); + } + } + + let failures = report + .checks + .iter() + .filter(|check| check.status == Status::Fail) + .count(); + let warnings = report + .checks + .iter() + .filter(|check| check.status == Status::Warn) + .count(); + println!(); + println!( + "{} checks: {} failures, {} warnings", + report.checks.len(), + failures, + warnings + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_labels_are_stable() { + assert_eq!(Status::Ok.label(), "ok"); + assert_eq!(Status::Warn.label(), "warn"); + assert_eq!(Status::Fail.label(), "fail"); + } + + #[test] + fn image_protocol_prefers_kitty() { + // The test process inherits the environment; only assert that the + // function returns one of the documented values. + let config = Config::default(); + let protocol = image_protocol(&config); + assert!(["kitty graphics", "iterm inline image", "half blocks"].contains(&protocol)); + } +} diff --git a/src/cli/doctor/platform/linux.rs b/src/cli/doctor/platform/linux.rs new file mode 100644 index 0000000..24bc491 --- /dev/null +++ b/src/cli/doctor/platform/linux.rs @@ -0,0 +1,62 @@ +use super::{Detail, command_exists, env_detail}; + +pub fn collect() -> Vec { + let mut details = Vec::new(); + + if let Ok(content) = std::fs::read_to_string("/etc/os-release") { + for line in content.lines() { + if let Some(value) = line.strip_prefix("PRETTY_NAME=") { + details.push(detail("Distribution", trim_quotes(value))); + } else if let Some(value) = line.strip_prefix("VERSION_ID=") { + details.push(detail("Distribution version", trim_quotes(value))); + } else if let Some(value) = line.strip_prefix("ID=") { + details.push(detail("Distribution id", trim_quotes(value))); + } + } + } + + if let Some(detail) = env_detail("WSL_DISTRO_NAME") { + details.push(detail); + } else if let Ok(version) = std::fs::read_to_string("/proc/version") + && version.to_ascii_lowercase().contains("microsoft") + { + details.push(detail("WSL", "yes")); + } + + for name in [ + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "WAYLAND_DISPLAY", + "DISPLAY", + ] { + if let Some(detail) = env_detail(name) { + details.push(detail); + } + } + + let managers: Vec<&str> = ["pacman", "dpkg", "rpm", "apk", "nix", "flatpak", "brew"] + .into_iter() + .filter(|command| command_exists(command)) + .collect(); + details.push(detail( + "Package managers", + if managers.is_empty() { + "none detected".to_string() + } else { + managers.join(", ") + }, + )); + + details +} + +fn detail(name: &str, value: impl Into) -> Detail { + Detail { + name: name.to_string(), + value: value.into(), + } +} + +fn trim_quotes(value: &str) -> String { + value.trim().trim_matches('"').to_string() +} diff --git a/src/cli/doctor/platform/macos.rs b/src/cli/doctor/platform/macos.rs new file mode 100644 index 0000000..97588a0 --- /dev/null +++ b/src/cli/doctor/platform/macos.rs @@ -0,0 +1,31 @@ +use super::{Detail, env_detail}; + +pub fn collect() -> Vec { + let mut details = Vec::new(); + + if let Ok(output) = std::process::Command::new("sw_vers") + .arg("-productVersion") + .output() + && output.status.success() + { + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !version.is_empty() { + details.push(detail("macOS version", version)); + } + } + + for name in ["TERM_PROGRAM", "SHELL", "SSH_CONNECTION"] { + if let Some(detail) = env_detail(name) { + details.push(detail); + } + } + + details +} + +fn detail(name: &str, value: impl Into) -> Detail { + Detail { + name: name.to_string(), + value: value.into(), + } +} diff --git a/src/cli/doctor/platform/mod.rs b/src/cli/doctor/platform/mod.rs new file mode 100644 index 0000000..a48d386 --- /dev/null +++ b/src/cli/doctor/platform/mod.rs @@ -0,0 +1,77 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct Detail { + pub name: String, + pub value: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PlatformInfo { + pub os: String, + pub arch: String, + pub family: String, + pub details: Vec, +} + +pub fn collect() -> PlatformInfo { + PlatformInfo { + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + family: std::env::consts::FAMILY.to_string(), + details: platform_details(), + } +} + +#[cfg(target_os = "linux")] +pub fn command_exists(command: &str) -> bool { + std::env::var_os("PATH") + .map(|paths| std::env::split_paths(&paths).any(|dir| dir.join(command).is_file())) + .unwrap_or(false) +} + +pub fn env_detail(name: &str) -> Option { + std::env::var(name).ok().map(|value| Detail { + name: name.to_string(), + value, + }) +} + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; + +#[cfg(target_os = "linux")] +fn platform_details() -> Vec { + linux::collect() +} + +#[cfg(target_os = "macos")] +fn platform_details() -> Vec { + macos::collect() +} + +#[cfg(target_os = "windows")] +fn platform_details() -> Vec { + windows::collect() +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +fn platform_details() -> Vec { + Vec::new() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn platform_info_has_os_and_arch() { + let info = collect(); + assert!(!info.os.is_empty()); + assert!(!info.arch.is_empty()); + } +} diff --git a/src/cli/doctor/platform/windows.rs b/src/cli/doctor/platform/windows.rs new file mode 100644 index 0000000..d79bd34 --- /dev/null +++ b/src/cli/doctor/platform/windows.rs @@ -0,0 +1,17 @@ +use super::{Detail, env_detail}; + +pub fn collect() -> Vec { + let mut details = Vec::new(); + for name in [ + "PROCESSOR_ARCHITECTURE", + "SESSIONNAME", + "WT_SESSION", + "HOMEDRIVE", + "USERPROFILE", + ] { + if let Some(detail) = env_detail(name) { + details.push(detail); + } + } + details +} diff --git a/src/cli/env_cmd.rs b/src/cli/env_cmd.rs new file mode 100644 index 0000000..236aeda --- /dev/null +++ b/src/cli/env_cmd.rs @@ -0,0 +1,104 @@ +use crate::cli::{build_info_cmd, paths_cmd}; +use crate::error::XFetchError; +use crate::runtime; +use serde_json::{Map, Value, json}; + +pub fn collect(config_path: Option) -> Value { + let mut environment = Map::new(); + for name in [ + "TERM", + "TERM_PROGRAM", + "KITTY_WINDOW_ID", + "COLORTERM", + "SHELL", + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "WAYLAND_DISPLAY", + "DISPLAY", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_STATE_HOME", + ] { + if let Ok(value) = std::env::var(name) { + environment.insert(name.to_string(), Value::String(value)); + } + } + + json!({ + "schema_version": crate::cli::output::JSON_SCHEMA_VERSION, + "build": build_info_cmd::collect(), + "platform": crate::cli::doctor::platform::collect(), + "environment": environment, + "settings": { + "log_level": runtime::log_level().as_str(), + "log_json": runtime::log_json(), + "offline": runtime::offline(), + "no_color": runtime::no_color(), + "quiet": runtime::quiet(), + "verbose": runtime::verbose(), + }, + "paths": paths_cmd::collect(config_path), + }) +} + +pub fn run(config_path: Option, json: bool) -> Result<(), XFetchError> { + let value = collect(config_path); + if json { + return crate::cli::output::print_json(&value); + } + + println!("[build]"); + println!( + "version: {}", + value["build"]["version"].as_str().unwrap_or("?") + ); + println!( + "target: {} {}", + value["build"]["target"]["os"].as_str().unwrap_or("?"), + value["build"]["target"]["arch"].as_str().unwrap_or("?") + ); + println!( + "profile: {}", + value["build"]["profile"].as_str().unwrap_or("?") + ); + + println!(); + println!("[platform]"); + if let Some(details) = value["platform"]["details"].as_array() { + for detail in details { + println!( + " {:<22} {}", + detail["name"].as_str().unwrap_or("?"), + detail["value"].as_str().unwrap_or("?") + ); + } + } + + println!(); + println!("[environment]"); + if let Some(environment) = value["environment"].as_object() { + for (key, value) in environment { + println!(" {:<22} {}", key, value.as_str().unwrap_or("?")); + } + } + + println!(); + println!("[settings]"); + if let Some(settings) = value["settings"].as_object() { + for (key, value) in settings { + println!(" {:<22} {}", key, value); + } + } + + println!(); + println!("[paths]"); + if let Some(paths) = value["paths"].as_object() { + for (key, value) in paths { + if key == "schema_version" { + continue; + } + println!(" {:<22} {}", key, value.as_str().unwrap_or("-")); + } + } + Ok(()) +} diff --git a/src/cli/fetch.rs b/src/cli/fetch.rs new file mode 100644 index 0000000..dfe4b18 --- /dev/null +++ b/src/cli/fetch.rs @@ -0,0 +1,138 @@ +use crate::cli::args::Cli; +use crate::cli::{Context, output, template}; +use crate::config::{Config, ModuleConfig}; +use crate::error::XFetchError; +use crate::info::Info; +use crate::runtime; + +pub fn run(cli: &Cli, ctx: &Context) -> Result<(), XFetchError> { + let config = ctx.load_config(); + + let output_mode = !cli.get.is_empty() || cli.template.is_some() || cli.format.is_some(); + let daemon = cli.daemon || config.daemon || (config.daemon_live && !cli.no_daemon_live); + if daemon && (cli.json || output_mode) { + return Err(XFetchError::Usage( + "daemon mode cannot be combined with --json, --get, --template or --format." + .to_string(), + )); + } + + runtime::debug(format!( + "config: {}", + ctx.config_path + .clone() + .unwrap_or_else(|| crate::config::default_config_path().display().to_string()) + )); + runtime::debug(format!( + "layout: {}", + config.layout.as_deref().unwrap_or("default") + )); + runtime::trace(format!("configured modules: {}", config.modules.len())); + + if !cli.get.is_empty() { + let keys = cli.get.clone(); + let collect_config = config_with_modules(&config, &keys); + let (info, _) = Info::with_config(&collect_config, false); + let values = output::collect_values(&info, &collect_config, &keys); + return output::print_values(&values, &keys, cli.json); + } + + if let Some(template_source) = template_source(cli)? { + let keys = template::keys(&template_source); + let collect_config = config_with_modules(&config, &keys); + let (info, _) = Info::with_config(&collect_config, false); + let context = template::context(&info, &collect_config, &keys); + let rendered = template::render(&template_source, &context); + print!("{rendered}"); + if !rendered.ends_with('\n') { + println!(); + } + return Ok(()); + } + + if cli.json { + let (info, bench_lines) = Info::with_config(&config, cli.benchmark); + let json = output::FetchJson::from_info(&info, bench_lines); + return output::print_json(&json); + } + + let (info, bench_lines) = Info::with_config(&config, cli.benchmark); + if cli.daemon || config.daemon { + crate::ui::draw_daemon(&info, &config); + } else if config.daemon_live && !cli.no_daemon_live { + let config_path = ctx.config_path.clone().or_else(|| { + let default = crate::config::default_config_path(); + default + .exists() + .then(|| default.to_string_lossy().into_owned()) + }); + let reload = config.daemon_live_reload || cli.daemon_live_reload; + crate::ui::draw_live_daemon(&info, &config, config_path, reload); + } else { + crate::ui::draw(&info, &config); + } + + if !bench_lines.is_empty() { + println!("\n--- Benchmark -------------------------------"); + for line in &bench_lines { + println!("{}", line); + } + println!("---------------------------------------------"); + } + Ok(()) +} + +fn config_with_modules(config: &Config, keys: &[String]) -> Config { + let mut config = config.clone(); + config.modules = keys + .iter() + .filter(|key| !is_special_key(key)) + .map(|key| { + let probe = if key == "packages_breakdown" { + "packages" + } else { + key + }; + ModuleConfig::Simple(probe.to_string()) + }) + .collect(); + config +} + +fn is_special_key(key: &str) -> bool { + matches!(key, "palette" | "header" | "sep" | "version" | "name") +} + +fn template_source(cli: &Cli) -> Result, XFetchError> { + if let Some(template) = &cli.format { + return Ok(Some(template.clone())); + } + if let Some(path) = &cli.template { + let content = std::fs::read_to_string(path).map_err(|err| { + XFetchError::Config(format!("cannot read template '{}': {}", path, err)) + })?; + return Ok(Some(content)); + } + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn special_keys_are_not_probed() { + assert!(is_special_key("header")); + assert!(is_special_key("palette")); + assert!(!is_special_key("os")); + assert!(!is_special_key("packages:pacman")); + } + + #[test] + fn collect_config_only_keeps_module_keys() { + let config = Config::default(); + let keys = vec!["os".to_string(), "header".to_string(), "cpu".to_string()]; + let collected = config_with_modules(&config, &keys); + assert_eq!(collected.modules.len(), 2); + } +} diff --git a/src/cli/jsonc_edit.rs b/src/cli/jsonc_edit.rs new file mode 100644 index 0000000..e0e54d3 --- /dev/null +++ b/src/cli/jsonc_edit.rs @@ -0,0 +1,307 @@ +use std::ops::Range; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Segment { + Key(String), + Index(usize), +} + +pub fn parse_path(path: &str) -> Vec { + path.split('.') + .filter(|part| !part.is_empty()) + .map(|part| match part.parse::() { + Ok(index) => Segment::Index(index), + Err(_) => Segment::Key(part.to_string()), + }) + .collect() +} + +pub fn replace_value_preserving(text: &str, path: &[Segment], new_value: &str) -> Option { + let mut parser = Parser::new(text); + let span = parser.find(path)?; + let mut output = String::with_capacity(text.len() + new_value.len()); + output.push_str(&text[..span.start]); + output.push_str(new_value); + output.push_str(&text[span.end..]); + Some(output) +} + +struct Parser<'a> { + text: &'a str, + bytes: &'a [u8], + pos: usize, +} + +impl<'a> Parser<'a> { + fn new(text: &'a str) -> Self { + Self { + text, + bytes: text.as_bytes(), + pos: 0, + } + } + + fn peek(&self) -> Option { + self.bytes.get(self.pos).copied() + } + + fn bump(&mut self) -> Option { + let byte = self.peek()?; + self.pos += 1; + Some(byte) + } + + fn skip_ws(&mut self) { + loop { + while self.peek().is_some_and(|byte| byte.is_ascii_whitespace()) { + self.pos += 1; + } + if self.bytes.get(self.pos..self.pos + 2) == Some(b"//") { + self.pos += 2; + while let Some(byte) = self.peek() { + self.pos += 1; + if byte == b'\n' { + break; + } + } + continue; + } + if self.bytes.get(self.pos..self.pos + 2) == Some(b"/*") { + self.pos += 2; + while self.pos + 1 < self.bytes.len() { + if &self.bytes[self.pos..self.pos + 2] == b"*/" { + self.pos += 2; + break; + } + self.pos += 1; + } + continue; + } + break; + } + } + + fn parse_string(&mut self) -> Option { + if self.peek() != Some(b'"') { + return None; + } + let start = self.pos; + self.pos += 1; + while let Some(byte) = self.bump() { + match byte { + b'\\' => { + self.bump(); + } + b'"' => { + let raw = &self.text[start..self.pos]; + return serde_json::from_str::(raw).ok(); + } + _ => {} + } + } + None + } + + fn skip_string(&mut self) -> bool { + if self.peek() != Some(b'"') { + return false; + } + self.pos += 1; + while let Some(byte) = self.bump() { + match byte { + b'\\' => { + self.bump(); + } + b'"' => return true, + _ => {} + } + } + false + } + + fn skip_value(&mut self) -> bool { + self.skip_ws(); + match self.peek() { + Some(b'{') => self.skip_object(), + Some(b'[') => self.skip_array(), + Some(b'"') => self.skip_string(), + Some(_) => { + while let Some(byte) = self.peek() { + if byte.is_ascii_whitespace() || matches!(byte, b',' | b'}' | b']') { + break; + } + if byte == b'/' + && matches!(self.bytes.get(self.pos + 1), Some(b'/') | Some(b'*')) + { + break; + } + self.pos += 1; + } + true + } + None => false, + } + } + + fn skip_object(&mut self) -> bool { + if self.bump() != Some(b'{') { + return false; + } + loop { + self.skip_ws(); + if self.peek() == Some(b'}') { + self.pos += 1; + return true; + } + if !self.skip_string() { + return false; + } + self.skip_ws(); + if self.bump() != Some(b':') { + return false; + } + if !self.skip_value() { + return false; + } + self.skip_ws(); + match self.peek() { + Some(b',') => self.pos += 1, + Some(b'}') => { + self.pos += 1; + return true; + } + _ => return false, + } + } + } + + fn skip_array(&mut self) -> bool { + if self.bump() != Some(b'[') { + return false; + } + loop { + self.skip_ws(); + if self.peek() == Some(b']') { + self.pos += 1; + return true; + } + if !self.skip_value() { + return false; + } + self.skip_ws(); + match self.peek() { + Some(b',') => self.pos += 1, + Some(b']') => { + self.pos += 1; + return true; + } + _ => return false, + } + } + } + + fn find(&mut self, path: &[Segment]) -> Option> { + self.skip_ws(); + if path.is_empty() { + let start = self.pos; + if self.skip_value() { + return Some(start..self.pos); + } + return None; + } + + match &path[0] { + Segment::Key(key) => { + if self.bump() != Some(b'{') { + return None; + } + loop { + self.skip_ws(); + if self.peek() == Some(b'}') { + return None; + } + let found_key = self.parse_string()?; + self.skip_ws(); + if self.bump() != Some(b':') { + return None; + } + if found_key == *key { + return self.find(&path[1..]); + } + if !self.skip_value() { + return None; + } + self.skip_ws(); + match self.peek() { + Some(b',') => self.pos += 1, + Some(b'}') => return None, + _ => return None, + } + } + } + Segment::Index(index) => { + if self.bump() != Some(b'[') { + return None; + } + let mut current = 0usize; + loop { + self.skip_ws(); + if self.peek() == Some(b']') { + return None; + } + if current == *index { + return self.find(&path[1..]); + } + if !self.skip_value() { + return None; + } + self.skip_ws(); + match self.peek() { + Some(b',') => { + self.pos += 1; + current += 1; + } + Some(b']') => return None, + _ => return None, + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn replaces_a_nested_scalar_and_keeps_comments() { + let text = "{\n // keep\n \"colors\": {\n \"cpu\": \"red\", // note\n \"os\": \"cyan\"\n }\n}\n"; + let path = parse_path("colors.cpu"); + let output = replace_value_preserving(text, &path, "\"green\"").unwrap(); + assert!(output.contains("// keep")); + assert!(output.contains("// note")); + assert!(output.contains("\"cpu\": \"green\"")); + } + + #[test] + fn replaces_an_array_element() { + let text = "{ \"modules\": [\"os\", \"cpu\", \"memory\"] }"; + let path = parse_path("modules.1"); + let output = replace_value_preserving(text, &path, "\"kernel\"").unwrap(); + assert_eq!(output, "{ \"modules\": [\"os\", \"kernel\", \"memory\"] }"); + } + + #[test] + fn rejects_missing_paths() { + let text = "{ \"colors\": { \"cpu\": \"red\" } }"; + assert!(replace_value_preserving(text, &parse_path("colors.gpu"), "\"blue\"").is_none()); + } + + #[test] + fn handles_escaped_keys() { + let text = "{ \"a\\\"b\": 1 }"; + let output = replace_value_preserving(text, &parse_path("a\"b"), "2").unwrap(); + assert_eq!(output, "{ \"a\\\"b\": 2 }"); + } +} diff --git a/src/cli/logo_cmd.rs b/src/cli/logo_cmd.rs new file mode 100644 index 0000000..251cbb8 --- /dev/null +++ b/src/cli/logo_cmd.rs @@ -0,0 +1,83 @@ +use crate::cli::args::LogoCommands; +use crate::cli::output; +use crate::error::XFetchError; +use crate::logos; + +pub fn run(action: LogoCommands, json: bool) -> Result<(), XFetchError> { + match action { + LogoCommands::List { query } => list(query.as_deref(), json), + LogoCommands::Preview { name } => preview(&name, json), + } +} + +fn list(query: Option<&str>, json: bool) -> Result<(), XFetchError> { + let index = logos::fetch_index().map_err(XFetchError::Network)?; + let query = query.map(|query| query.to_ascii_lowercase()); + let entries: Vec<&logos::LogoEntry> = index + .logos + .iter() + .filter(|entry| { + let Some(query) = &query else { + return true; + }; + entry.id.to_ascii_lowercase().contains(query) + || entry + .aliases + .iter() + .any(|alias| alias.to_ascii_lowercase().contains(query)) + || entry.file.to_ascii_lowercase().contains(query) + }) + .collect(); + + if json { + let entries: Vec = entries + .iter() + .map(|entry| { + serde_json::json!({ + "id": entry.id, + "file": entry.file, + "aliases": entry.aliases, + }) + }) + .collect(); + return output::print_json(&serde_json::json!({ + "schema_version": output::JSON_SCHEMA_VERSION, + "logos": entries, + })); + } + + println!("{:<24} {:<40} ALIASES", "ID", "FILE"); + for entry in entries { + println!( + "{:<24} {:<40} {}", + entry.id, + entry.file, + entry.aliases.join(", ") + ); + } + Ok(()) +} + +fn preview(name: &str, json: bool) -> Result<(), XFetchError> { + let index = logos::fetch_index().map_err(XFetchError::Network)?; + let entry = logos::find_entry(&index, name).ok_or_else(|| { + XFetchError::NotFound(format!("logo '{}' was not found in the catalog", name)) + })?; + let art = logos::fetch_art(entry).map_err(XFetchError::Network)?; + + if json { + return output::print_json(&serde_json::json!({ + "schema_version": output::JSON_SCHEMA_VERSION, + "id": entry.id, + "file": entry.file, + "aliases": entry.aliases, + "art": art, + })); + } + + print!("{art}"); + if !art.ends_with('\n') { + println!(); + } + Ok(()) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..7b8818f --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,41 @@ +pub mod addon_cmd; +pub mod args; +pub mod backup_cmd; +pub mod build_info_cmd; +pub mod completions; +pub mod config_cmd; +pub mod dispatch; +pub mod doctor; +pub mod env_cmd; +pub mod fetch; +pub mod jsonc_edit; +pub mod logo_cmd; +pub mod modules; +pub mod output; +pub mod overrides; +pub mod paths_cmd; +pub mod run_cmd; +pub mod schema_cmd; +pub mod settings; +pub mod template; +pub mod theme_cmd; +pub mod uninstall_cmd; +pub mod update_cmd; +pub mod wasm_cmd; + +pub use args::Cli; +pub use dispatch::run; + +use crate::config::{Config, ConfigOverrides, load_config_with_overrides}; + +/// Per-invocation CLI state shared by the command handlers. +pub struct Context { + pub config_path: Option, + pub overrides: ConfigOverrides, +} + +impl Context { + pub fn load_config(&self) -> Config { + load_config_with_overrides(self.config_path.clone(), &self.overrides) + } +} diff --git a/src/cli/modules.rs b/src/cli/modules.rs new file mode 100644 index 0000000..e554938 --- /dev/null +++ b/src/cli/modules.rs @@ -0,0 +1,201 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Copy, Serialize)] +pub struct ModuleInfo { + pub key: &'static str, + pub description: &'static str, + pub platforms: &'static str, +} + +/// Canonical module keys accepted in `modules`. Keep this list in sync with +/// `ui::nodes::get_module_value` and the documentation tables. +pub const MODULES: &[ModuleInfo] = &[ + ModuleInfo { + key: "os", + description: "Operating system name and architecture", + platforms: "all", + }, + ModuleInfo { + key: "kernel", + description: "Kernel version", + platforms: "all", + }, + ModuleInfo { + key: "hostname", + description: "Machine hostname", + platforms: "all", + }, + ModuleInfo { + key: "host", + description: "Alias for hostname", + platforms: "all", + }, + ModuleInfo { + key: "user", + description: "Current user", + platforms: "all", + }, + ModuleInfo { + key: "shell", + description: "Login shell", + platforms: "all", + }, + ModuleInfo { + key: "terminal", + description: "Terminal emulator", + platforms: "all", + }, + ModuleInfo { + key: "wm", + description: "Desktop environment or window manager", + platforms: "linux,macos,windows", + }, + ModuleInfo { + key: "cpu", + description: "CPU model, cores and frequency", + platforms: "all", + }, + ModuleInfo { + key: "gpu", + description: "GPU model and vendor", + platforms: "all", + }, + ModuleInfo { + key: "memory", + description: "RAM usage", + platforms: "all", + }, + ModuleInfo { + key: "swap", + description: "Swap usage", + platforms: "all", + }, + ModuleInfo { + key: "disk", + description: "Disk usage", + platforms: "all", + }, + ModuleInfo { + key: "battery", + description: "Battery status", + platforms: "linux,macos,windows", + }, + ModuleInfo { + key: "uptime", + description: "System uptime", + platforms: "all", + }, + ModuleInfo { + key: "packages", + description: "Installed package count", + platforms: "all", + }, + ModuleInfo { + key: "packages:", + description: "Count for one package manager (pacman, dpkg, rpm, apk, nix, brew, flatpak, ...)", + platforms: "all", + }, + ModuleInfo { + key: "datetime", + description: "Local date and time", + platforms: "all", + }, + ModuleInfo { + key: "local_ip", + description: "Local IPv4 address", + platforms: "all", + }, + ModuleInfo { + key: "local_ip:v6", + description: "Local IPv6 address", + platforms: "all", + }, + ModuleInfo { + key: "public_ip", + description: "Public IP address (network)", + platforms: "all", + }, + ModuleInfo { + key: "interfaces", + description: "Network interface summary", + platforms: "all", + }, + ModuleInfo { + key: "palette", + description: "Terminal color palette", + platforms: "all", + }, + ModuleInfo { + key: "header", + description: "user@hostname header", + platforms: "all", + }, + ModuleInfo { + key: "sep", + description: "Separator line", + platforms: "all", + }, + ModuleInfo { + key: "plugin:", + description: "Lines returned by an info plugin", + platforms: "all", + }, +]; + +pub fn find(key: &str) -> Option<&'static ModuleInfo> { + if let Some(info) = MODULES.iter().find(|info| info.key == key) { + return Some(info); + } + if key.starts_with("packages:") && key.len() > "packages:".len() { + return MODULES.iter().find(|info| info.key == "packages:"); + } + if key.starts_with("plugin:") && key.len() > "plugin:".len() { + return MODULES.iter().find(|info| info.key == "plugin:"); + } + None +} + +pub fn is_known(key: &str) -> bool { + find(key).is_some() +} + +pub fn print_modules(json: bool) -> Result<(), crate::error::XFetchError> { + if json { + let output = serde_json::to_string_pretty(MODULES) + .map_err(|err| crate::error::XFetchError::Fatal(err.to_string()))?; + println!("{output}"); + return Ok(()); + } + + println!("{:<18} {:<20} DESCRIPTION", "KEY", "PLATFORMS"); + for module in MODULES { + println!( + "{:<18} {:<20} {}", + module.key, module.platforms, module.description + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_keys_and_patterns() { + assert!(is_known("os")); + assert!(is_known("packages:pacman")); + assert!(is_known("plugin:banner")); + assert!(!is_known("packages:")); + assert!(!is_known("plugin:")); + assert!(!is_known("nope")); + } + + #[test] + fn registry_keys_are_unique() { + let mut keys: Vec<&str> = MODULES.iter().map(|info| info.key).collect(); + keys.sort_unstable(); + keys.dedup(); + assert_eq!(keys.len(), MODULES.len()); + } +} diff --git a/src/cli/output.rs b/src/cli/output.rs new file mode 100644 index 0000000..cff3ddb --- /dev/null +++ b/src/cli/output.rs @@ -0,0 +1,259 @@ +use crate::addons::model::{AddonEntry, AddonKind}; +use crate::config::Config; +use crate::error::XFetchError; +use crate::info::Info; +use crate::runtime; +use crate::ui::nodes::{format_palette, get_module_value}; +use console::strip_ansi_codes; +use serde::Serialize; +use std::collections::BTreeMap; + +pub const JSON_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Serialize)] +pub struct PackageCount { + pub manager: String, + pub count: String, +} + +#[derive(Debug, Serialize)] +pub struct FetchJson { + pub schema_version: u32, + pub name: &'static str, + pub version: &'static str, + pub os: String, + pub kernel: String, + pub hostname: String, + pub user: String, + pub shell: String, + pub terminal: String, + pub desktop: String, + pub cpu: String, + pub gpu: Vec, + pub memory: String, + pub swap: String, + pub disks: Vec, + pub battery: String, + pub uptime: String, + pub packages: String, + pub packages_breakdown: Vec, + pub datetime: String, + pub local_ip: String, + pub local_ip_v6: String, + pub public_ip: String, + pub network_interfaces: String, + pub plugins: BTreeMap>, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub benchmark: Vec, +} + +impl FetchJson { + pub fn from_info(info: &Info, benchmark: Vec) -> Self { + let packages_breakdown = info + .packages_breakdown + .iter() + .map(|(manager, count)| PackageCount { + manager: manager.clone(), + count: count.clone(), + }) + .collect(); + let plugins: BTreeMap> = info + .plugin_info + .iter() + .map(|(key, lines)| { + ( + key.clone(), + lines + .iter() + .map(|line| strip_ansi_codes(line).to_string()) + .collect(), + ) + }) + .collect(); + + Self { + schema_version: JSON_SCHEMA_VERSION, + name: "xfetch", + version: env!("CARGO_PKG_VERSION"), + os: info.os.clone(), + kernel: info.kernel.clone(), + hostname: info.host_name.clone(), + user: info.user.clone(), + shell: info.shell.clone(), + terminal: info.terminal.clone(), + desktop: info.desktop.clone(), + cpu: info.cpu.clone(), + gpu: info.gpu.clone(), + memory: info.memory.clone(), + swap: info.swap.clone(), + disks: info.disks.clone(), + battery: info.battery.clone(), + uptime: info.uptime.clone(), + packages: info.packages.clone(), + packages_breakdown, + datetime: info.datetime.clone(), + local_ip: info.local_ip.clone(), + local_ip_v6: info.local_ip_v6.clone(), + public_ip: info.public_ip.clone(), + network_interfaces: info.network_interfaces.clone(), + plugins, + benchmark, + } + } +} + +#[derive(Debug, Serialize)] +pub struct GetJson { + pub schema_version: u32, + pub values: BTreeMap>, +} + +#[derive(Debug, Serialize)] +pub struct AddonJson { + pub kind: String, + pub name: String, + pub path: String, + pub runtime: String, + pub manifest_kind: String, + pub version: Option, + pub description: Option, + pub enabled: bool, + pub capabilities: Vec, + pub limits: Option, + pub config: Vec, + pub manifest_source: Option, + pub installed_source: Option, +} + +impl AddonJson { + pub fn from_entry(entry: &AddonEntry) -> Self { + Self { + kind: entry.kind.label().to_string(), + name: entry.name.clone(), + path: entry.path.display().to_string(), + runtime: entry.runtime.as_str().to_string(), + manifest_kind: entry.manifest_kind().to_string(), + version: entry.version().map(str::to_string), + description: entry.description().map(str::to_string), + enabled: entry.enabled, + capabilities: entry.capability_lines(), + limits: entry.limits_line(), + config: entry.config_snippet(), + manifest_source: entry.manifest_source.clone(), + installed_source: entry.receipt.as_ref().map(|receipt| receipt.source.clone()), + } + } +} + +#[derive(Debug, Serialize)] +pub struct AddonListJson { + pub schema_version: u32, + pub kind: String, + pub addons: Vec, +} + +#[derive(Debug, Serialize)] +pub struct AddonInfoJson { + pub schema_version: u32, + #[serde(flatten)] + pub addon: AddonJson, +} + +pub fn print_json(value: &T) -> Result<(), XFetchError> { + let text = serde_json::to_string_pretty(value) + .map_err(|err| XFetchError::Fatal(format!("failed to serialize JSON: {}", err)))?; + println!("{text}"); + Ok(()) +} + +pub fn value_for_key(info: &Info, config: &Config, key: &str) -> Option { + let value: Option = if key == "palette" { + Some(format_palette(config)) + } else if key == "name" { + Some("xfetch".to_string()) + } else if key == "version" { + Some(env!("CARGO_PKG_VERSION").to_string()) + } else if key == "header" { + Some(format!("{}@{}", info.user, info.host_name)) + } else if key == "sep" { + Some("---".to_string()) + } else if let Some(lines) = info.plugin_info.get(key) { + Some(lines.join("\n")) + } else { + get_module_value(info, key) + }; + + value.map(|text: String| { + if runtime::no_color() { + strip_ansi_codes(&text).to_string() + } else { + text + } + }) +} + +pub fn collect_values( + info: &Info, + config: &Config, + keys: &[String], +) -> BTreeMap> { + keys.iter() + .map(|key| (key.clone(), value_for_key(info, config, key))) + .collect() +} + +pub fn print_values( + values: &BTreeMap>, + keys: &[String], + json: bool, +) -> Result<(), XFetchError> { + if json { + let values = values + .iter() + .map(|(key, value)| { + ( + key.clone(), + value + .as_ref() + .map(|text| strip_ansi_codes(text).to_string()), + ) + }) + .collect(); + return print_json(&GetJson { + schema_version: JSON_SCHEMA_VERSION, + values, + }); + } + + if keys.len() == 1 { + if let Some(Some(value)) = values.iter().next().map(|(_, value)| value) { + println!("{value}"); + } + return Ok(()); + } + + for key in keys { + let value = values + .get(key) + .and_then(|value| value.as_deref()) + .unwrap_or(""); + let single_line = value.replace('\n', "\\n"); + println!("{key}={single_line}"); + } + Ok(()) +} + +pub fn print_addon_list_json(kind: AddonKind, entries: &[AddonEntry]) -> Result<(), XFetchError> { + print_json(&AddonListJson { + schema_version: JSON_SCHEMA_VERSION, + kind: kind.label().to_string(), + addons: entries.iter().map(AddonJson::from_entry).collect(), + }) +} + +pub fn print_addon_info_json(entry: &AddonEntry) -> Result<(), XFetchError> { + print_json(&AddonInfoJson { + schema_version: JSON_SCHEMA_VERSION, + addon: AddonJson::from_entry(entry), + }) +} diff --git a/src/cli/overrides.rs b/src/cli/overrides.rs new file mode 100644 index 0000000..1bce5e3 --- /dev/null +++ b/src/cli/overrides.rs @@ -0,0 +1,126 @@ +use crate::cli::args::Cli; +use crate::cli::modules; +use crate::config::{ConfigOverrides, ModuleConfig, resolve_theme_path}; +use crate::error::XFetchError; +use crate::runtime; + +pub fn build(cli: &Cli) -> ConfigOverrides { + let modules = cli.modules.as_ref().and_then(|list| { + let parsed: Vec = list + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(|item| ModuleConfig::Simple(item.to_string())) + .collect(); + (!parsed.is_empty()).then_some(parsed) + }); + + ConfigOverrides { + theme: cli.theme.clone(), + layout: cli.layout.clone(), + modules, + no_color: runtime::no_color(), + offline: cli.offline, + no_cache: cli.no_cache, + } +} + +pub fn validate(cli: &Cli) -> Result<(), XFetchError> { + if cli.backup && cli.restore { + return Err(XFetchError::Usage( + "use either --backup or --restore, not both.".to_string(), + )); + } + + if (cli.backup || cli.restore) && cli.command.is_some() { + return Err(XFetchError::Usage( + "--backup/--restore cannot be combined with a subcommand; use 'xfetch config backup' or 'xfetch config restore' instead.".to_string(), + )); + } + + let other_action = cli.gen_config + || cli.list_layouts + || cli.list_modules + || cli.clean_cache + || cli.daemon + || cli.daemon_stop + || cli.no_daemon_live + || cli.daemon_live_stop + || cli.daemon_live_reload + || !cli.get.is_empty() + || cli.modules.is_some() + || cli.template.is_some() + || cli.format.is_some(); + if (cli.backup || cli.restore) && other_action { + return Err(XFetchError::Usage( + "--backup/--restore cannot be combined with another action flag.".to_string(), + )); + } + + if let Some(layout) = &cli.layout + && !cli.gen_config + && !crate::ui::is_known_layout(layout) + { + return Err(XFetchError::Usage(format!( + "unknown layout '{}'. Run 'xfetch --list-layouts' to see the accepted names.", + layout + ))); + } + + if let Some(theme) = &cli.theme + && !cli.gen_config + && resolve_theme_path(theme).is_none() + { + return Err(XFetchError::Config(format!( + "theme '{}' not found. Run 'xfetch theme list' to see installed themes.", + theme + ))); + } + + if cli.modules.is_some() && build(cli).modules.is_none() { + return Err(XFetchError::Usage( + "--modules needs at least one module key.".to_string(), + )); + } + + if !cli.get.is_empty() && cli.command.is_some() { + return Err(XFetchError::Usage( + "--get only applies to the default fetch; drop the subcommand.".to_string(), + )); + } + + if cli.template.is_some() && cli.format.is_some() { + return Err(XFetchError::Usage( + "use either --template or --format, not both.".to_string(), + )); + } + + if cli.json && (cli.template.is_some() || cli.format.is_some()) { + return Err(XFetchError::Usage( + "use either --json or --template/--format, not both.".to_string(), + )); + } + + if !cli.get.is_empty() && (cli.template.is_some() || cli.format.is_some()) { + return Err(XFetchError::Usage( + "use either --get or --template/--format, not both.".to_string(), + )); + } + + if (cli.template.is_some() || cli.format.is_some()) && cli.command.is_some() { + return Err(XFetchError::Usage( + "--template/--format only apply to the default fetch; drop the subcommand.".to_string(), + )); + } + + for key in &cli.get { + if !modules::is_known(key) && !matches!(key.as_str(), "version" | "name") { + return Err(XFetchError::NotFound(format!( + "unknown module key '{}'. Run 'xfetch --list-modules'.", + key + ))); + } + } + + Ok(()) +} diff --git a/src/cli/paths_cmd.rs b/src/cli/paths_cmd.rs new file mode 100644 index 0000000..7b4b631 --- /dev/null +++ b/src/cli/paths_cmd.rs @@ -0,0 +1,52 @@ +use crate::cli::output; +use crate::config::{default_config_path, default_themes_dir}; +use crate::error::XFetchError; +use serde_json::{Value, json}; +use std::path::PathBuf; + +pub fn collect(config_path: Option) -> Value { + let config = config_path + .map(PathBuf::from) + .unwrap_or_else(default_config_path); + let config_dir = crate::config::config_dir().join("xfetch"); + let cache = dirs::cache_dir().map(|dir| dir.join("xfetch").join("cache.json")); + let data = dirs::data_dir().map(|dir| dir.join("xfetch")); + + json!({ + "schema_version": output::JSON_SCHEMA_VERSION, + "config": config.display().to_string(), + "config_dir": config_dir.display().to_string(), + "themes": default_themes_dir().display().to_string(), + "plugins": crate::plugins::default_plugin_dir().display().to_string(), + "effects": crate::effects::default_effect_dir().display().to_string(), + "extensions": crate::extensions::default_extension_dir().display().to_string(), + "backups": crate::cli::backup_cmd::default_backup_dir().display().to_string(), + "cache": cache.map(|path| path.display().to_string()), + "data": data.map(|path| path.display().to_string()), + }) +} + +pub fn run(config_path: Option, json: bool) -> Result<(), XFetchError> { + let paths = collect(config_path); + if json { + return output::print_json(&paths); + } + + let entries = [ + ("config", "config"), + ("config_dir", "config_dir"), + ("themes", "themes"), + ("plugins", "plugins"), + ("effects", "effects"), + ("extensions", "extensions"), + ("backups", "backups"), + ("cache", "cache"), + ("data", "data"), + ]; + println!("{:<14} PATH", "KEY"); + for (key, field) in entries { + let value = paths[field].as_str().unwrap_or("-"); + println!("{key:<14} {value}"); + } + Ok(()) +} diff --git a/src/cli/run_cmd.rs b/src/cli/run_cmd.rs new file mode 100644 index 0000000..4d5fc69 --- /dev/null +++ b/src/cli/run_cmd.rs @@ -0,0 +1,32 @@ +use crate::cli::{Context, output}; +use crate::error::XFetchError; + +pub fn run( + name: &str, + args: &[String], + render: bool, + json: bool, + ctx: &Context, +) -> Result<(), XFetchError> { + let result = crate::commands::run_command(name, args).map_err(XFetchError::Addon)?; + + if json { + return output::print_json(&serde_json::json!({ + "schema_version": output::JSON_SCHEMA_VERSION, + "command": name, + "args": args, + "lines": result.lines, + })); + } + + if render { + let config = ctx.load_config(); + return crate::ui::print_custom_content(&config, result.lines).map_err(XFetchError::Fatal); + } + + print!("{}", result.raw); + if !result.raw.ends_with('\n') { + println!(); + } + Ok(()) +} diff --git a/src/cli/schema_cmd.rs b/src/cli/schema_cmd.rs new file mode 100644 index 0000000..e319282 --- /dev/null +++ b/src/cli/schema_cmd.rs @@ -0,0 +1,279 @@ +use crate::cli::args::SchemaKind; +use crate::cli::output; +use crate::error::XFetchError; +use serde_json::{Value, json}; + +pub fn run(kind: SchemaKind) -> Result<(), XFetchError> { + let value = match kind { + SchemaKind::Fetch => fetch_schema(), + SchemaKind::Backup => backup_schema(), + SchemaKind::Addon => addon_schema(), + SchemaKind::Update => update_schema(), + SchemaKind::Doctor => doctor_schema(), + SchemaKind::Config => config_schema(), + SchemaKind::All => json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "xfetch CLI outputs", + "type": "object", + "properties": { + "fetch": fetch_schema(), + "backup": backup_schema(), + "addon": addon_schema(), + "update": update_schema(), + "doctor": doctor_schema(), + "config": config_schema(), + }, + }), + }; + output::print_json(&value) +} + +fn base(title: &str) -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": format!("https://xfetch-cli.github.io/web/schemas/{}.json", title.to_ascii_lowercase()), + "title": format!("xfetch {}", title), + "type": "object", + "properties": { + "schema_version": { "type": "integer", "const": 1 } + }, + "required": ["schema_version"] + }) +} + +fn fetch_schema() -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://xfetch-cli.github.io/web/schemas/fetch.json", + "title": "xfetch fetch output", + "type": "object", + "properties": { + "schema_version": { "type": "integer", "const": 1 }, + "name": { "type": "string", "const": "xfetch" }, + "version": { "type": "string" }, + "os": { "type": "string" }, + "kernel": { "type": "string" }, + "hostname": { "type": "string" }, + "user": { "type": "string" }, + "shell": { "type": "string" }, + "terminal": { "type": "string" }, + "desktop": { "type": "string" }, + "cpu": { "type": "string" }, + "gpu": { "type": "array", "items": { "type": "string" } }, + "memory": { "type": "string" }, + "swap": { "type": "string" }, + "disks": { "type": "array", "items": { "type": "string" } }, + "battery": { "type": "string" }, + "uptime": { "type": "string" }, + "packages": { "type": "string" }, + "packages_breakdown": { + "type": "array", + "items": { + "type": "object", + "properties": { + "manager": { "type": "string" }, + "count": { "type": "string" } + }, + "required": ["manager", "count"] + } + }, + "datetime": { "type": "string" }, + "local_ip": { "type": "string" }, + "local_ip_v6": { "type": "string" }, + "public_ip": { "type": "string" }, + "network_interfaces": { "type": "string" }, + "plugins": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "type": "string" } + } + }, + "benchmark": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": [ + "schema_version", "name", "version", "os", "kernel", "hostname", + "user", "shell", "terminal", "desktop", "cpu", "gpu", "memory", + "swap", "disks", "battery", "uptime", "packages", + "packages_breakdown", "datetime", "local_ip", "local_ip_v6", + "public_ip", "network_interfaces", "plugins" + ] + }) +} + +fn backup_schema() -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://xfetch-cli.github.io/web/schemas/backup.json", + "title": "xfetch backup", + "type": "object", + "properties": { + "schema_version": { "type": "integer", "const": 1 }, + "created_unix": { "type": "integer" }, + "created_unix_ms": { "type": "integer" }, + "created_utc": { "type": "string" }, + "xfetch_version": { "type": "string" }, + "config_path": { "type": "string" }, + "theme": { "type": ["string", "null"] }, + "theme_path": { "type": ["string", "null"] }, + "config_providers": { "type": "array", "items": { "type": "string" } }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { "type": "string" }, + "path": { "type": "string" }, + "name": { "type": ["string", "null"] }, + "encoding": { "type": ["string", "null"], "enum": ["base64", null] }, + "content": { "type": "string" } + }, + "required": ["role", "path", "content"] + } + }, + "missing": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { "type": "string" }, + "path": { "type": "string" }, + "reason": { "type": "string" } + }, + "required": ["role", "path", "reason"] + } + }, + "warnings": { "type": "array", "items": { "type": "string" } }, + "resolved_config": { "type": "object" } + }, + "required": [ + "schema_version", "created_unix", "created_utc", "xfetch_version", + "config_path", "config_providers", "files", "missing", "warnings", + "resolved_config" + ] + }) +} + +fn addon_schema() -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://xfetch-cli.github.io/web/schemas/addon.json", + "title": "xfetch addon output", + "type": "object", + "properties": { + "schema_version": { "type": "integer", "const": 1 }, + "kind": { "type": "string", "enum": ["plugin", "effect", "extension"] }, + "name": { "type": "string" }, + "path": { "type": "string" }, + "runtime": { "type": "string", "enum": ["native", "wasm"] }, + "manifest_kind": { "type": "string" }, + "version": { "type": ["string", "null"] }, + "description": { "type": ["string", "null"] }, + "enabled": { "type": "boolean" }, + "capabilities": { "type": "array", "items": { "type": "string" } }, + "limits": { "type": ["string", "null"] }, + "config": { "type": "array", "items": { "type": "string" } }, + "manifest_source": { "type": ["string", "null"] }, + "installed_source": { "type": ["string", "null"] }, + "addons": { "type": "array", "items": { "type": "object" } }, + "checks": { "type": "array", "items": { "type": "object" } } + }, + "required": ["schema_version"] + }) +} + +fn update_schema() -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://xfetch-cli.github.io/web/schemas/update.json", + "title": "xfetch update check output", + "type": "object", + "properties": { + "schema_version": { "type": "integer", "const": 1 }, + "status": { + "type": "string", + "enum": ["up-to-date", "update-available", "updated", "manual", "rolled-back"] + }, + "current": { "type": "string" }, + "latest": { "type": "string" }, + "method": { "type": "string" }, + "command": { "type": "string" }, + "path": { "type": "string" }, + "message": { "type": "string" }, + "update_available": { "type": "boolean" } + }, + "required": ["schema_version", "status"] + }) +} + +fn doctor_schema() -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://xfetch-cli.github.io/web/schemas/doctor.json", + "title": "xfetch doctor output", + "type": "object", + "properties": { + "schema_version": { "type": "integer", "const": 1 }, + "version": { "type": "string" }, + "platform": { + "type": "object", + "properties": { + "os": { "type": "string" }, + "arch": { "type": "string" }, + "family": { "type": "string" }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "value": { "type": "string" } + }, + "required": ["name", "value"] + } + } + }, + "required": ["os", "arch", "family", "details"] + }, + "checks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "category": { "type": "string" }, + "name": { "type": "string" }, + "status": { "type": "string", "enum": ["ok", "warn", "fail"] }, + "detail": { "type": "string" } + }, + "required": ["category", "name", "status", "detail"] + } + }, + "fixes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "action": { "type": "string" }, + "detail": { "type": "string" } + }, + "required": ["action", "detail"] + } + } + }, + "required": ["schema_version", "version", "platform", "checks", "fixes"] + }) +} + +fn config_schema() -> Value { + let _ = base("config"); + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://xfetch-cli.github.io/web/schemas/config.json", + "title": "xfetch resolved config", + "type": "object", + "additionalProperties": true + }) +} diff --git a/src/cli/settings.rs b/src/cli/settings.rs new file mode 100644 index 0000000..5009be0 --- /dev/null +++ b/src/cli/settings.rs @@ -0,0 +1,30 @@ +use crate::cli::args::{Cli, ColorChoice}; +use crate::runtime::{self, RuntimeFlags}; +use std::io::IsTerminal; + +pub fn init(cli: &Cli) { + let no_color = cli.no_color + || cli.color == ColorChoice::Never + || (cli.color == ColorChoice::Auto && !std::io::stdout().is_terminal()); + + let log_level = match cli.log_level { + Some(level) => match level { + crate::cli::args::LogLevel::Off => runtime::LogLevel::Off, + crate::cli::args::LogLevel::Error => runtime::LogLevel::Error, + crate::cli::args::LogLevel::Warn => runtime::LogLevel::Warn, + crate::cli::args::LogLevel::Info => runtime::LogLevel::Info, + crate::cli::args::LogLevel::Debug => runtime::LogLevel::Debug, + crate::cli::args::LogLevel::Trace => runtime::LogLevel::Trace, + }, + None if cli.quiet => runtime::LogLevel::Warn, + None if cli.verbose => runtime::LogLevel::Debug, + None => runtime::LogLevel::Info, + }; + + runtime::init(RuntimeFlags { + log_level, + log_json: cli.log_json, + offline: cli.offline, + no_color, + }); +} diff --git a/src/cli/template.rs b/src/cli/template.rs new file mode 100644 index 0000000..af44caa --- /dev/null +++ b/src/cli/template.rs @@ -0,0 +1,376 @@ +use crate::cli::output::value_for_key; +use crate::config::Config; +use crate::info::Info; +use std::collections::BTreeMap; + +#[derive(Debug, Clone)] +pub enum Value { + Text(String), + List(Vec), +} + +impl Value { + fn as_text(&self) -> String { + match self { + Value::Text(text) => text.clone(), + Value::List(items) => items.join(", "), + } + } + + fn is_truthy(&self) -> bool { + match self { + Value::Text(text) => { + let lower = text.trim().to_ascii_lowercase(); + !lower.is_empty() && !matches!(lower.as_str(), "false" | "0" | "unknown" | "n/a") + } + Value::List(items) => !items.is_empty(), + } + } + + fn as_list(&self) -> Vec { + match self { + Value::Text(text) if text.is_empty() => Vec::new(), + Value::Text(text) => vec![text.clone()], + Value::List(items) => items.clone(), + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct Context { + values: BTreeMap, + this: Option, + index: Option, +} + +impl Context { + pub fn new(values: BTreeMap) -> Self { + Self { + values, + this: None, + index: None, + } + } + + fn get(&self, key: &str) -> Option<&Value> { + self.values.get(key) + } + + fn truthy(&self, key: &str) -> bool { + self.get(key).is_some_and(Value::is_truthy) + } + + fn list(&self, key: &str) -> Vec { + self.get(key).map(Value::as_list).unwrap_or_default() + } + + fn child(&self, item: String, index: usize) -> Self { + let mut child = self.clone(); + child.this = Some(item); + child.index = Some(index); + child + } +} + +pub fn context(info: &Info, config: &Config, keys: &[String]) -> Context { + let mut values = BTreeMap::new(); + for key in keys { + let value = match key.as_str() { + "gpu" => Value::List(info.gpu.clone()), + "disks" => Value::List(info.disks.clone()), + "packages_breakdown" => Value::List( + info.packages_breakdown + .iter() + .map(|(manager, count)| format!("{manager}={count}")) + .collect(), + ), + key if key.starts_with("plugin:") => { + Value::List(info.plugin_info.get(key).cloned().unwrap_or_default()) + } + _ => Value::Text(value_for_key(info, config, key).unwrap_or_default()), + }; + values.insert(key.clone(), value); + } + Context::new(values) +} + +pub fn keys(template: &str) -> Vec { + let mut keys = Vec::new(); + for token in tokenize(template) { + let key = match token { + Token::Var(tag) => tag.split('|').next().unwrap_or("").trim().to_string(), + Token::If(key) | Token::Unless(key) | Token::Each(key) => key, + _ => continue, + }; + if !key.is_empty() && key != "this" && key != "@index" && !keys.contains(&key) { + keys.push(key); + } + } + keys +} + +pub fn render(template: &str, context: &Context) -> String { + render_tokens(&tokenize(template), context) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Token { + Text(String), + Var(String), + If(String), + Unless(String), + Each(String), + Else, + EndIf, + EndUnless, + EndEach, +} + +fn tokenize(template: &str) -> Vec { + let mut tokens = Vec::new(); + let mut rest = template; + while let Some(start) = rest.find("{{") { + let text = &rest[..start]; + if !text.is_empty() { + tokens.push(Token::Text(text.to_string())); + } + let after = &rest[start + 2..]; + let Some(end) = after.find("}}") else { + tokens.push(Token::Text(rest[start..].to_string())); + return tokens; + }; + let tag = after[..end].trim(); + let token = if let Some(key) = tag.strip_prefix("#if ") { + Some(Token::If(key.trim().to_string())) + } else if let Some(key) = tag.strip_prefix("#unless ") { + Some(Token::Unless(key.trim().to_string())) + } else if let Some(key) = tag.strip_prefix("#each ") { + Some(Token::Each(key.trim().to_string())) + } else if tag == "else" { + Some(Token::Else) + } else if tag == "/if" { + Some(Token::EndIf) + } else if tag == "/unless" { + Some(Token::EndUnless) + } else if tag == "/each" { + Some(Token::EndEach) + } else if tag.starts_with('!') { + None + } else { + Some(Token::Var(tag.to_string())) + }; + if let Some(token) = token { + tokens.push(token); + } + rest = &after[end + 2..]; + } + if !rest.is_empty() { + tokens.push(Token::Text(rest.to_string())); + } + tokens +} + +fn render_tokens(tokens: &[Token], context: &Context) -> String { + let mut output = String::new(); + let mut index = 0usize; + while index < tokens.len() { + match &tokens[index] { + Token::Text(text) => output.push_str(text), + Token::Var(tag) => output.push_str(&resolve(tag, context)), + Token::If(key) => { + let (else_index, end_index) = scan_block(tokens, index + 1, "if"); + let true_end = else_index.unwrap_or(end_index); + if context.truthy(key) { + output.push_str(&render_tokens(&tokens[index + 1..true_end], context)); + } else if let Some(else_index) = else_index { + output.push_str(&render_tokens(&tokens[else_index + 1..end_index], context)); + } + index = end_index; + } + Token::Unless(key) => { + let (else_index, end_index) = scan_block(tokens, index + 1, "unless"); + let true_end = else_index.unwrap_or(end_index); + if !context.truthy(key) { + output.push_str(&render_tokens(&tokens[index + 1..true_end], context)); + } else if let Some(else_index) = else_index { + output.push_str(&render_tokens(&tokens[else_index + 1..end_index], context)); + } + index = end_index; + } + Token::Each(key) => { + let (else_index, end_index) = scan_block(tokens, index + 1, "each"); + let body_end = else_index.unwrap_or(end_index); + let items = context.list(key); + if items.is_empty() { + if let Some(else_index) = else_index { + output + .push_str(&render_tokens(&tokens[else_index + 1..end_index], context)); + } + } else { + for (item_index, item) in items.iter().enumerate() { + let child = context.child(item.clone(), item_index); + output.push_str(&render_tokens(&tokens[index + 1..body_end], &child)); + } + } + index = end_index; + } + Token::Else | Token::EndIf | Token::EndUnless | Token::EndEach => {} + } + index += 1; + } + output +} + +fn scan_block(tokens: &[Token], start: usize, kind: &'static str) -> (Option, usize) { + let mut stack: Vec<&'static str> = vec![kind]; + let mut else_index = None; + let mut index = start; + while index < tokens.len() { + match &tokens[index] { + Token::If(_) => stack.push("if"), + Token::Unless(_) => stack.push("unless"), + Token::Each(_) => stack.push("each"), + Token::EndIf => { + if stack.last() == Some(&"if") { + stack.pop(); + if stack.is_empty() { + return (else_index, index); + } + } + } + Token::EndUnless => { + if stack.last() == Some(&"unless") { + stack.pop(); + if stack.is_empty() { + return (else_index, index); + } + } + } + Token::EndEach => { + if stack.last() == Some(&"each") { + stack.pop(); + if stack.is_empty() { + return (else_index, index); + } + } + } + Token::Else if stack.len() == 1 => else_index = Some(index), + _ => {} + } + index += 1; + } + (else_index, tokens.len()) +} + +fn resolve(tag: &str, context: &Context) -> String { + let mut parts = tag.split('|'); + let key = parts.next().unwrap_or("").trim(); + let mut value = if key == "this" { + context + .this + .clone() + .map(Value::Text) + .unwrap_or(Value::Text(String::new())) + } else if key == "@index" { + Value::Text( + context + .index + .map(|index| index.to_string()) + .unwrap_or_default(), + ) + } else { + context + .get(key) + .cloned() + .unwrap_or(Value::Text(String::new())) + }; + + for filter in parts { + let filter = filter.trim(); + let (name, argument) = match filter.split_once(':') { + Some((name, argument)) => (name.trim(), Some(argument.trim().trim_matches('"'))), + None => (filter, None), + }; + value = match name { + "upper" => Value::Text(value.as_text().to_uppercase()), + "lower" => Value::Text(value.as_text().to_lowercase()), + "trim" => Value::Text(value.as_text().trim().to_string()), + "count" => Value::Text(value.as_list().len().to_string()), + "join" => Value::Text(value.as_list().join(argument.unwrap_or(", "))), + "default" => { + if value.as_text().is_empty() { + Value::Text(argument.unwrap_or("").to_string()) + } else { + value + } + } + _ => value, + }; + } + + value.as_text() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn context() -> Context { + let mut values = BTreeMap::new(); + values.insert("os".to_string(), Value::Text("Arch Linux".to_string())); + values.insert("empty".to_string(), Value::Text(String::new())); + values.insert( + "gpu".to_string(), + Value::List(vec!["GPU A".to_string(), "GPU B".to_string()]), + ); + Context::new(values) + } + + #[test] + fn variables_and_filters() { + let context = context(); + assert_eq!(render("{{os}}", &context), "Arch Linux"); + assert_eq!(render("{{os|upper}}", &context), "ARCH LINUX"); + assert_eq!(render("{{os|lower}}", &context), "arch linux"); + assert_eq!( + render("{{empty|default:\"fallback\"}}", &context), + "fallback" + ); + assert_eq!(render("{{gpu|count}}", &context), "2"); + assert_eq!(render("{{gpu|join:\" / \"}}", &context), "GPU A / GPU B"); + } + + #[test] + fn conditionals() { + let context = context(); + assert_eq!(render("{{#if os}}yes{{else}}no{{/if}}", &context), "yes"); + assert_eq!(render("{{#if empty}}yes{{else}}no{{/if}}", &context), "no"); + assert_eq!(render("{{#unless empty}}yes{{/unless}}", &context), "yes"); + } + + #[test] + fn each_blocks() { + let context = context(); + assert_eq!( + render("{{#each gpu}}[{{@index}}] {{this}};{{/each}}", &context), + "[0] GPU A;[1] GPU B;" + ); + assert_eq!( + render("{{#each empty}}x{{else}}none{{/each}}", &context), + "none" + ); + } + + #[test] + fn keys_collects_all_references() { + assert_eq!( + keys("{{os}} {{#if cpu}}{{gpu}}{{/if}} {{#each disks}}{{this}}{{/each}}"), + vec![ + "os".to_string(), + "cpu".to_string(), + "gpu".to_string(), + "disks".to_string() + ] + ); + } +} diff --git a/src/cli/theme_cmd.rs b/src/cli/theme_cmd.rs new file mode 100644 index 0000000..6f3ec53 --- /dev/null +++ b/src/cli/theme_cmd.rs @@ -0,0 +1,513 @@ +use crate::cli::args::{Cli, ThemeCommands}; +use crate::cli::{Context, output}; +use crate::config::{ + default_themes_dir, load_config_with_overrides, parse_jsonc_str_strict, resolve_theme_path, +}; +use crate::error::XFetchError; +use crate::runtime; +use crate::themes::{export_current_theme, list_themes, remove_theme, set_active_theme}; +use semver::Version; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const THEMES_INDEX: &str = "https://raw.githubusercontent.com/xfetch-cli/themes/main/index.json"; +const THEME_INDEX_LIMIT: usize = 4 * 1024 * 1024; +const THEME_FILE_LIMIT: usize = 1024 * 1024; + +#[derive(Debug, Deserialize)] +struct ThemeIndex { + #[serde(default)] + registry: String, + themes: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct ThemeEntry { + name: String, + #[serde(default)] + author: String, + #[serde(default)] + version: String, + #[serde(default)] + description: String, + #[serde(default)] + layout: Option, + #[serde(default)] + palette_style: Option, + #[serde(default)] + tags: Vec, + #[serde(default)] + source: String, + #[serde(default)] + screenshot: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct ThemeMeta { + version: String, + source: String, + installed_unix: u64, +} + +pub fn run(action: ThemeCommands, cli: &Cli, ctx: &Context) -> Result<(), XFetchError> { + match action { + ThemeCommands::List => list(cli), + ThemeCommands::Search { query } => search(query.as_deref(), cli.json), + ThemeCommands::Info { name } => info(&name, cli.json), + ThemeCommands::Install { name, force } => install(&name, force, cli.json), + ThemeCommands::Update { + name, + all, + check, + force, + } => update(name.as_deref(), all, check, force, cli.json), + ThemeCommands::Set { name } => { + let config_path = ctx + .config_path + .as_ref() + .map(PathBuf::from) + .unwrap_or_else(crate::config::default_config_path); + set_active_theme(&config_path, &name).map_err(XFetchError::Config)?; + if cli.json { + return output::print_json(&serde_json::json!({ + "schema_version": output::JSON_SCHEMA_VERSION, + "status": "set", + "theme": name, + "path": config_path.display().to_string(), + })); + } + runtime::info(format!("Theme set to '{}'.", name)); + Ok(()) + } + ThemeCommands::Remove { name } => { + remove_theme(&name).map_err(XFetchError::Config)?; + if cli.json { + return output::print_json(&serde_json::json!({ + "schema_version": output::JSON_SCHEMA_VERSION, + "status": "removed", + "theme": name, + })); + } + runtime::info(format!("Theme '{}' removed.", name)); + Ok(()) + } + ThemeCommands::Export { name } => { + let config = load_config_with_overrides(ctx.config_path.clone(), &ctx.overrides); + let path = export_current_theme(&config, &name).map_err(XFetchError::Config)?; + if cli.json { + return output::print_json(&serde_json::json!({ + "schema_version": output::JSON_SCHEMA_VERSION, + "status": "exported", + "theme": name, + "path": path.display().to_string(), + })); + } + runtime::info(format!("Theme exported to {}", path.display())); + runtime::info(format!("Set it with: xfetch theme set {}", name)); + Ok(()) + } + } +} + +fn list(cli: &Cli) -> Result<(), XFetchError> { + let themes = list_themes().map_err(XFetchError::Config)?; + if cli.json { + let entries: Vec = themes + .iter() + .map(|(name, path)| { + let meta = read_meta(name); + serde_json::json!({ + "name": name, + "path": path.display().to_string(), + "version": meta.as_ref().map(|meta| meta.version.clone()), + "source": meta.as_ref().map(|meta| meta.source.clone()), + "installed_unix": meta.as_ref().map(|meta| meta.installed_unix), + }) + }) + .collect(); + return output::print_json(&serde_json::json!({ + "schema_version": output::JSON_SCHEMA_VERSION, + "themes": entries, + })); + } + if themes.is_empty() { + println!("No themes installed."); + println!( + "Theme directory: {}", + crate::config::default_themes_dir().display() + ); + } else { + println!("Available themes:"); + for (name, path) in &themes { + let version = read_meta(name) + .map(|meta| format!(" v{}", meta.version)) + .unwrap_or_default(); + println!(" {}{} ({})", name, version, path.display()); + } + } + Ok(()) +} + +fn search(query: Option<&str>, json: bool) -> Result<(), XFetchError> { + let index = fetch_index()?; + let query = query.map(|query| query.to_ascii_lowercase()); + let entries: Vec<&ThemeEntry> = index + .themes + .iter() + .filter(|entry| { + let Some(query) = &query else { + return true; + }; + entry.name.to_ascii_lowercase().contains(query) + || entry.description.to_ascii_lowercase().contains(query) + || entry + .tags + .iter() + .any(|tag| tag.to_ascii_lowercase().contains(query)) + }) + .collect(); + + if json { + return output::print_json(&serde_json::json!({ + "schema_version": output::JSON_SCHEMA_VERSION, + "themes": entries, + })); + } + + println!( + "{:<20} {:<10} {:<10} DESCRIPTION", + "NAME", "VERSION", "AUTHOR" + ); + for entry in entries { + println!( + "{:<20} {:<10} {:<10} {}", + entry.name, entry.version, entry.author, entry.description + ); + } + Ok(()) +} + +fn info(name: &str, json: bool) -> Result<(), XFetchError> { + let index = fetch_index()?; + let entry = find_entry(&index, name)?; + let meta = read_meta(&entry.name); + if json { + return output::print_json(&serde_json::json!({ + "schema_version": output::JSON_SCHEMA_VERSION, + "theme": entry, + "installed": resolve_theme_path(&entry.name).is_some(), + "installed_version": meta.as_ref().map(|meta| meta.version.clone()), + })); + } + println!("Name: {}", entry.name); + println!("Author: {}", entry.author); + println!("Version: {}", entry.version); + println!("Description: {}", entry.description); + if let Some(layout) = &entry.layout { + println!("Layout: {}", layout); + } + if let Some(style) = &entry.palette_style { + println!("Palette style: {}", style); + } + if !entry.tags.is_empty() { + println!("Tags: {}", entry.tags.join(", ")); + } + println!("Source: {}", entry.source); + println!( + "Installed: {}", + if resolve_theme_path(&entry.name).is_some() { + "yes" + } else { + "no" + } + ); + if let Some(meta) = meta { + println!("Installed version: {}", meta.version); + } + Ok(()) +} + +fn install(name: &str, force: bool, json: bool) -> Result<(), XFetchError> { + let index = fetch_index()?; + let entry = find_entry(&index, name)?.clone(); + let target = theme_path(&entry.name); + if target.exists() && !force { + return Err(XFetchError::Config(format!( + "theme '{}' is already installed; use --force to reinstall", + entry.name + ))); + } + let content = fetch_theme(&index, &entry)?; + write_theme(&target, &content)?; + write_meta(&entry).map_err(XFetchError::Config)?; + + if json { + return output::print_json(&serde_json::json!({ + "schema_version": output::JSON_SCHEMA_VERSION, + "status": "installed", + "name": entry.name, + "version": entry.version, + "path": target.display().to_string(), + })); + } + runtime::info(format!( + "Installed theme '{}' v{} to {}", + entry.name, + entry.version, + target.display() + )); + Ok(()) +} + +fn update( + name: Option<&str>, + all: bool, + check: bool, + force: bool, + json: bool, +) -> Result<(), XFetchError> { + if all && name.is_some() { + return Err(XFetchError::Usage( + "use either a theme name or --all, not both".to_string(), + )); + } + if !all && name.is_none() { + return Err(XFetchError::Usage( + "specify a theme name or use --all".to_string(), + )); + } + + let index = fetch_index()?; + let targets: Vec = if all { + list_themes() + .map_err(XFetchError::Config)? + .into_iter() + .map(|(name, _)| name) + .filter(|name| index.themes.iter().any(|entry| &entry.name == name)) + .collect() + } else { + vec![name.expect("validated above").to_string()] + }; + + let mut results = Vec::new(); + let mut any_update = false; + for target in targets { + let entry = find_entry(&index, &target)?; + let meta = read_meta(&entry.name); + let installed = meta.as_ref().map(|meta| meta.version.clone()); + let update_available = + force || installed.is_none() || version_newer(installed.as_deref(), &entry.version); + any_update |= update_available; + + if check { + results.push(serde_json::json!({ + "name": entry.name, + "installed": installed, + "available": entry.version, + "update_available": update_available, + })); + continue; + } + + if !update_available { + results.push(serde_json::json!({ + "name": entry.name, + "installed": installed, + "available": entry.version, + "update_available": false, + "status": "up-to-date", + })); + continue; + } + + let content = fetch_theme(&index, entry)?; + write_theme(&theme_path(&entry.name), &content)?; + write_meta(entry).map_err(XFetchError::Config)?; + results.push(serde_json::json!({ + "name": entry.name, + "installed": installed, + "available": entry.version, + "update_available": true, + "status": "updated", + })); + } + + if json { + output::print_json(&serde_json::json!({ + "schema_version": output::JSON_SCHEMA_VERSION, + "check": check, + "themes": results, + }))?; + } else if check { + if results.is_empty() { + runtime::info("No matching themes to check."); + } + for result in &results { + let name = result["name"].as_str().unwrap_or("?"); + let installed = result["installed"].as_str().unwrap_or("not installed"); + let available = result["available"].as_str().unwrap_or("?"); + let flag = if result["update_available"].as_bool().unwrap_or(false) { + "update available" + } else { + "up to date" + }; + println!("{name}: {installed} -> {available} ({flag})"); + } + } else { + for result in &results { + let name = result["name"].as_str().unwrap_or("?"); + let status = result["status"].as_str().unwrap_or("updated"); + runtime::info(format!("{name}: {status}")); + } + } + + if check && any_update { + return Err(XFetchError::Exit(crate::error::EXIT_GENERIC)); + } + Ok(()) +} + +fn fetch_index() -> Result { + if runtime::offline() { + return Err(XFetchError::Network( + "network access is disabled by --offline".to_string(), + )); + } + let source = std::env::var("XFETCH_THEME_INDEX").unwrap_or_else(|_| THEMES_INDEX.to_string()); + let text = fetch_text(&source, THEME_INDEX_LIMIT).map_err(XFetchError::Network)?; + serde_json::from_str(&text) + .map_err(|err| XFetchError::Network(format!("invalid theme index: {err}"))) +} + +fn fetch_theme(index: &ThemeIndex, entry: &ThemeEntry) -> Result { + if runtime::offline() { + return Err(XFetchError::Network( + "network access is disabled by --offline".to_string(), + )); + } + let source = if entry.source.starts_with("http://") || entry.source.starts_with("https://") { + entry.source.clone() + } else { + format!("{}/{}", index.registry.trim_end_matches('/'), entry.source) + }; + let content = fetch_text(&source, THEME_FILE_LIMIT).map_err(XFetchError::Network)?; + validate_theme(&content)?; + Ok(content) +} + +fn validate_theme(content: &str) -> Result<(), XFetchError> { + let value = parse_jsonc_str_strict(content).map_err(XFetchError::Config)?; + let Some(object) = value.as_object() else { + return Err(XFetchError::Config( + "theme file must contain a JSON object".to_string(), + )); + }; + if object.contains_key("icons") { + return Err(XFetchError::Config( + "theme files must not contain icons".to_string(), + )); + } + Ok(()) +} + +fn fetch_text(url: &str, limit: usize) -> Result { + let mut response = ureq::get(url) + .config() + .timeout_global(Some(Duration::from_secs(15))) + .build() + .call() + .map_err(|err| format!("failed to fetch '{}': {err}", url))?; + let status = response.status().as_u16(); + if !(200..300).contains(&status) { + return Err(format!("failed to fetch '{}': HTTP {status}", url)); + } + response + .body_mut() + .with_config() + .limit(limit as u64) + .read_to_string() + .map_err(|err| format!("failed to read '{}': {err}", url)) +} + +fn find_entry<'a>(index: &'a ThemeIndex, name: &str) -> Result<&'a ThemeEntry, XFetchError> { + index + .themes + .iter() + .find(|entry| entry.name.eq_ignore_ascii_case(name)) + .ok_or_else(|| { + XFetchError::NotFound(format!("theme '{}' was not found in the registry", name)) + }) +} + +fn theme_path(name: &str) -> PathBuf { + default_themes_dir().join(format!("{name}.jsonc")) +} + +fn meta_path(name: &str) -> PathBuf { + default_themes_dir().join(format!(".{name}.xfetch-meta.json")) +} + +fn read_meta(name: &str) -> Option { + let text = fs::read_to_string(meta_path(name)).ok()?; + serde_json::from_str(&text).ok() +} + +fn write_meta(entry: &ThemeEntry) -> Result<(), String> { + let meta = ThemeMeta { + version: entry.version.clone(), + source: entry.source.clone(), + installed_unix: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0), + }; + let text = serde_json::to_string_pretty(&meta) + .map_err(|err| format!("cannot serialize theme metadata: {err}"))?; + let path = meta_path(&entry.name); + crate::atomic_fs::write_atomic(&path, text.as_bytes()) + .map_err(|err| format!("cannot write '{}': {}", path.display(), err)) +} + +fn write_theme(path: &Path, content: &str) -> Result<(), XFetchError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| { + XFetchError::Config(format!("cannot create '{}': {}", parent.display(), err)) + })?; + } + crate::atomic_fs::write_atomic(path, content.as_bytes()) + .map_err(|err| XFetchError::Config(format!("cannot write '{}': {}", path.display(), err))) +} + +fn version_newer(installed: Option<&str>, available: &str) -> bool { + let Some(installed) = installed else { + return true; + }; + match ( + Version::parse(installed.trim_start_matches('v')), + Version::parse(available.trim_start_matches('v')), + ) { + (Ok(installed), Ok(available)) => available > installed, + _ => installed != available, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_comparison_handles_unknown_versions() { + assert!(version_newer(None, "1.0.0")); + assert!(version_newer(Some("0.9.0"), "1.0.0")); + assert!(!version_newer(Some("1.0.0"), "1.0.0")); + assert!(version_newer(Some("custom"), "1.0.0")); + } + + #[test] + fn validate_theme_rejects_icons() { + assert!(validate_theme("{\"layout\":\"section\"}").is_ok()); + assert!(validate_theme("{\"icons\":{}}").is_err()); + } +} diff --git a/src/cli/uninstall_cmd.rs b/src/cli/uninstall_cmd.rs new file mode 100644 index 0000000..080ef2c --- /dev/null +++ b/src/cli/uninstall_cmd.rs @@ -0,0 +1,109 @@ +use crate::error::XFetchError; +use crate::runtime; +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; + +pub fn run(purge: bool, yes: bool, json: bool) -> Result<(), XFetchError> { + if !yes { + if std::io::stdin().is_terminal() { + eprint!( + "Remove the xfetch binary{}? [y/N]: ", + if purge { " and its config/data" } else { "" } + ); + let mut answer = String::new(); + std::io::stdin() + .read_line(&mut answer) + .map_err(|err| XFetchError::Fatal(format!("cannot read confirmation: {err}")))?; + if !matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") { + runtime::info("Uninstall cancelled."); + return Ok(()); + } + } else { + return Err(XFetchError::Usage( + "refusing to uninstall without --yes in non-interactive mode".to_string(), + )); + } + } + + let current = std::env::current_exe() + .map_err(|err| XFetchError::Fatal(format!("cannot locate the xfetch binary: {err}")))?; + let mut removed = Vec::new(); + + if purge { + for dir in purge_dirs() { + if dir.exists() { + std::fs::remove_dir_all(&dir).map_err(|err| { + XFetchError::Fatal(format!("cannot remove '{}': {}", dir.display(), err)) + })?; + removed.push(dir.display().to_string()); + } + } + } + + let backup = backup_path(¤t); + if backup.is_file() { + let _ = std::fs::remove_file(&backup); + } + remove_binary(¤t)?; + removed.push(current.display().to_string()); + + if json { + return crate::cli::output::print_json(&serde_json::json!({ + "schema_version": crate::cli::output::JSON_SCHEMA_VERSION, + "status": "uninstalled", + "purged": purge, + "removed": removed, + })); + } + runtime::info("xfetch uninstalled."); + for path in &removed { + println!(" {path}"); + } + Ok(()) +} + +fn purge_dirs() -> Vec { + let mut dirs = Vec::new(); + dirs.push(crate::config::config_dir().join("xfetch")); + if let Some(cache) = dirs::cache_dir() { + dirs.push(cache.join("xfetch")); + } + if let Some(data) = dirs::data_dir() { + dirs.push(data.join("xfetch")); + } + dirs +} + +fn backup_path(path: &Path) -> PathBuf { + path.with_file_name(format!( + "{}.bak", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("xfetch") + )) +} + +#[cfg(unix)] +fn remove_binary(path: &Path) -> Result<(), XFetchError> { + std::fs::remove_file(path) + .map_err(|err| XFetchError::Fatal(format!("cannot remove '{}': {}", path.display(), err))) +} + +#[cfg(windows)] +fn remove_binary(path: &Path) -> Result<(), XFetchError> { + let command = format!( + "ping 127.0.0.1 -n 3 >NUL & del /F /Q \"{}\"", + path.display() + ); + std::process::Command::new("cmd") + .args(["/C", &command]) + .spawn() + .map(|_| ()) + .map_err(|err| XFetchError::Fatal(format!("cannot schedule removal: {err}"))) +} + +#[cfg(not(any(unix, windows)))] +fn remove_binary(path: &Path) -> Result<(), XFetchError> { + std::fs::remove_file(path) + .map_err(|err| XFetchError::Fatal(format!("cannot remove '{}': {}", path.display(), err))) +} diff --git a/src/cli/update_cmd.rs b/src/cli/update_cmd.rs new file mode 100644 index 0000000..d14fe02 --- /dev/null +++ b/src/cli/update_cmd.rs @@ -0,0 +1,142 @@ +use crate::cli::args::Cli; +use crate::error::XFetchError; + +#[cfg(feature = "update")] +use crate::cli::output; +#[cfg(feature = "update")] +use crate::runtime; +#[cfg(feature = "update")] +use std::path::PathBuf; + +#[cfg(feature = "update")] +pub fn run( + check: bool, + prebuilt: bool, + bin_dir: Option, + yes: bool, + rollback: bool, + cli: &Cli, +) -> Result<(), XFetchError> { + if rollback { + if check || prebuilt { + return Err(XFetchError::Usage( + "--rollback cannot be combined with --check or --prebuilt".to_string(), + )); + } + let path = + crate::update::rollback(bin_dir.map(PathBuf::from)).map_err(XFetchError::Update)?; + if cli.json { + return output::print_json(&serde_json::json!({ + "schema_version": output::JSON_SCHEMA_VERSION, + "status": "rolled-back", + "path": path.display().to_string(), + })); + } + runtime::info(format!("Rolled back xfetch binary: {}", path.display())); + return Ok(()); + } + + if cli.json && !check { + return Err(XFetchError::Usage( + "--json for 'update' requires --check.".to_string(), + )); + } + + let options = crate::update::UpdateOptions { + check, + prebuilt, + bin_dir: bin_dir.map(PathBuf::from), + yes, + }; + let outcome = crate::update::run(options).map_err(XFetchError::Update)?; + + if cli.json { + use crate::update::UpdateOutcome; + let value = match &outcome { + UpdateOutcome::UpToDate { current, latest } => serde_json::json!({ + "schema_version": output::JSON_SCHEMA_VERSION, + "status": "up-to-date", + "current": current.to_string(), + "latest": latest.to_string(), + "update_available": false, + }), + UpdateOutcome::UpdateAvailable { + current, + latest, + method, + command, + } => serde_json::json!({ + "schema_version": output::JSON_SCHEMA_VERSION, + "status": "update-available", + "current": current.to_string(), + "latest": latest.to_string(), + "method": method, + "command": command, + "update_available": true, + }), + UpdateOutcome::Updated { from, to, path } => serde_json::json!({ + "schema_version": output::JSON_SCHEMA_VERSION, + "status": "updated", + "current": from.to_string(), + "latest": to.to_string(), + "path": path.display().to_string(), + "update_available": false, + }), + UpdateOutcome::Manual { message } => serde_json::json!({ + "schema_version": output::JSON_SCHEMA_VERSION, + "status": "manual", + "message": message, + "update_available": false, + }), + }; + output::print_json(&value)?; + if check && matches!(outcome, UpdateOutcome::UpdateAvailable { .. }) { + return Err(XFetchError::Exit(crate::error::EXIT_GENERIC)); + } + return Ok(()); + } + + use crate::update::UpdateOutcome; + match outcome { + UpdateOutcome::UpToDate { current, latest } => { + runtime::info(format!( + "xfetch {} is up to date (latest v{}).", + current, latest + )); + } + UpdateOutcome::UpdateAvailable { + current, + latest, + method, + command, + } => { + println!("xfetch {} -> v{} available ({}).", current, latest, method); + println!(" {}", command); + return Err(XFetchError::Exit(crate::error::EXIT_GENERIC)); + } + UpdateOutcome::Updated { from, to, path } => { + runtime::info(format!( + "Updated xfetch {} -> {} ({})", + from, + to, + path.display() + )); + } + UpdateOutcome::Manual { message } => println!("{}", message), + } + Ok(()) +} + +#[cfg(not(feature = "update"))] +pub fn run( + _check: bool, + _prebuilt: bool, + _bin_dir: Option, + _yes: bool, + _rollback: bool, + _cli: &Cli, +) -> Result<(), XFetchError> { + Err(XFetchError::Update( + "This xfetch binary was built without the 'update' feature.".to_string(), + )) +} diff --git a/src/cli/wasm_cmd.rs b/src/cli/wasm_cmd.rs new file mode 100644 index 0000000..51c2ecb --- /dev/null +++ b/src/cli/wasm_cmd.rs @@ -0,0 +1,51 @@ +use crate::cli::args::{Cli, WasmCommands}; +use crate::error::XFetchError; + +pub fn run(action: WasmCommands, cli: &Cli) -> Result<(), XFetchError> { + match action { + WasmCommands::Inspect { path } => { + crate::wasm::inspect::inspect(std::path::Path::new(&path), cli.json) + .map_err(XFetchError::Addon) + } + WasmCommands::Run { + path, + request, + request_file, + timeout, + kind, + } => { + let request = match (request, request_file) { + (Some(_), Some(_)) => { + Err("Use either --request or --request_file, not both".to_string()) + } + (Some(request), None) => Ok(request.into_bytes()), + (None, Some(file)) => std::fs::read(&file) + .map_err(|err| format!("Failed to read '{}': {}", file, err)), + (None, None) => Ok(b"{}".to_vec()), + } + .map_err(XFetchError::Usage)?; + + let kind = match kind.as_str() { + "effect" => crate::wasm::GuestKind::Effect, + "extension" => crate::wasm::GuestKind::Extension, + "command" => crate::wasm::GuestKind::Command, + _ => crate::wasm::GuestKind::Plugin, + }; + let timeout = timeout.map(std::time::Duration::from_secs); + + let response = + crate::wasm::run_request(std::path::Path::new(&path), &request, timeout, kind) + .map_err(XFetchError::Addon)?; + use std::io::Write; + let stdout = std::io::stdout(); + let mut stdout = stdout.lock(); + let _ = stdout.write_all(&response); + let _ = stdout.write_all(b"\n"); + Ok(()) + } + WasmCommands::Wit => { + crate::wasm::inspect::print_wit(); + Ok(()) + } + } +} diff --git a/src/commands/install.rs b/src/commands/install.rs new file mode 100644 index 0000000..23ecd64 --- /dev/null +++ b/src/commands/install.rs @@ -0,0 +1,184 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::commands::{COMMAND_PREFIX, default_command_dir, default_command_repo}; +use crate::wasm::install::SourceAction; + +fn is_url(value: &str) -> bool { + value.starts_with("http://") || value.starts_with("https://") +} + +fn has_path_separator(name: &str) -> bool { + name.contains('/') || name.contains('\\') +} + +fn command_name_from_wasm_file(path: &Path) -> Option { + let stem = path.file_stem()?.to_str()?; + let name = stem.strip_prefix(COMMAND_PREFIX).unwrap_or(stem); + (!name.is_empty()).then(|| name.to_string()) +} + +pub fn install_command( + name_or_path: &str, + repo: Option<&str>, + sha256: Option<&str>, +) -> Result<(), String> { + if is_url(name_or_path) { + let name = crate::wasm::install::name_from_path(name_or_path) + .ok_or_else(|| format!("Cannot derive a command name from URL '{name_or_path}'"))?; + crate::install_support::validate_guest_name(&name).map_err(|err| { + format!("Invalid command name '{name}' derived from URL '{name_or_path}': {err}") + })?; + return crate::wasm::install::download_and_install( + name_or_path, + None, + sha256, + &default_command_dir(), + COMMAND_PREFIX, + &name, + "command", + ); + } + + let direct = PathBuf::from(name_or_path); + if direct.is_file() && crate::wasm::is_wasm_file(&direct) { + let name = command_name_from_wasm_file(&direct) + .ok_or_else(|| format!("Cannot derive a command name from '{name_or_path}'"))?; + return crate::wasm::install::install_file( + &direct, + sha256, + &default_command_dir(), + COMMAND_PREFIX, + &name, + "command", + ); + } + + if direct.is_file() && sha256.is_some() { + return Err(format!( + "'{}' is not a WebAssembly artifact; --sha256 cannot be enforced for a native command", + direct.display() + )); + } + + match resolve_local_command_dir(name_or_path) { + Ok(directory) => build_and_install_command(&directory, sha256), + Err(_) if repo.is_some() || has_path_separator(name_or_path) => { + let default_repo = default_command_repo(); + install_remote_command(name_or_path, repo.unwrap_or(&default_repo), sha256) + } + Err(_) => { + let default_repo = default_command_repo(); + install_remote_command(name_or_path, &default_repo, sha256) + } + } +} + +fn resolve_local_command_dir(path: &str) -> Result { + let candidate = PathBuf::from(path); + if candidate.is_dir() { + return Ok(candidate); + } + + if let Ok(cwd) = env::current_dir() { + let mut search = vec![ + cwd.join(path), + cwd.join("commands").join(path), + cwd.join("commands").join("commands").join(path), + ]; + if let Some(parent) = cwd.parent() { + search.push(parent.join("commands").join(path)); + search.push(parent.join("commands").join("commands").join(path)); + } + for candidate in search { + if candidate.is_dir() { + return Ok(candidate); + } + } + } + + Err(format!("Command not found locally: '{path}'")) +} + +fn build_and_install_command(directory: &Path, sha256: Option<&str>) -> Result<(), String> { + let name = directory + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| "Invalid command directory name".to_string())?; + + match crate::wasm::install::plan(directory, "command", name)? { + SourceAction::Install(plan) => crate::wasm::install::install( + plan, + sha256, + &default_command_dir(), + COMMAND_PREFIX, + name, + "command", + ), + SourceAction::Download { url, manifest } => crate::wasm::install::download_and_install( + &url, + manifest, + sha256, + &default_command_dir(), + COMMAND_PREFIX, + name, + "command", + ), + SourceAction::BuildThenInstall { command } => { + crate::wasm::install::run_build(directory, &command)?; + match crate::wasm::install::plan(directory, "command", name)? { + SourceAction::Install(plan) => crate::wasm::install::install( + plan, + sha256, + &default_command_dir(), + COMMAND_PREFIX, + name, + "command", + ), + _ => Err(format!( + "Build command in '{}' did not produce a wasm artifact", + directory.display() + )), + } + } + SourceAction::NotWasm => Err(format!( + "Command source '{}' is not a WebAssembly artifact; command addons must be wasm", + directory.display() + )), + } +} + +fn install_remote_command(name: &str, repo_url: &str, sha256: Option<&str>) -> Result<(), String> { + crate::install_support::validate_repo(repo_url)?; + let work_dir = crate::install_support::temp_work_dir(COMMAND_PREFIX, name)?; + let temp_dir = work_dir.join("repo"); + + let repo_display = repo_url.trim_end_matches(".git"); + println!("Fetching command '{name}' from {repo_display}..."); + + if let Err(err) = crate::install_support::clone_repository(repo_url, &temp_dir, "command") { + let _ = fs::remove_dir_all(&work_dir); + return Err(err); + } + + let command_path = [ + temp_dir.join(name), + temp_dir.join("commands").join(name), + temp_dir.join("commands").join("commands").join(name), + ] + .into_iter() + .find(|path| path.is_dir()); + + let Some(command_path) = command_path else { + let _ = fs::remove_dir_all(&work_dir); + return Err(format!( + "Command '{name}' not found in repository '{repo_display}'.\n\ + Available commands live in the repository root or under commands/commands" + )); + }; + + let result = build_and_install_command(&command_path, sha256); + let _ = fs::remove_dir_all(&work_dir); + result +} diff --git a/src/commands/manage.rs b/src/commands/manage.rs new file mode 100644 index 0000000..c55f837 --- /dev/null +++ b/src/commands/manage.rs @@ -0,0 +1,119 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::addons::model::AddonKind; +use crate::commands::{ + COMMAND_PREFIX, EXE_EXT, command_binary_name, command_manifest_name, command_wasm_name, + default_command_dir, +}; + +pub fn list_commands() -> Result, String> { + let mut commands = Vec::new(); + let directory = default_command_dir(); + if directory.is_dir() { + let entries = fs::read_dir(&directory) + .map_err(|err| format!("Failed to read commands directory: {err}"))?; + for entry in entries { + let entry = entry.map_err(|err| format!("Failed to read entry: {err}"))?; + let path = entry.path(); + if let Some(name) = extract_command_name(&path) { + commands.push((name, path)); + } + } + } + commands.sort_by(|left, right| left.0.cmp(&right.0)); + Ok(commands) +} + +pub fn remove_command(name: &str) -> Result<(), String> { + let kind = AddonKind::Command; + let directory = default_command_dir(); + let native = directory.join(command_binary_name(name)); + let wasm = directory.join(command_wasm_name(name)); + let manifest = directory.join(command_manifest_name(name)); + let receipt = kind.receipt_path(name); + let candidates = [ + native.clone(), + wasm.clone(), + manifest.clone(), + receipt.clone(), + kind.backup_path(&native), + kind.backup_path(&wasm), + kind.backup_path(&manifest), + kind.backup_path(&receipt), + ]; + + let mut removed = false; + for path in &candidates { + if path.is_file() { + fs::remove_file(path) + .map_err(|err| format!("Failed to remove command '{name}': {err}"))?; + removed = true; + } + } + if removed { + Ok(()) + } else { + Err(format!( + "Command '{name}' is not installed (not found in {})", + directory.display() + )) + } +} + +pub fn find_command_binary(name: &str) -> Option { + let direct = Path::new(name); + if direct.is_file() { + return Some(direct.to_path_buf()); + } + + let directory = default_command_dir(); + for candidate in [ + directory.join(command_binary_name(name)), + directory.join(command_wasm_name(name)), + ] { + if candidate.is_file() { + return Some(candidate); + } + } + + if let Ok(dev_dir) = env::var("XFETCH_COMMAND_DEV_DIR") { + let dev = PathBuf::from(dev_dir); + for candidate in [ + dev.join(command_binary_name(name)), + dev.join(command_wasm_name(name)), + ] { + if candidate.is_file() { + return Some(candidate); + } + } + } + + if let Ok(path) = env::var("PATH") { + for directory in env::split_paths(&path) { + for binary in [command_binary_name(name), command_wasm_name(name)] { + let candidate = directory.join(binary); + if candidate.is_file() { + return Some(candidate); + } + } + } + } + + None +} + +fn extract_command_name(path: &Path) -> Option { + let filename = path.file_name()?.to_str()?; + let name = filename.strip_prefix(COMMAND_PREFIX)?; + if name.ends_with(".json") || name.ends_with(".bak") { + return None; + } + let name = name.strip_suffix(".wasm").unwrap_or(name); + if cfg!(target_os = "windows") { + name.strip_suffix(EXE_EXT).map(str::to_string) + } else { + Some(name.to_string()) + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..52558f1 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,54 @@ +//! Official command addons: installable CLI commands. +//! +//! Command addons are WebAssembly (or native) guests that receive a JSON +//! request with positional arguments and return plain text. The core prints +//! the text as-is unless the user requests a layout. + +pub mod install; +pub mod manage; +pub mod run; + +use std::env; +use std::path::PathBuf; + +use crate::config::config_dir; + +pub use install::install_command; +pub use manage::{find_command_binary, list_commands, remove_command}; +pub use run::run_command; + +const COMMAND_PREFIX: &str = "xfetch-command-"; +const EXE_EXT: &str = ".exe"; +const CONFIG_DIR_NAME: &str = "xfetch"; +const COMMAND_DIR_NAME: &str = "commands"; +const ENV_COMMAND_REPO: &str = "XFETCH_COMMAND_REPO"; + +/// Default repository for official command addons. +pub const DEFAULT_COMMAND_REPO: &str = "https://github.com/xfetch-cli/commands.git"; + +pub fn default_command_dir() -> PathBuf { + config_dir().join(CONFIG_DIR_NAME).join(COMMAND_DIR_NAME) +} + +pub fn default_command_repo() -> String { + env::var(ENV_COMMAND_REPO).unwrap_or_else(|_| DEFAULT_COMMAND_REPO.to_string()) +} + +/// Native executable name for `name`. +pub fn command_binary_name(name: &str) -> String { + if cfg!(target_os = "windows") { + format!("{COMMAND_PREFIX}{name}{EXE_EXT}") + } else { + format!("{COMMAND_PREFIX}{name}") + } +} + +/// Installed wasm artifact name: `xfetch-command-.wasm`. +pub fn command_wasm_name(name: &str) -> String { + format!("{}.wasm", command_binary_name(name)) +} + +/// Sidecar manifest name next to the wasm artifact. +pub fn command_manifest_name(name: &str) -> String { + format!("{}.json", command_binary_name(name)) +} diff --git a/src/commands/run.rs b/src/commands/run.rs new file mode 100644 index 0000000..38c1fae --- /dev/null +++ b/src/commands/run.rs @@ -0,0 +1,69 @@ +use std::path::Path; +use std::time::Duration; + +use crate::addons::model::AddonKind; +use crate::commands::find_command_binary; +use crate::wasm::GuestKind; +use crate::wasm::manifest; + +/// Plain-text output returned by a command addon. +#[derive(Debug, Clone)] +pub struct CommandOutput { + pub lines: Vec, + pub raw: String, +} + +impl CommandOutput { + fn from_bytes(bytes: &[u8]) -> Self { + let raw = String::from_utf8_lossy(bytes).to_string(); + let lines = raw.lines().map(str::to_string).collect(); + Self { lines, raw } + } +} + +/// Runs an installed command addon with positional arguments. +pub fn run_command(name: &str, args: &[String]) -> Result { + let path = find_command_binary(name) + .or_else(|| AddonKind::Command.installed_path(name)) + .ok_or_else(|| format!("Command '{name}' is not installed"))?; + + let request = serde_json::json!({ + "version": 1, + "kind": "command", + "command": name, + "args": args, + "cwd": std::env::current_dir().ok().map(|path| path.display().to_string()), + "env": {}, + }); + let request = serde_json::to_vec(&request) + .map_err(|err| format!("Failed to serialize command request: {err}"))?; + + if crate::wasm::is_wasm_file(&path) { + let output = crate::wasm::run_request(&path, &request, None, GuestKind::Command)?; + return Ok(CommandOutput::from_bytes(&output)); + } + + let timeout = manifest::load(&path) + .ok() + .map(|loaded| Duration::from_millis(loaded.manifest.limits.timeout_ms)) + .unwrap_or_else(|| Duration::from_secs(30)); + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let output = crate::subprocess::run_cmd_with_stdin_timeout( + Path::new(&path), + &arg_refs, + Some(&request), + Some(timeout), + ) + .ok_or_else(|| format!("Failed to run command '{}'", path.display()))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if stderr.is_empty() { + format!("Command '{name}' exited with status {}", output.status) + } else { + stderr + }); + } + + Ok(CommandOutput::from_bytes(&output.stdout)) +} diff --git a/src/config.rs b/src/config.rs index c6536e0..a1f9f1e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,6 +26,9 @@ pub struct Config { pub logo_path: Option, pub modules: Vec, pub show_colors: bool, + /// Runtime-only color switch used by `--color never`/`--no-color`. + #[serde(skip)] + pub no_color: bool, pub icons: HashMap, pub colors: HashMap, /// Optional per-module display labels for the row key shown in a line. @@ -89,6 +92,48 @@ pub struct Config { pub custom_x: Option, } +/// Runtime overrides applied on top of the file, the theme and any config +/// providers. CLI flags always win. +#[derive(Debug, Clone, Default)] +pub struct ConfigOverrides { + pub theme: Option, + pub layout: Option, + pub modules: Option>, + pub no_color: bool, + pub offline: bool, + pub no_cache: bool, +} + +impl ConfigOverrides { + /// Applies the fields that must be visible before the theme is resolved. + pub fn apply_before_theme(&self, value: &mut Value) { + if let Some(theme) = &self.theme + && let Some(object) = value.as_object_mut() + { + object.insert("theme".to_string(), Value::String(theme.clone())); + } + } + + /// Applies the remaining fields after the theme and config providers. + pub fn apply(&self, config: &mut Config) { + if let Some(layout) = &self.layout { + config.layout = Some(layout.clone()); + } + if let Some(modules) = &self.modules { + config.modules = modules.clone(); + } + if self.no_color { + config.no_color = true; + } + if self.offline { + config.disable_ip_fetching = Some(true); + } + if self.no_cache { + config.disable_cache = Some(true); + } + } +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(untagged)] pub enum FramePaths { @@ -216,6 +261,7 @@ impl Default for Config { ModuleConfig::Simple("battery".to_string()), ], show_colors: true, + no_color: false, icons, colors, labels: HashMap::new(), @@ -268,6 +314,30 @@ fn parse_jsonc_str(content: &str) -> Option { serde_json::from_str(&clean).ok() } +/// Strict JSONC parsing used by `xfetch config validate`. +pub fn parse_jsonc_file_strict(path: &Path) -> Result { + let content = fs::read_to_string(path) + .map_err(|err| format!("cannot read {}: {}", path.display(), err))?; + parse_jsonc_str_strict(&content) +} + +pub fn parse_jsonc_str_strict(content: &str) -> Result { + let content = content.strip_prefix('\u{feff}').unwrap_or(content); + let mut stripped = StripComments::new(content.as_bytes()); + let mut clean = String::new(); + stripped + .read_to_string(&mut clean) + .map_err(|err| format!("failed to strip comments: {}", err))?; + serde_json::from_str(&clean).map_err(|err| { + format!( + "invalid JSON at line {}, column {}: {}", + err.line(), + err.column(), + err + ) + }) +} + fn deep_merge(base: &mut Value, overlay: &Value) { match (base, overlay) { (Value::Object(base), Value::Object(overlay)) => { @@ -330,21 +400,25 @@ pub fn resolve_theme_path(theme: &str) -> Option { } pub fn load_config(path: Option) -> Config { + load_config_with_overrides(path, &ConfigOverrides::default()) +} + +pub fn load_config_with_overrides(path: Option, overrides: &ConfigOverrides) -> Config { let config_path = if let Some(p) = path { PathBuf::from(p) } else { default_config_path() }; - if !config_path.exists() { - return Config::default(); - } - - let config_value = match parse_jsonc_file(&config_path) { - Some(v) => v, - None => return Config::default(), + let mut config_value = if config_path.exists() { + parse_jsonc_file(&config_path) + .unwrap_or_else(|| serde_json::to_value(Config::default()).unwrap_or_default()) + } else { + serde_json::to_value(Config::default()).unwrap_or_default() }; + overrides.apply_before_theme(&mut config_value); + let mut config: Config = serde_json::from_value(config_value.clone()).unwrap_or_default(); if let Some(ref theme_name) = config.theme.clone() @@ -375,9 +449,28 @@ pub fn load_config(path: Option) -> Config { config = serde_json::from_value(current).unwrap_or(config); } + overrides.apply(&mut config); config } +/// Writes the built-in defaults as a JSONC config file. +pub fn write_default_config(path: &Path) -> Result<(), String> { + let value = serde_json::to_value(Config::default()) + .map_err(|err| format!("cannot serialize defaults: {err}"))?; + let mut text = String::from("// xfetch configuration\n"); + text.push_str( + &serde_json::to_string_pretty(&value) + .map_err(|err| format!("cannot serialize defaults: {err}"))?, + ); + text.push('\n'); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("cannot create '{}': {}", parent.display(), err))?; + } + crate::atomic_fs::write_atomic(path, text.as_bytes()) + .map_err(|err| format!("cannot write '{}': {}", path.display(), err)) +} + pub fn default_config_path() -> PathBuf { config_dir().join("xfetch").join("config.jsonc") } @@ -569,6 +662,42 @@ mod tests { assert_eq!(config.info_plugins[0].timeout_secs, None); } + #[test] + fn test_config_overrides_apply_after_theme() { + let mut config = Config::default(); + let overrides = ConfigOverrides { + theme: None, + layout: Some("right".to_string()), + modules: Some(vec![ModuleConfig::Simple("os".to_string())]), + no_color: true, + offline: true, + no_cache: true, + }; + overrides.apply(&mut config); + assert_eq!(config.layout.as_deref(), Some("right")); + assert_eq!(config.modules.len(), 1); + assert!(config.no_color); + assert_eq!(config.disable_ip_fetching, Some(true)); + assert_eq!(config.disable_cache, Some(true)); + } + + #[test] + fn test_config_overrides_apply_theme_before_merge() { + let mut value = serde_json::json!({"theme": "old"}); + let overrides = ConfigOverrides { + theme: Some("new".to_string()), + ..ConfigOverrides::default() + }; + overrides.apply_before_theme(&mut value); + assert_eq!(value["theme"], "new"); + } + + #[test] + fn test_strict_jsonc_reports_line_and_column() { + let error = parse_jsonc_str_strict("{ \"layout\": ").expect_err("must fail"); + assert!(error.contains("line"), "{error}"); + assert!(error.contains("column"), "{error}"); + } #[test] fn test_parse_jsonc_strips_utf8_bom() { // PowerShell 5.1 `Set-Content -Encoding UTF8` writes the BOM. diff --git a/src/effects/manage.rs b/src/effects/manage.rs index 3ff71ba..0102ad4 100644 --- a/src/effects/manage.rs +++ b/src/effects/manage.rs @@ -1,3 +1,4 @@ +use crate::addons::model::AddonKind; use crate::effects::{ default_effect_dir, effect_binary_name, effect_manifest_name, effect_wasm_name, extract_effect_name, @@ -6,13 +7,24 @@ use std::fs; use std::path::PathBuf; /// Removes an effect regardless of its runtime: the native binary, the wasm -/// artifact and the wasm sidecar manifest are all cleaned up. +/// artifact, the wasm sidecar manifest, the install receipt and the update +/// backups are all cleaned up. pub fn remove_effect(name: &str) -> Result<(), String> { let effect_dir = default_effect_dir(); + let kind = AddonKind::Effect; + let native = effect_dir.join(effect_binary_name(name)); + let wasm = effect_dir.join(effect_wasm_name(name)); + let manifest = effect_dir.join(effect_manifest_name(name)); + let receipt = kind.receipt_path(name); let candidates = [ - effect_dir.join(effect_binary_name(name)), - effect_dir.join(effect_wasm_name(name)), - effect_dir.join(effect_manifest_name(name)), + native.clone(), + wasm.clone(), + manifest.clone(), + receipt.clone(), + kind.backup_path(&native), + kind.backup_path(&wasm), + kind.backup_path(&manifest), + kind.backup_path(&receipt), ]; let mut removed = false; @@ -25,7 +37,6 @@ pub fn remove_effect(name: &str) -> Result<(), String> { } if removed { - println!("Removed effect '{}'", name); Ok(()) } else { Err(format!( diff --git a/src/effects/mod.rs b/src/effects/mod.rs index 9ab830a..04c7f94 100644 --- a/src/effects/mod.rs +++ b/src/effects/mod.rs @@ -66,7 +66,7 @@ fn extract_effect_name(path: &Path) -> Option { let filename = path.file_name()?.to_str()?; let name = filename.strip_prefix(EFFECT_PREFIX)?; // Sidecar manifests are not effects. - if name.ends_with(".json") { + if name.ends_with(".json") || name.ends_with(".bak") { return None; } let name = name.strip_suffix(".wasm").unwrap_or(name); @@ -229,6 +229,18 @@ mod tests { } } + #[test] + fn test_receipts_and_backups_are_not_effects() { + assert_eq!( + extract_effect_name(Path::new("xfetch-effect-decrypt.install.json")), + None + ); + assert_eq!( + extract_effect_name(Path::new("xfetch-effect-decrypt.bak")), + None + ); + } + #[test] fn test_find_missing_effect_returns_none() { assert!(find_effect_binary("definitely_not_an_effect_xyz").is_none()); diff --git a/src/error.rs b/src/error.rs index 9c4e06b..b374ae0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,17 +1,59 @@ //! Top-level error handling of the CLI. //! -//! `run()` in `main.rs` returns a `Result` and every failure funnels through -//! `exit_on_error`, the single handler that prints and exits with code 1. -//! Each variant carries the full message; `Display` restores the exact -//! prefixes the user-facing output expects. +//! `run()` returns a `Result` and every failure funnels through +//! `exit_on_error`, the single handler that prints and exits with the code +//! associated with the failure kind. +//! +//! Exit codes are stable for scripts: +//! - 0: success +//! - 1: generic failure +//! - 2: usage error (clap) +//! - 3: configuration error +//! - 4: network/offline error +//! - 5: addon error +//! - 6: update error +//! - 7: not found use std::fmt; +pub const EXIT_GENERIC: i32 = 1; +pub const EXIT_USAGE: i32 = 2; +pub const EXIT_CONFIG: i32 = 3; +pub const EXIT_NETWORK: i32 = 4; +pub const EXIT_ADDON: i32 = 5; +pub const EXIT_UPDATE: i32 = 6; +pub const EXIT_NOT_FOUND: i32 = 7; + #[derive(Debug)] pub enum XFetchError { Fatal(String), CleanCache(String), GenConfig(String), + Usage(String), + Config(String), + Network(String), + Addon(String), + Update(String), + NotFound(String), + /// Exit with a specific code without printing an extra message. + Exit(i32), +} + +impl XFetchError { + pub fn code(&self) -> i32 { + match self { + XFetchError::Fatal(_) => EXIT_GENERIC, + XFetchError::CleanCache(_) => EXIT_GENERIC, + XFetchError::GenConfig(_) => EXIT_CONFIG, + XFetchError::Usage(_) => EXIT_USAGE, + XFetchError::Config(_) => EXIT_CONFIG, + XFetchError::Network(_) => EXIT_NETWORK, + XFetchError::Addon(_) => EXIT_ADDON, + XFetchError::Update(_) => EXIT_UPDATE, + XFetchError::NotFound(_) => EXIT_NOT_FOUND, + XFetchError::Exit(code) => *code, + } + } } impl fmt::Display for XFetchError { @@ -20,17 +62,26 @@ impl fmt::Display for XFetchError { XFetchError::Fatal(msg) => write!(f, "Error: {}", msg), XFetchError::CleanCache(msg) => write!(f, "Failed to clean cache: {}", msg), XFetchError::GenConfig(msg) => write!(f, "Failed to generate config: {}", msg), + XFetchError::Usage(msg) => write!(f, "Error: {}", msg), + XFetchError::Config(msg) => write!(f, "Config error: {}", msg), + XFetchError::Network(msg) => write!(f, "Network error: {}", msg), + XFetchError::Addon(msg) => write!(f, "Addon error: {}", msg), + XFetchError::Update(msg) => write!(f, "Update error: {}", msg), + XFetchError::NotFound(msg) => write!(f, "Not found: {}", msg), + XFetchError::Exit(_) => Ok(()), } } } impl std::error::Error for XFetchError {} -/// Prints the error (with its prefix) and exits 1. The only error handler in -/// the binary: every `run()` failure funnels through here. +/// Prints the error (with its prefix) and exits with the matching code. pub fn exit_on_error(result: Result<(), XFetchError>) { if let Err(err) = result { - eprintln!("{err}"); - std::process::exit(1); + let message = err.to_string(); + if !message.is_empty() { + crate::runtime::error(message); + } + std::process::exit(err.code()); } } diff --git a/src/extensions/manage.rs b/src/extensions/manage.rs index 61fc87d..a17cb62 100644 --- a/src/extensions/manage.rs +++ b/src/extensions/manage.rs @@ -1,3 +1,4 @@ +use crate::addons::model::AddonKind; use crate::extensions::{ default_extension_dir, extension_binary_name, extension_manifest_name, extension_wasm_name, extract_extension_name, @@ -5,14 +6,25 @@ use crate::extensions::{ use std::fs; use std::path::PathBuf; -/// Removes an extension regardless of its runtime: the native binary, the -/// wasm artifact and the wasm sidecar manifest are all cleaned up. +/// Removes an extension regardless of its runtime: the native binary, the wasm +/// artifact, the wasm sidecar manifest, the install receipt and the update +/// backups are all cleaned up. pub fn remove_extension(name: &str) -> Result<(), String> { let ext_dir = default_extension_dir(); + let kind = AddonKind::Extension; + let native = ext_dir.join(extension_binary_name(name)); + let wasm = ext_dir.join(extension_wasm_name(name)); + let manifest = ext_dir.join(extension_manifest_name(name)); + let receipt = kind.receipt_path(name); let candidates = [ - ext_dir.join(extension_binary_name(name)), - ext_dir.join(extension_wasm_name(name)), - ext_dir.join(extension_manifest_name(name)), + native.clone(), + wasm.clone(), + manifest.clone(), + receipt.clone(), + kind.backup_path(&native), + kind.backup_path(&wasm), + kind.backup_path(&manifest), + kind.backup_path(&receipt), ]; let mut removed = false; @@ -25,7 +37,6 @@ pub fn remove_extension(name: &str) -> Result<(), String> { } if removed { - println!("Removed extension '{}'", name); Ok(()) } else { Err(format!( diff --git a/src/extensions/mod.rs b/src/extensions/mod.rs index c037c41..c71fabf 100644 --- a/src/extensions/mod.rs +++ b/src/extensions/mod.rs @@ -43,7 +43,7 @@ fn extract_extension_name(path: &Path) -> Option { let filename = path.file_name()?.to_str()?; let name = filename.strip_prefix(EXTENSION_PREFIX)?; // Sidecar manifests are not extensions. - if name.ends_with(".json") { + if name.ends_with(".json") || name.ends_with(".bak") { return None; } let name = name.strip_suffix(".wasm").unwrap_or(name); @@ -135,6 +135,18 @@ mod tests { assert_eq!(extract_extension_name(path), None); } + #[test] + fn test_receipts_and_backups_are_not_extensions() { + assert_eq!( + extract_extension_name(Path::new("xfetch-extension-night.install.json")), + None + ); + assert_eq!( + extract_extension_name(Path::new("xfetch-extension-night.bak")), + None + ); + } + #[test] fn test_extension_binary_name() { let name = extension_binary_name("test"); diff --git a/src/logos.rs b/src/logos.rs index 4f6c839..7af4459 100644 --- a/src/logos.rs +++ b/src/logos.rs @@ -63,6 +63,32 @@ pub fn resolve_entry<'a>( }) } +pub fn find_entry<'a>(index: &'a LogoIndex, name: &str) -> Option<&'a LogoEntry> { + index.logos.iter().find(|entry| entry_matches(entry, name)) +} + +pub fn fetch_index() -> Result { + if crate::runtime::offline() { + return Err("network access is disabled by --offline".to_string()); + } + let text = fetch_text(&format!("{}/logos.json", logos_base_url())) + .ok_or_else(|| "failed to fetch the logo index".to_string())?; + serde_json::from_str(&text).map_err(|err| format!("invalid logo index: {err}")) +} + +pub fn fetch_art(entry: &LogoEntry) -> Result { + if crate::runtime::offline() { + return Err("network access is disabled by --offline".to_string()); + } + let art = fetch_text(&format!("{}/{}", logos_base_url(), entry.file)) + .ok_or_else(|| format!("failed to fetch '{}'", entry.file))?; + if validate_art(&art) { + Ok(art) + } else { + Err(format!("'{}' is not a valid logo", entry.file)) + } +} + fn fetch_text(url: &str) -> Option { let output = run_cmd_with_timeout("curl", &["-fsS", "--max-time", "10", url], FETCH_TIMEOUT)?; if !output.status.success() { @@ -89,6 +115,9 @@ fn validate_art(art: &str) -> bool { /// the detected OS/distro otherwise. Returns `(resolved entry id, art)`; /// `None` on any failure (callers fall back to the default logo). pub fn fetch_distro_logo(logo_override: Option<&str>) -> Option<(String, String)> { + if crate::runtime::offline() { + return None; + } let (detected_id, id_like) = crate::info::platform::detect_os_ids(); let (query_id, query_like) = match logo_override { Some(override_id) => (override_id, Vec::new()), diff --git a/src/main.rs b/src/main.rs index dbff34b..f82e82c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod addons; mod atomic_fs; mod cache; mod cli; +mod commands; mod config; mod effects; mod error; @@ -11,6 +12,7 @@ mod info; mod install_support; mod logos; mod plugins; +mod runtime; mod subprocess; mod themes; mod ui; @@ -18,414 +20,9 @@ mod ui; mod update; mod wasm; -use crate::addons::install::infer_name; -use crate::addons::model::{AddonEntry, AddonKind}; -use crate::config::{generate_config, load_config}; -use crate::effects::{install_effect, list_effects, remove_effect}; -use crate::extensions::{install_extension, list_extensions, remove_extension}; -use crate::info::Info; -use crate::plugins::{install_plugin, list_plugins, remove_plugin}; -use crate::themes::{export_current_theme, list_themes, remove_theme, set_active_theme}; -use crate::ui::draw; use clap::Parser; -use cli::{ - Cli, Commands, EffectCommands, ExtensionCommands, PluginCommands, ThemeCommands, WasmCommands, -}; -use std::path::PathBuf; fn main() { - let cli = Cli::parse(); - error::exit_on_error(run(cli)); -} - -fn run(cli: Cli) -> Result<(), error::XFetchError> { - if cli.list_layouts { - for name in crate::ui::LAYOUT_NAMES { - println!("{name}"); - } - return Ok(()); - } - - if cli.clean_cache { - return cache::clean() - .map_err(|err| error::XFetchError::CleanCache(err.to_string())) - .map(|()| println!("Cache cleaned.")); - } - - if cli.daemon_stop { - if crate::ui::stop_daemon() { - println!("Daemon stopped."); - } else { - println!("No daemon running."); - } - return Ok(()); - } - - if cli.daemon_live_stop { - if crate::ui::stop_live_daemon() { - println!("Live daemon stopped."); - } else { - println!("No live daemon running."); - } - return Ok(()); - } - - if cli.gen_config { - return generate_config( - cli.config.clone(), - cli.logo.as_deref(), - cli.layout.as_deref(), - ) - .map_err(|err| error::XFetchError::GenConfig(err.to_string())) - .map(|path| { - println!("Generated config: {}", path.display()); - println!("Run xfetch to see the new layout."); - }); - } - - match cli.command { - Some(Commands::Plugin { action }) => match action { - PluginCommands::Install { - path, - repo, - sha256, - name, - manifest, - } => { - let direct = name.is_some() || manifest.is_some(); - let installed = if direct { - if repo.is_some() { - return Err(error::XFetchError::Fatal( - "--name/--manifest apply to direct wasm files or URLs; omit --repo" - .to_string(), - )); - } - addons::install::install_direct( - AddonKind::Plugin, - &path, - sha256.as_deref(), - name.as_deref(), - manifest.as_deref(), - ) - .map_err(error::XFetchError::Fatal)? - } else { - install_plugin(&path, repo.as_deref(), sha256.as_deref()) - .map_err(error::XFetchError::Fatal)?; - infer_name(AddonKind::Plugin, &path, None).map_err(error::XFetchError::Fatal)? - }; - addons::report::print_install_guidance(AddonKind::Plugin, &installed); - Ok(()) - } - PluginCommands::List { verbose } => { - let installed = list_plugins().map_err(error::XFetchError::Fatal)?; - let config = load_config(cli.config.clone()); - let entries = installed - .into_iter() - .map(|(name, path)| AddonEntry::load(AddonKind::Plugin, &name, &path, &config)) - .collect::>(); - addons::report::print_list(AddonKind::Plugin, &entries, verbose); - Ok(()) - } - PluginCommands::Info { name } => { - let path = AddonKind::Plugin - .installed_path(&name) - .or_else(|| plugins::find_plugin_binary(&name)) - .ok_or_else(|| { - error::XFetchError::Fatal(format!("Plugin '{}' is not installed.", name)) - })?; - let config = load_config(cli.config.clone()); - let entry = AddonEntry::load(AddonKind::Plugin, &name, &path, &config); - addons::report::print_info(&entry); - Ok(()) - } - PluginCommands::Search { query, json } => { - addons::catalog::print_search(AddonKind::Plugin, query.as_deref(), json) - .map_err(error::XFetchError::Fatal) - } - PluginCommands::Remove { name } => { - remove_plugin(&name).map_err(error::XFetchError::Fatal) - } - }, - Some(Commands::Extension { action }) => match action { - ExtensionCommands::Install { - path, - repo, - sha256, - name, - manifest, - } => { - let direct = name.is_some() || manifest.is_some(); - let installed = if direct { - if repo.is_some() { - return Err(error::XFetchError::Fatal( - "--name/--manifest apply to direct wasm files or URLs; omit --repo" - .to_string(), - )); - } - addons::install::install_direct( - AddonKind::Extension, - &path, - sha256.as_deref(), - name.as_deref(), - manifest.as_deref(), - ) - .map_err(error::XFetchError::Fatal)? - } else { - install_extension(&path, repo.as_deref(), sha256.as_deref()) - .map_err(error::XFetchError::Fatal)?; - infer_name(AddonKind::Extension, &path, None) - .map_err(error::XFetchError::Fatal)? - }; - addons::report::print_install_guidance(AddonKind::Extension, &installed); - Ok(()) - } - ExtensionCommands::List { verbose } => { - let installed = list_extensions().map_err(error::XFetchError::Fatal)?; - let config = load_config(cli.config.clone()); - let entries = installed - .into_iter() - .map(|(name, path)| { - AddonEntry::load(AddonKind::Extension, &name, &path, &config) - }) - .collect::>(); - addons::report::print_list(AddonKind::Extension, &entries, verbose); - Ok(()) - } - ExtensionCommands::Info { name } => { - let path = AddonKind::Extension - .installed_path(&name) - .or_else(|| extensions::find_extension_binary(&name)) - .ok_or_else(|| { - error::XFetchError::Fatal(format!("Extension '{}' is not installed.", name)) - })?; - let config = load_config(cli.config.clone()); - let entry = AddonEntry::load(AddonKind::Extension, &name, &path, &config); - addons::report::print_info(&entry); - Ok(()) - } - ExtensionCommands::Search { query, json } => { - addons::catalog::print_search(AddonKind::Extension, query.as_deref(), json) - .map_err(error::XFetchError::Fatal) - } - ExtensionCommands::Remove { name } => { - remove_extension(&name).map_err(error::XFetchError::Fatal) - } - }, - Some(Commands::Theme { action }) => match action { - ThemeCommands::List => list_themes() - .map_err(error::XFetchError::Fatal) - .map(|themes| { - if themes.is_empty() { - println!("No themes installed."); - println!( - "Theme directory: {}", - config::default_themes_dir().display() - ); - } else { - println!("Available themes:"); - for (name, path) in &themes { - println!(" {} ({})", name, path.display()); - } - } - }), - ThemeCommands::Set { name } => { - let config_path = cli - .config - .as_ref() - .map(PathBuf::from) - .unwrap_or_else(config::default_config_path); - - set_active_theme(&config_path, &name) - .map_err(error::XFetchError::Fatal) - .map(|()| println!("Theme set to '{}'.", name)) - } - ThemeCommands::Remove { name } => remove_theme(&name) - .map_err(error::XFetchError::Fatal) - .map(|()| println!("Theme '{}' removed.", name)), - ThemeCommands::Export { name } => { - let config = load_config(cli.config); - export_current_theme(&config, &name) - .map_err(error::XFetchError::Fatal) - .map(|path| { - println!("Theme exported to {}", path.display()); - println!("Set it with: xfetch theme set {}", name); - }) - } - }, - Some(Commands::Update { - check, - prebuilt, - bin_dir, - yes, - }) => { - #[cfg(feature = "update")] - { - let options = update::UpdateOptions { - check, - prebuilt, - bin_dir: bin_dir.map(PathBuf::from), - yes, - }; - let outcome = update::run(options).map_err(error::XFetchError::Fatal)?; - match outcome { - update::UpdateOutcome::UpToDate { current, latest } => { - println!("xfetch {} is up to date (latest v{}).", current, latest); - } - update::UpdateOutcome::UpdateAvailable { - current, - latest, - method, - command, - } => { - println!("xfetch {} -> v{} available ({}).", current, latest, method); - println!(" {}", command); - std::process::exit(1); - } - update::UpdateOutcome::Updated { from, to, path } => { - println!("Updated xfetch {} -> {} ({})", from, to, path.display()); - } - update::UpdateOutcome::Manual { message } => println!("{}", message), - } - Ok(()) - } - #[cfg(not(feature = "update"))] - { - let _ = (check, prebuilt, bin_dir, yes); - Err(error::XFetchError::Fatal( - "This xfetch binary was built without the 'update' feature.".to_string(), - )) - } - } - Some(Commands::Wasm { action }) => match action { - WasmCommands::Inspect { path, json } => { - wasm::inspect::inspect(std::path::Path::new(&path), json) - .map_err(error::XFetchError::Fatal) - } - WasmCommands::Run { - path, - request, - request_file, - timeout, - kind, - } => { - let request = match (request, request_file) { - (Some(_), Some(_)) => { - Err("Use either --request or --request_file, not both".to_string()) - } - (Some(request), None) => Ok(request.into_bytes()), - (None, Some(file)) => std::fs::read(&file) - .map_err(|err| format!("Failed to read '{}': {}", file, err)), - (None, None) => Ok(b"{}".to_vec()), - } - .map_err(error::XFetchError::Fatal)?; - - let kind = match kind.as_str() { - "effect" => wasm::GuestKind::Effect, - "extension" => wasm::GuestKind::Extension, - _ => wasm::GuestKind::Plugin, - }; - let timeout = timeout.map(std::time::Duration::from_secs); - - wasm::run_request(std::path::Path::new(&path), &request, timeout, kind) - .map_err(error::XFetchError::Fatal) - .map(|response| { - use std::io::Write; - let stdout = std::io::stdout(); - let mut stdout = stdout.lock(); - let _ = stdout.write_all(&response); - let _ = stdout.write_all(b"\n"); - }) - } - WasmCommands::Wit => { - wasm::inspect::print_wit(); - Ok(()) - } - }, - Some(Commands::Effects { action }) => match action { - EffectCommands::Install { - path, - repo, - sha256, - name, - manifest, - } => { - let direct = name.is_some() || manifest.is_some(); - let installed = if direct { - if repo.is_some() { - return Err(error::XFetchError::Fatal( - "--name/--manifest apply to direct wasm files or URLs; omit --repo" - .to_string(), - )); - } - addons::install::install_direct( - AddonKind::Effect, - &path, - sha256.as_deref(), - name.as_deref(), - manifest.as_deref(), - ) - .map_err(error::XFetchError::Fatal)? - } else { - install_effect(&path, repo.as_deref(), sha256.as_deref()) - .map_err(error::XFetchError::Fatal)?; - infer_name(AddonKind::Effect, &path, None).map_err(error::XFetchError::Fatal)? - }; - addons::report::print_install_guidance(AddonKind::Effect, &installed); - Ok(()) - } - EffectCommands::List { verbose } => { - let installed = list_effects().map_err(error::XFetchError::Fatal)?; - let config = load_config(cli.config.clone()); - let entries = installed - .into_iter() - .map(|(name, path)| AddonEntry::load(AddonKind::Effect, &name, &path, &config)) - .collect::>(); - addons::report::print_list(AddonKind::Effect, &entries, verbose); - Ok(()) - } - EffectCommands::Info { name } => { - let path = AddonKind::Effect - .installed_path(&name) - .or_else(|| effects::find_effect_binary(&name)) - .ok_or_else(|| { - error::XFetchError::Fatal(format!("Effect '{}' is not installed.", name)) - })?; - let config = load_config(cli.config.clone()); - let entry = AddonEntry::load(AddonKind::Effect, &name, &path, &config); - addons::report::print_info(&entry); - Ok(()) - } - EffectCommands::Search { query, json } => { - addons::catalog::print_search(AddonKind::Effect, query.as_deref(), json) - .map_err(error::XFetchError::Fatal) - } - EffectCommands::Remove { name } => { - remove_effect(&name).map_err(error::XFetchError::Fatal) - } - }, - None => { - let config = load_config(cli.config.clone()); - let (info, bench_lines) = Info::with_config(&config, cli.benchmark); - if cli.daemon || config.daemon { - crate::ui::draw_daemon(&info, &config); - } else if config.daemon_live && !cli.no_daemon_live { - let config_path = cli.config.clone().or_else(|| { - let d = config::default_config_path(); - d.exists().then(|| d.to_string_lossy().into_owned()) - }); - let reload = config.daemon_live_reload || cli.daemon_live_reload; - crate::ui::draw_live_daemon(&info, &config, config_path, reload); - } else { - draw(&info, &config); - } - if !bench_lines.is_empty() { - println!("\n--- Benchmark -------------------------------"); - for line in &bench_lines { - println!("{}", line); - } - println!("---------------------------------------------"); - } - Ok(()) - } - } + let cli = cli::Cli::parse(); + error::exit_on_error(cli::run(cli)); } diff --git a/src/plugins/manage.rs b/src/plugins/manage.rs index 4abbd4f..b6efe21 100644 --- a/src/plugins/manage.rs +++ b/src/plugins/manage.rs @@ -1,3 +1,4 @@ +use crate::addons::model::AddonKind; use crate::plugins::{ default_plugin_dir, extract_plugin_name, plugin_binary_name, plugin_manifest_name, plugin_wasm_name, @@ -6,13 +7,24 @@ use std::fs; use std::path::PathBuf; /// Removes a plugin regardless of its runtime: the native binary, the wasm -/// artifact and the wasm sidecar manifest are all cleaned up. +/// artifact, the wasm sidecar manifest, the install receipt and the update +/// backups are all cleaned up. pub fn remove_plugin(name: &str) -> Result<(), String> { let plugin_dir = default_plugin_dir(); + let kind = AddonKind::Plugin; + let native = plugin_dir.join(plugin_binary_name(name)); + let wasm = plugin_dir.join(plugin_wasm_name(name)); + let manifest = plugin_dir.join(plugin_manifest_name(name)); + let receipt = kind.receipt_path(name); let candidates = [ - plugin_dir.join(plugin_binary_name(name)), - plugin_dir.join(plugin_wasm_name(name)), - plugin_dir.join(plugin_manifest_name(name)), + native.clone(), + wasm.clone(), + manifest.clone(), + receipt.clone(), + kind.backup_path(&native), + kind.backup_path(&wasm), + kind.backup_path(&manifest), + kind.backup_path(&receipt), ]; let mut removed = false; @@ -25,7 +37,6 @@ pub fn remove_plugin(name: &str) -> Result<(), String> { } if removed { - println!("Removed plugin '{}'", name); Ok(()) } else { Err(format!( diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index ddf469f..fd03f45 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -58,7 +58,7 @@ fn extract_plugin_name(path: &Path) -> Option { let filename = path.file_name()?.to_str()?; let name = filename.strip_prefix(PLUGIN_PREFIX)?; // Sidecar manifests are not plugins. - if name.ends_with(".json") { + if name.ends_with(".json") || name.ends_with(".bak") { return None; } let name = name.strip_suffix(".wasm").unwrap_or(name); @@ -238,6 +238,18 @@ mod tests { assert_eq!(extract_plugin_name(path), None); } + #[test] + fn test_receipts_and_backups_are_not_plugins() { + assert_eq!( + extract_plugin_name(Path::new("xfetch-plugin-weather.install.json")), + None + ); + assert_eq!( + extract_plugin_name(Path::new("xfetch-plugin-weather.bak")), + None + ); + } + #[test] fn test_extract_plugin_name_invalid_utf8() { let path = Path::new("/tmp/random-file"); diff --git a/src/runtime.rs b/src/runtime.rs new file mode 100644 index 0000000..d353688 --- /dev/null +++ b/src/runtime.rs @@ -0,0 +1,124 @@ +//! Process-wide runtime flags shared by the CLI, addons and network probes. +//! +//! The flags are set once from the parsed CLI and are read-only afterwards. + +use std::sync::OnceLock; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] +pub enum LogLevel { + Off, + Error, + Warn, + #[default] + Info, + Debug, + Trace, +} + +impl LogLevel { + pub const fn as_str(self) -> &'static str { + match self { + LogLevel::Off => "off", + LogLevel::Error => "error", + LogLevel::Warn => "warn", + LogLevel::Info => "info", + LogLevel::Debug => "debug", + LogLevel::Trace => "trace", + } + } + + pub const fn includes(self, level: LogLevel) -> bool { + self as u8 >= level as u8 + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct RuntimeFlags { + pub log_level: LogLevel, + pub log_json: bool, + pub offline: bool, + pub no_color: bool, +} + +static FLAGS: OnceLock = OnceLock::new(); + +pub fn init(flags: RuntimeFlags) { + let _ = FLAGS.set(flags); +} + +pub fn flags() -> &'static RuntimeFlags { + FLAGS.get_or_init(RuntimeFlags::default) +} + +pub fn log_level() -> LogLevel { + flags().log_level +} + +pub fn log_json() -> bool { + flags().log_json +} + +pub fn offline() -> bool { + flags().offline +} + +pub fn no_color() -> bool { + flags().no_color +} + +pub fn quiet() -> bool { + !flags().log_level.includes(LogLevel::Info) +} + +pub fn verbose() -> bool { + flags().log_level.includes(LogLevel::Debug) +} + +/// Emits a log line at `level`. Errors are always emitted; other levels are +/// filtered by `--log-level`. `--log-json` writes JSON lines to stderr. +pub fn log_at(level: LogLevel, message: impl AsRef) { + let flags = flags(); + if level == LogLevel::Off { + return; + } + if level != LogLevel::Error && level > flags.log_level { + return; + } + + let message = message.as_ref(); + if flags.log_json { + let line = serde_json::json!({ + "level": level.as_str(), + "target": "xfetch", + "message": message, + }); + eprintln!("{line}"); + return; + } + + if level == LogLevel::Info { + println!("{message}"); + } else { + eprintln!("{message}"); + } +} + +pub fn trace(message: impl AsRef) { + log_at(LogLevel::Trace, message); +} + +pub fn debug(message: impl AsRef) { + log_at(LogLevel::Debug, message); +} + +pub fn info(message: impl AsRef) { + log_at(LogLevel::Info, message); +} + +pub fn warn(message: impl AsRef) { + log_at(LogLevel::Warn, message); +} + +pub fn error(message: impl AsRef) { + log_at(LogLevel::Error, message); +} diff --git a/src/themes/mod.rs b/src/themes/mod.rs index 1e2c9aa..e8bb1ed 100644 --- a/src/themes/mod.rs +++ b/src/themes/mod.rs @@ -35,8 +35,11 @@ pub fn list_themes() -> Result, String> { pub fn remove_theme(name: &str) -> Result<(), String> { if let Some(theme_path) = resolve_theme_path(name) { if theme_path.starts_with(default_themes_dir()) { - fs::remove_file(&theme_path) - .map_err(|e| format!("Failed to remove theme '{}': {}", name, e)) + let result = fs::remove_file(&theme_path) + .map_err(|e| format!("Failed to remove theme '{}': {}", name, e)); + let meta = default_themes_dir().join(format!(".{name}.xfetch-meta.json")); + let _ = fs::remove_file(meta); + result } else { Err(format!( "Theme '{}' is in a protected location and cannot be removed", diff --git a/src/ui.rs b/src/ui.rs index b9dea7a..0ee4b8f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -12,7 +12,7 @@ mod layout; #[cfg(unix)] mod live; mod logo; -mod nodes; +pub(crate) mod nodes; mod print; mod renders; #[cfg(windows)] @@ -21,6 +21,98 @@ mod x; #[cfg(unix)] pub use daemon::{draw_daemon, stop_daemon}; pub use layout::{LAYOUT_NAMES, is_known_layout}; +pub(crate) use logo::LogoDiagnostics; + +pub(crate) fn logo_diagnostics(config: &Config, term_width: usize) -> LogoDiagnostics { + logo::diagnostics(config, term_width) +} + +/// Renders arbitrary command output lines through the configured layout and +/// logo. Used by `xfetch run --render`. +pub(crate) fn print_custom_content( + config: &Config, + content_lines: Vec, +) -> Result<(), String> { + let content_lines = if config.no_color { + content_lines + .iter() + .map(|line| strip_ansi_codes(line).to_string()) + .collect() + } else { + content_lines + }; + + if layout::is_minimal(config.layout.as_ref()) { + for line in &content_lines { + println!("{line}"); + } + return Ok(()); + } + + let right = layout::is_right(config.layout.as_ref()); + let term_width = crossterm::terminal::size() + .map(|(width, _)| width as usize) + .unwrap_or(80); + let term_width = if term_width == 0 { 80 } else { term_width }; + let (ascii_lines, image_printed, ascii_width, image_height) = + if right && logo::is_image_logo(config) { + logo::get_logo_data_right(config, term_width) + } else { + logo::get_logo_data(config) + }; + + if layout::is_vertical(config.layout.as_ref()) { + print::print_stacked_output( + ascii_lines, + image_printed, + image_height, + content_lines, + config, + !layout::is_bottom(config.layout.as_ref()), + ); + } else if right { + print::print_output_right( + ascii_lines, + image_printed, + ascii_width, + image_height, + content_lines, + config, + false, + ); + } else { + print::print_output( + ascii_lines, + image_printed, + ascii_width, + image_height, + content_lines, + config, + false, + ); + } + Ok(()) +} + +/// Prints only the configured logo. Used by `xfetch doctor --image`. +pub(crate) fn preview_logo(config: &Config) -> Result<(), String> { + let term_width = crossterm::terminal::size() + .map(|(width, _)| width as usize) + .unwrap_or(80); + let term_width = if term_width == 0 { 80 } else { term_width }; + let (ascii_lines, image_printed, _width, _height) = + if config.layout.as_deref() == Some("right") && logo::is_image_logo(config) { + logo::get_logo_data_right(config, term_width) + } else { + logo::get_logo_data(config) + }; + if !image_printed { + for line in ascii_lines { + println!("{line}"); + } + } + Ok(()) +} #[cfg(unix)] pub use live::{draw_live_daemon, stop_live_daemon}; #[cfg(windows)] @@ -60,25 +152,42 @@ pub fn draw(info: &Info, config: &Config) { let nodes = prepare_render_tree(info, &config.modules, config); - let (ascii_lines, image_printed, ascii_width, image_height) = logo::get_logo_data(config); - + let right = layout::is_right(config.layout.as_ref()); let term_width = crossterm::terminal::size() .map(|(w, _)| w as usize) .unwrap_or(80); + let term_width = if term_width == 0 { 80 } else { term_width }; + let (mut ascii_lines, image_printed, ascii_width, image_height) = + if right && logo::is_image_logo(config) { + logo::get_logo_data_right(config, term_width) + } else { + logo::get_logo_data(config) + }; let gap_base = config.logo_gap.unwrap_or(12) as usize; let gap = console::measure_text_width(print::LOGO_INFO_GAP) + gap_base; let mut available_width = term_width.saturating_sub(ascii_width + gap); - if available_width < 10 && term_width > 40 { + if !right && available_width < 10 && term_width > 40 { available_width = term_width.saturating_sub(ascii_width.max(12)); } - let content_lines = layout::get_content_lines(&nodes, config, Some(available_width)); + let mut content_lines = layout::get_content_lines(&nodes, config, Some(available_width)); + + if config.no_color { + ascii_lines = ascii_lines + .iter() + .map(|line| strip_ansi_codes(line).to_string()) + .collect(); + content_lines = content_lines + .iter() + .map(|line| strip_ansi_codes(line).to_string()) + .collect(); + } // Intro effects over the content lines (opt-in; skipped when an effect // binary is missing or fails, so the plain fetch is unaffected). Multiple // effects play in sequence. let effects = config.effects_list(); - if !image_printed && stdout().is_terminal() && !effects.is_empty() { + if !right && !image_printed && stdout().is_terminal() && !effects.is_empty() { let mut played: Vec> = Vec::new(); for effect_cfg in &effects { match crate::effects::run_effect(effect_cfg, &content_lines) { @@ -95,6 +204,17 @@ pub fn draw(info: &Info, config: &Config) { } } if !played.is_empty() { + if config.no_color { + for frames in &mut played { + for frame in frames { + frame.lines = frame + .lines + .iter() + .map(|line| strip_ansi_codes(line).to_string()) + .collect(); + } + } + } let (logo_frames, _) = logo::build_logo_frames(config, &ascii_lines, image_printed); let force_plain = logo_frames.len() > 1; print::print_effect_output( @@ -109,7 +229,8 @@ pub fn draw(info: &Info, config: &Config) { } } - if !image_printed + if !right + && !image_printed && !ascii_lines.is_empty() && let Some(animation_config) = &config.logo_animation && let Some(plugin_name) = animation_config.plugin.as_deref() @@ -120,7 +241,7 @@ pub fn draw(info: &Info, config: &Config) { run_logo_animation_plugin(plugin_name, animation_config, &ascii_lines, frame_sets) { logo::apply_logo_style(&mut frames, config); - if !config.show_colors { + if !config.show_colors || config.no_color { for frame in &mut frames { frame.lines = frame .lines @@ -162,6 +283,19 @@ pub fn draw(info: &Info, config: &Config) { return; } + if right { + print::print_output_right( + ascii_lines, + image_printed, + ascii_width, + image_height, + content_lines, + config, + false, + ); + return; + } + print::print_output( ascii_lines, image_printed, diff --git a/src/ui/daemon.rs b/src/ui/daemon.rs index 8deef01..e134f60 100644 --- a/src/ui/daemon.rs +++ b/src/ui/daemon.rs @@ -109,7 +109,13 @@ fn prepare_frames( available_width = term_width.saturating_sub(ascii_width.max(12)); } - let content_lines = layout::get_content_lines(&nodes, config, Some(available_width)); + let mut content_lines = layout::get_content_lines(&nodes, config, Some(available_width)); + if config.no_color { + content_lines = content_lines + .iter() + .map(|line| strip_ansi_codes(line).to_string()) + .collect(); + } let animation_config = config.logo_animation.as_ref()?; let plugin_name = animation_config.plugin.as_deref()?; @@ -127,7 +133,7 @@ fn prepare_frames( logo::apply_logo_style(&mut frames, config); - if !config.show_colors { + if !config.show_colors || config.no_color { for frame in &mut frames { frame.lines = frame .lines diff --git a/src/ui/layout.rs b/src/ui/layout.rs index 2710f11..0e887c2 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -12,6 +12,7 @@ const TREE_LAYOUT: &str = "tree"; const SECTION_LAYOUT: &str = "section"; const SECTION_BOX_LAYOUT: &str = "section-box"; const CUSTOM_X_LAYOUT: &str = "custom-x"; +const RIGHT_LAYOUT: &str = "right"; const COMPACT_LAYOUT: &str = "compact"; const MINIMAL_LAYOUT: &str = "minimal"; const CLASSIC_VARIANTS: &[&str] = &["pacman", "box", "line", "dots", "bottom_line"]; @@ -25,6 +26,7 @@ pub const LAYOUT_NAMES: &[&str] = &[ "section", "section-box", "custom-x", + "right", "compact", "minimal", "pacman", @@ -75,6 +77,10 @@ pub fn is_bottom(layout: Option<&String>) -> bool { layout.map(|l| l == "bottom").unwrap_or(false) } +pub fn is_right(layout: Option<&String>) -> bool { + layout.map(|l| l == RIGHT_LAYOUT).unwrap_or(false) +} + //tests #[cfg(test)] mod tests { @@ -86,6 +92,13 @@ mod tests { assert!(LAYOUT_NAMES.contains(&"section")); } + #[test] + fn recognizes_the_right_layout() { + assert!(is_right(Some(&"right".to_string()))); + assert!(!is_right(Some(&"default".to_string()))); + assert!(!is_right(None)); + } + #[test] fn test_get_content_lines_empty() { // Verify that the layout correctly handles an empty list of nodes without crashing diff --git a/src/ui/live.rs b/src/ui/live.rs index 1fdaac8..b3be322 100644 --- a/src/ui/live.rs +++ b/src/ui/live.rs @@ -145,7 +145,14 @@ fn build_content_lines(info: &Info, config: &Config, ascii_width: usize) -> Vec< if available_width < 10 && term_width > 40 { available_width = term_width.saturating_sub(ascii_width.max(12)); } - layout::get_content_lines(&nodes, config, Some(available_width)) + let mut lines = layout::get_content_lines(&nodes, config, Some(available_width)); + if config.no_color { + lines = lines + .iter() + .map(|line| console::strip_ansi_codes(line).to_string()) + .collect(); + } + lines } fn theme_file_path(name: &str) -> PathBuf { diff --git a/src/ui/logo.rs b/src/ui/logo.rs index 50dc899..8211e4b 100644 --- a/src/ui/logo.rs +++ b/src/ui/logo.rs @@ -11,11 +11,11 @@ use viuer::{Config as ViuerConfig, print}; const IMAGE_EXTENSIONS: [&str; 4] = [".png", ".jpg", ".jpeg", ".svg"]; -fn is_image_file(path: &str) -> bool { +pub(crate) fn is_image_file(path: &str) -> bool { IMAGE_EXTENSIONS.iter().any(|ext| path.ends_with(ext)) } -fn auto_logo_width() -> u32 { +pub(crate) fn auto_logo_width() -> u32 { let term_width = size().map(|(w, _)| w as u32).unwrap_or(80); let w = if term_width < 60 { (term_width as f32 * 0.20) as u32 @@ -29,7 +29,119 @@ fn is_kitty() -> bool { std::env::var("KITTY_WINDOW_ID").is_ok() } +pub(crate) fn is_image_logo(config: &Config) -> bool { + config + .logo_path + .as_deref() + .is_some_and(|path| match config.logo_type.as_deref() { + Some("ascii") => false, + Some("image") => true, + _ => is_image_file(path), + }) +} + pub fn get_logo_data(config: &Config) -> (Vec, bool, usize, usize) { + load_logo(config, None) +} + +/// Loads the logo for the right layout, placing an image flush against the +/// terminal's right edge. +pub(crate) fn get_logo_data_right( + config: &Config, + term_width: usize, +) -> (Vec, bool, usize, usize) { + load_logo(config, Some(term_width)) +} + +/// Width, in terminal cells, that `viuer` will actually print. Its resize logic +/// only downscales, so a source narrower than the requested width keeps its +/// original width; when a height is also configured the resize is exact. +fn printed_image_width(source_width: u32, requested: u32, logo_height: Option) -> u32 { + if logo_height.is_some() { + requested + } else { + source_width.min(requested) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct LogoDiagnostics { + pub is_image: bool, + pub path: Option, + pub path_exists: bool, + pub requested_width: Option, + pub printed_width: Option, + pub ascii_lines: Option, + pub gap: usize, + pub content_width: Option, + pub image_x: Option, +} + +pub(crate) fn diagnostics(config: &Config, term_width: usize) -> LogoDiagnostics { + let is_image = is_image_logo(config); + let gap = 2 + config.logo_gap.unwrap_or(12) as usize; + let (path, path_exists) = if let Some(path) = &config.logo_path { + (Some(path.clone()), expand_path(path).exists()) + } else if let Some(path) = &config.ascii { + (Some(path.clone()), expand_path(path).exists()) + } else { + (None, true) + }; + let requested_width = config.logo_width.unwrap_or_else(auto_logo_width); + + let mut printed_width = None; + let mut ascii_lines = None; + if is_image { + if let Some(path) = &config.logo_path + && let Ok((width, _height)) = image::image_dimensions(expand_path(path)) + { + printed_width = + Some(printed_image_width(width, requested_width, config.logo_height) as usize); + } + } else { + let content = if let Some(path) = &config.logo_path { + std::fs::read_to_string(expand_path(path)).ok() + } else if let Some(path) = &config.ascii { + std::fs::read_to_string(expand_path(path)).ok() + } else { + Some(get_default_ascii()) + }; + if let Some(content) = content { + let lines: Vec<&str> = content.lines().collect(); + let width = lines + .iter() + .map(|line| console::measure_text_width(&strip_ansi_codes(line))) + .max() + .unwrap_or(0); + printed_width = Some(width); + ascii_lines = Some(lines.len()); + } + } + + let content_width = printed_width.map(|width| term_width.saturating_sub(width + gap)); + let image_x = if is_image { + printed_width.map(|width| term_width.saturating_sub(width).min(u16::MAX as usize) as u16) + } else { + None + }; + + LogoDiagnostics { + is_image, + path, + path_exists, + requested_width: Some(requested_width), + printed_width, + ascii_lines, + gap, + content_width, + image_x, + } +} + +fn load_logo( + config: &Config, + right_term_width: Option, +) -> (Vec, bool, usize, usize) { let mut ascii_lines: Vec = Vec::new(); let mut image_printed = false; let mut ascii_width = 0; @@ -47,28 +159,38 @@ pub fn get_logo_data(config: &Config) -> (Vec, bool, usize, usize) { let img_width = config.logo_width.unwrap_or_else(auto_logo_width); let use_native = is_kitty() && config.logo_kitty.unwrap_or(true); - let conf = ViuerConfig { - width: Some(img_width), - height: config.logo_height, - absolute_offset: false, - transparent: false, - use_kitty: use_native, - ..Default::default() - }; - let _ = execute!( stdout, crossterm::cursor::SavePosition, crossterm::cursor::MoveToColumn(0) ); - if let Ok(img) = image::open(&path) - && let Ok((width, height)) = print(&img, &conf) - { - image_printed = true; - ascii_width = width as usize; - image_height = height as usize; - let _ = stdout.flush(); + if let Ok(img) = image::open(&path) { + let printed_width = printed_image_width(img.width(), img_width, config.logo_height); + let image_x = right_term_width + .map(|term_width| { + term_width + .saturating_sub(printed_width as usize) + .min(u16::MAX as usize) as u16 + }) + .unwrap_or(0); + + let conf = ViuerConfig { + width: Some(img_width), + height: config.logo_height, + absolute_offset: false, + x: image_x, + transparent: false, + use_kitty: use_native, + ..Default::default() + }; + + if let Ok((width, height)) = print(&img, &conf) { + image_printed = true; + ascii_width = width as usize; + image_height = height as usize; + let _ = stdout.flush(); + } } let _ = execute!( @@ -177,7 +299,7 @@ pub fn build_logo_frames( && !frames.is_empty() { apply_logo_style(&mut frames, config); - if !config.show_colors { + if !config.show_colors || config.no_color { for frame in &mut frames { frame.lines = frame .lines @@ -309,4 +431,21 @@ mod tests { assert_eq!(frames.len(), 1); assert!(force_plain); } + + #[test] + fn printed_width_matches_viuer_downscale_only_behaviour() { + assert_eq!(printed_image_width(10, 20, None), 10); + assert_eq!(printed_image_width(120, 20, None), 20); + assert_eq!(printed_image_width(10, 20, Some(5)), 20); + assert_eq!(printed_image_width(120, 20, Some(5)), 20); + } + + #[test] + fn right_layout_uses_the_printed_width_for_the_offset() { + let term_width = 80usize; + let printed = printed_image_width(10, 20, None) as usize; + let image_x = term_width.saturating_sub(printed); + assert_eq!(image_x + printed, term_width); + assert_eq!(image_x, 70); + } } diff --git a/src/ui/nodes.rs b/src/ui/nodes.rs index 146d4fb..0f809b8 100644 --- a/src/ui/nodes.rs +++ b/src/ui/nodes.rs @@ -117,7 +117,7 @@ pub fn prepare_render_tree( nodes } -fn get_module_value(info: &Info, key: &str) -> Option { +pub(crate) fn get_module_value(info: &Info, key: &str) -> Option { match key { "os" => Some(info.os.clone()), "kernel" => Some(info.kernel.clone()), @@ -165,7 +165,7 @@ fn get_module_value(info: &Info, key: &str) -> Option { } } -fn format_palette(config: &Config) -> String { +pub(crate) fn format_palette(config: &Config) -> String { let style = config.palette_style.as_deref().unwrap_or(SQUARES_STYLE); let mut s = String::new(); diff --git a/src/ui/print.rs b/src/ui/print.rs index d369ee7..f5d3c36 100644 --- a/src/ui/print.rs +++ b/src/ui/print.rs @@ -177,6 +177,64 @@ pub fn print_output( } } +fn right_columns(term_width: usize, logo_width: usize, gap: usize) -> (usize, usize) { + let content_width = term_width.saturating_sub(logo_width + gap); + let spacer = term_width.saturating_sub(content_width + logo_width); + (content_width, spacer) +} + +/// Renders the text column on the left and the logo/image on the right. +pub fn print_output_right( + ascii_lines: Vec, + image_printed: bool, + ascii_width: usize, + image_height: usize, + content_lines: Vec, + config: &Config, + force_plain_logo: bool, +) { + let mut out = stdout(); + let term_width = size().map(|(w, _)| w as usize).unwrap_or(80); + let term_width = if term_width == 0 { 80 } else { term_width }; + let gap_base = config.logo_gap.unwrap_or(12) as usize; + let gap = console::measure_text_width(LOGO_INFO_GAP) + gap_base; + let (content_width, ascii_spacer) = right_columns(term_width, ascii_width, gap); + + let text_height = content_lines.len(); + let logo_height = if image_printed { + image_height + } else { + ascii_lines.len() + }; + let max_lines = std::cmp::max(logo_height, text_height); + + for i in 0..max_lines { + if content_width > 0 { + if i < content_lines.len() { + let line = truncate_line(&content_lines[i], content_width); + let visible = visible_width(&line); + let _ = execute!( + out, + Print(format!( + "{}{}", + line, + " ".repeat(content_width.saturating_sub(visible)) + )) + ); + } else { + let _ = execute!(out, Print(" ".repeat(content_width))); + } + } + + if !image_printed { + let _ = execute!(out, Print(" ".repeat(ascii_spacer))); + let ascii_line = ascii_lines.get(i).map(String::as_str).unwrap_or(""); + print_logo_line(&mut out, ascii_line, ascii_width, config, force_plain_logo); + } + let _ = execute!(out, Print("\n")); + } +} + pub struct FrameGeometry { pub max_logo_width: usize, pub max_lines: usize, @@ -433,7 +491,8 @@ fn append_logo_line( config: &Config, force_plain_logo: bool, ) { - let is_custom_ascii = force_plain_logo || config.ascii.is_some() || config.logo_path.is_some(); + let is_custom_ascii = + config.no_color || force_plain_logo || config.ascii.is_some() || config.logo_path.is_some(); let visible_len = visible_width(ascii_line); let padding = ascii_width.saturating_sub(visible_len); let text = format!("{}{}", ascii_line, " ".repeat(padding)); @@ -612,7 +671,8 @@ fn print_logo_line( config: &Config, force_plain_logo: bool, ) { - let is_custom_ascii = force_plain_logo || config.ascii.is_some() || config.logo_path.is_some(); + let is_custom_ascii = + config.no_color || force_plain_logo || config.ascii.is_some() || config.logo_path.is_some(); let visible_len = visible_width(ascii_line); let padding = ascii_width.saturating_sub(visible_len); @@ -771,3 +831,35 @@ mod tests { ); } } + +#[cfg(test)] +mod right_layout_columns_tests { + use super::right_columns; + + #[test] + fn text_and_logo_fill_the_terminal_width() { + for (term, logo, gap) in [ + (80, 5, 6), + (80, 20, 6), + (120, 5, 6), + (40, 20, 6), + (80, 20, 14), + (80, 40, 6), + ] { + let (content, spacer) = right_columns(term, logo, gap); + assert_eq!( + content + spacer + logo, + term, + "term={term} logo={logo} gap={gap}" + ); + assert_eq!(spacer, gap, "the gap must sit between text and logo"); + } + } + + #[test] + fn oversized_logo_does_not_underflow() { + let (content, spacer) = right_columns(10, 12, 6); + assert_eq!(content, 0); + assert_eq!(spacer, 0); + } +} diff --git a/src/ui/win_daemon.rs b/src/ui/win_daemon.rs index 0189261..9da32f6 100644 --- a/src/ui/win_daemon.rs +++ b/src/ui/win_daemon.rs @@ -356,7 +356,8 @@ fn append_logo_line( config: &Config, force_plain_logo: bool, ) { - let is_custom_ascii = force_plain_logo || config.ascii.is_some() || config.logo_path.is_some(); + let is_custom_ascii = + config.no_color || force_plain_logo || config.ascii.is_some() || config.logo_path.is_some(); let padding = ascii_width.saturating_sub(visible_width(ascii_line)); if is_custom_ascii { buf.push_str(ascii_line); @@ -456,7 +457,13 @@ fn prepare_frames( available_width = term_width.saturating_sub(ascii_width.max(12)); } - let content_lines = layout::get_content_lines(&nodes, config, Some(available_width)); + let mut content_lines = layout::get_content_lines(&nodes, config, Some(available_width)); + if config.no_color { + content_lines = content_lines + .iter() + .map(|line| strip_ansi_codes(line).to_string()) + .collect(); + } let animation_config = config.logo_animation.as_ref()?; let plugin_name = animation_config.plugin.as_deref()?; @@ -474,7 +481,7 @@ fn prepare_frames( logo::apply_logo_style(&mut frames, config); - if !config.show_colors { + if !config.show_colors || config.no_color { for frame in &mut frames { frame.lines = frame .lines @@ -667,7 +674,14 @@ fn build_content_lines(info: &Info, config: &Config, ascii_width: usize) -> Vec< if available_width < 10 && term_width > 40 { available_width = term_width.saturating_sub(ascii_width.max(12)); } - layout::get_content_lines(&nodes, config, Some(available_width)) + let mut lines = layout::get_content_lines(&nodes, config, Some(available_width)); + if config.no_color { + lines = lines + .iter() + .map(|line| strip_ansi_codes(line).to_string()) + .collect(); + } + lines } fn theme_file_path(name: &str) -> PathBuf { diff --git a/src/update/github.rs b/src/update/github.rs index c33ff16..0bb04b4 100644 --- a/src/update/github.rs +++ b/src/update/github.rs @@ -14,8 +14,6 @@ use std::time::Duration; pub struct Asset { pub name: String, pub url: String, - #[allow(dead_code)] - pub size: u64, } /// A release with its parsed version. @@ -24,16 +22,12 @@ pub struct Release { pub tag: String, pub version: Version, pub assets: Vec, - #[allow(dead_code)] - pub html_url: String, } #[derive(Debug, Deserialize)] struct WireRelease { tag_name: String, #[serde(default)] - html_url: String, - #[serde(default)] assets: Vec, } @@ -41,8 +35,6 @@ struct WireRelease { struct WireAsset { name: String, browser_download_url: String, - #[serde(default)] - size: u64, } struct HttpResponse { @@ -90,7 +82,6 @@ impl Client { } /// Downloads a binary asset with a bounded size. - #[cfg_attr(not(unix), allow(dead_code))] pub fn get_bytes(&self, url: &str, cap: usize) -> Result, String> { let mut response = self.request(url, Duration::from_secs(300))?; let declared = response.content_length.unwrap_or(0); @@ -183,7 +174,6 @@ pub fn parse_release(body: &str) -> Result { .map(|asset| Asset { name: asset.name, url: asset.browser_download_url, - size: asset.size, }) .collect(); @@ -191,25 +181,28 @@ pub fn parse_release(body: &str) -> Result { tag: wire.tag_name, version, assets, - html_url: wire.html_url, }) } /// Release asset name for one target, as produced by the release workflow. -#[cfg_attr(not(unix), allow(dead_code))] +/// +/// Unix targets are published as `.tar.gz`; the Windows target is a `.zip`. pub fn asset_name(version: &Version, target: &str) -> String { - format!("xfetch-{}-{}.tar.gz", version, target) + let extension = if target.contains("windows") { + "zip" + } else { + "tar.gz" + }; + format!("xfetch-{}-{}.{}", version, target, extension) } /// Finds the prebuilt asset for `target`. -#[cfg_attr(not(unix), allow(dead_code))] pub fn choose_asset<'a>(release: &'a Release, target: &str) -> Option<&'a Asset> { let name = asset_name(&release.version, target); release.assets.iter().find(|asset| asset.name == name) } /// Finds the `SHA256SUMS` asset. -#[cfg_attr(not(unix), allow(dead_code))] pub fn checksums_asset(release: &Release) -> Option<&Asset> { release .assets @@ -218,7 +211,6 @@ pub fn checksums_asset(release: &Release) -> Option<&Asset> { } /// Parses one hash out of a `sha256sum`-style file. -#[cfg_attr(not(unix), allow(dead_code))] pub fn parse_checksum(sums: &str, file: &str) -> Option { for line in sums.lines() { let mut parts = line.split_whitespace(); @@ -244,6 +236,11 @@ mod tests { "browser_download_url": "https://example.com/xfetch.tar.gz", "size": 1234 }, + { + "name": "xfetch-1.2.3-x86_64-pc-windows-msvc.zip", + "browser_download_url": "https://example.com/xfetch.zip", + "size": 2345 + }, { "name": "SHA256SUMS", "browser_download_url": "https://example.com/SHA256SUMS", @@ -257,10 +254,14 @@ mod tests { let release = parse_release(FIXTURE).expect("parse"); assert_eq!(release.version, Version::parse("1.2.3").expect("version")); assert_eq!(release.tag, "v1.2.3"); - assert_eq!(release.assets.len(), 2); + assert_eq!(release.assets.len(), 3); let asset = choose_asset(&release, "x86_64-unknown-linux-gnu").expect("asset"); assert_eq!(asset.url, "https://example.com/xfetch.tar.gz"); + + let windows = choose_asset(&release, "x86_64-pc-windows-msvc").expect("windows asset"); + assert_eq!(windows.url, "https://example.com/xfetch.zip"); + assert!( choose_asset(&release, "aarch64-apple-darwin").is_none(), "missing targets must not match" @@ -268,6 +269,19 @@ mod tests { assert!(checksums_asset(&release).is_some()); } + #[test] + fn asset_names_use_zip_for_windows() { + let version = Version::parse("1.2.3").expect("version"); + assert_eq!( + asset_name(&version, "x86_64-unknown-linux-gnu"), + "xfetch-1.2.3-x86_64-unknown-linux-gnu.tar.gz" + ); + assert_eq!( + asset_name(&version, "x86_64-pc-windows-msvc"), + "xfetch-1.2.3-x86_64-pc-windows-msvc.zip" + ); + } + #[test] fn rejects_bad_tags() { let body = r#"{"tag_name": "not-a-version", "assets": []}"#; diff --git a/src/update/install.rs b/src/update/install.rs deleted file mode 100644 index eb07e86..0000000 --- a/src/update/install.rs +++ /dev/null @@ -1,316 +0,0 @@ -//! In-place prebuilt updates (Unix). -//! -//! The release tarball is downloaded, verified against the published -//! `SHA256SUMS`, extracted to a hidden temporary file next to the target and -//! moved over the current executable with a single atomic rename. The -//! previous binary is kept once as `xfetch.bak` (overwritten by the next -//! update, so no version graveyard accumulates). - -use super::github::{self, Client, Release}; -use crate::hash::sha256_hex; -use std::fs; -use std::io::{Cursor, Read}; -use std::os::unix::fs::PermissionsExt; -use std::path::{Path, PathBuf}; -use std::time::Duration; - -/// Upper bound for a release asset (the binaries are a few MiB). -const MAX_ASSET: usize = 200 * 1024 * 1024; -/// Upper bound for the `SHA256SUMS` file. -const MAX_SUMS: usize = 1024 * 1024; - -/// Downloads, verifies and installs the release for this platform. -pub fn update_prebuilt( - client: &Client, - release: &Release, - target_dir: &Path, -) -> Result { - let target = release_target() - .ok_or_else(|| "Prebuilt updates are not available for this platform".to_string())?; - - let asset = github::choose_asset(release, target).ok_or_else(|| { - format!( - "Release {} does not publish a prebuilt binary for {}", - release.tag, target - ) - })?; - let sums = github::checksums_asset(release) - .ok_or_else(|| format!("Release {} does not publish SHA256SUMS", release.tag))?; - - let sums_text = client.get_text(&sums.url, MAX_SUMS, Duration::from_secs(30))?; - let expected = github::parse_checksum(&sums_text, &asset.name).ok_or_else(|| { - format!( - "SHA256SUMS does not list {}; refusing to install an unverified binary", - asset.name - ) - })?; - - let bytes = client.get_bytes(&asset.url, MAX_ASSET)?; - let actual = sha256_hex(&bytes); - if actual != expected { - return Err(format!( - "Checksum mismatch for {}: expected {}, got {}", - asset.name, expected, actual - )); - } - - fs::create_dir_all(target_dir) - .map_err(|err| format!("Cannot create '{}': {}", target_dir.display(), err))?; - - let new_binary = extract_binary(&bytes, target_dir)?; - match replace_binary(target_dir, &new_binary) { - Ok(path) => Ok(path), - Err(err) => { - let _ = fs::remove_file(&new_binary); - Err(err) - } - } -} - -/// The release workflow target triple for the running platform. -pub fn release_target() -> Option<&'static str> { - if cfg!(all( - target_os = "linux", - target_arch = "x86_64", - target_env = "gnu" - )) { - Some("x86_64-unknown-linux-gnu") - } else if cfg!(all( - target_os = "linux", - target_arch = "x86_64", - target_env = "musl" - )) { - Some("x86_64-unknown-linux-musl") - } else if cfg!(all( - target_os = "linux", - target_arch = "aarch64", - target_env = "gnu" - )) { - Some("aarch64-unknown-linux-gnu") - } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) { - Some("x86_64-apple-darwin") - } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) { - Some("aarch64-apple-darwin") - } else { - None - } -} - -/// Extracts the `xfetch` entry from the release tarball into a temporary file. -fn extract_binary(bytes: &[u8], dir: &Path) -> Result { - let decoder = flate2::read::GzDecoder::new(Cursor::new(bytes)); - let mut archive = tar::Archive::new(decoder); - let tmp = dir.join(format!(".xfetch-update-{}", std::process::id())); - - let entries = archive - .entries() - .map_err(|err| format!("Invalid release archive: {}", err))?; - for entry in entries { - let mut entry = entry.map_err(|err| format!("Invalid archive entry: {}", err))?; - let path = entry - .path() - .map_err(|err| format!("Invalid archive path: {}", err))?; - if path.file_name().and_then(|name| name.to_str()) != Some("xfetch") { - continue; - } - - let mut file = fs::File::create(&tmp) - .map_err(|err| format!("Cannot create '{}': {}", tmp.display(), err))?; - std::io::copy(&mut entry, &mut file) - .map_err(|err| format!("Failed to extract the binary: {}", err))?; - let _ = file.sync_all(); - fs::set_permissions(&tmp, fs::Permissions::from_mode(0o755)) - .map_err(|err| format!("Cannot mark '{}' executable: {}", tmp.display(), err))?; - return Ok(tmp); - } - - let _ = fs::remove_file(&tmp); - Err("The release archive does not contain the xfetch binary".to_string()) -} - -/// Keeps one backup and moves the new binary into place atomically. -fn replace_binary(target_dir: &Path, new_binary: &Path) -> Result { - let target = target_dir.join("xfetch"); - let backup = target_dir.join("xfetch.bak"); - - if target.exists() { - fs::copy(&target, &backup) - .map_err(|err| format!("Cannot back up '{}': {}", target.display(), err))?; - } - - fs::rename(new_binary, &target) - .map_err(|err| format!("Cannot replace '{}': {}", target.display(), err))?; - - Ok(target) -} - -/// Reads a bounded stream into memory. -#[allow(dead_code)] -fn read_bounded(reader: impl Read, cap: usize) -> Result, String> { - let mut body = Vec::new(); - reader - .take(cap as u64 + 1) - .read_to_end(&mut body) - .map_err(|err| format!("Read failed: {}", err))?; - if body.len() > cap { - return Err("Payload exceeds the size limit".to_string()); - } - Ok(body) -} - -#[cfg(test)] -mod tests { - use super::*; - use flate2::Compression; - use flate2::write::GzEncoder; - use std::io::Write; - - fn scratch(tag: &str) -> PathBuf { - let dir = - std::env::temp_dir().join(format!("xfetch-update-test-{}-{}", tag, std::process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).expect("create scratch"); - dir - } - - fn tarball(name: &str, payload: &[u8]) -> Vec { - let encoder = GzEncoder::new(Vec::new(), Compression::fast()); - let mut builder = tar::Builder::new(encoder); - - let mut header = tar::Header::new_gnu(); - header.set_size(payload.len() as u64); - header.set_mode(0o755); - header.set_cksum(); - builder - .append_data(&mut header, format!("./{}", name), payload) - .expect("append"); - - builder - .into_inner() - .expect("finish tar") - .finish() - .expect("finish gzip") - } - - #[test] - fn sha256_matches_known_vector() { - assert_eq!( - sha256_hex(b"abc"), - "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" - ); - } - - #[test] - fn extracts_xfetch_from_the_archive() { - let dir = scratch("extract"); - let archive = tarball("xfetch", b"binary-bytes"); - let extracted = extract_binary(&archive, &dir).expect("extract"); - assert_eq!(fs::read(&extracted).expect("read"), b"binary-bytes"); - let mode = fs::metadata(&extracted) - .expect("metadata") - .permissions() - .mode(); - assert_eq!(mode & 0o777, 0o755); - let _ = fs::remove_dir_all(&dir); - } - - #[test] - fn rejects_archives_without_the_binary() { - let dir = scratch("missing"); - let archive = tarball("README.md", b"docs"); - assert!(extract_binary(&archive, &dir).is_err()); - let _ = fs::remove_dir_all(&dir); - } - - #[test] - fn replaces_the_binary_and_keeps_one_backup() { - let dir = scratch("replace"); - let target = dir.join("xfetch"); - fs::write(&target, b"old").expect("write old"); - let new_binary = dir.join(".xfetch-update-test-new"); - fs::write(&new_binary, b"new").expect("write new"); - - let replaced = replace_binary(&dir, &new_binary).expect("replace"); - assert_eq!(replaced, target); - assert_eq!(fs::read(&target).expect("read"), b"new"); - assert_eq!(fs::read(dir.join("xfetch.bak")).expect("backup"), b"old"); - assert!(!new_binary.exists()); - - // A second update overwrites the same backup instead of accumulating. - let newer = dir.join(".xfetch-update-test-newer"); - fs::write(&newer, b"newest").expect("write newest"); - replace_binary(&dir, &newer).expect("replace again"); - assert_eq!(fs::read(dir.join("xfetch.bak")).expect("backup"), b"new"); - let backups: Vec<_> = fs::read_dir(&dir) - .expect("read dir") - .filter_map(Result::ok) - .filter(|entry| entry.file_name().to_string_lossy().contains(".bak")) - .collect(); - assert_eq!(backups.len(), 1, "only one backup file must exist"); - let _ = fs::remove_dir_all(&dir); - } - - #[test] - fn reads_bounded_rejects_oversized_payloads() { - let mut small = &b"abcd"[..]; - assert_eq!(read_bounded(&mut small, 8).expect("small"), b"abcd"); - let mut big = &b"abcdef"[..]; - assert!(read_bounded(&mut big, 3).is_err()); - } - - #[test] - fn release_target_is_known_on_supported_platforms() { - #[cfg(any(target_os = "linux", target_os = "macos"))] - assert!(release_target().is_some()); - #[cfg(not(any(target_os = "linux", target_os = "macos")))] - assert!(release_target().is_none()); - } - - #[test] - fn tarball_helper_roundtrips() { - let dir = scratch("roundtrip"); - let archive = tarball("xfetch", b"payload"); - let extracted = extract_binary(&archive, &dir).expect("extract"); - assert_eq!(fs::read(extracted).expect("read"), b"payload"); - let _ = fs::remove_dir_all(&dir); - } - - #[test] - fn encoder_writes_are_flushed() { - let mut encoder = GzEncoder::new(Vec::new(), Compression::fast()); - encoder.write_all(b"x").expect("write"); - assert!(!encoder.finish().expect("finish").is_empty()); - } - - /// Full path against a real GitHub release: fetch, download, verify the - /// checksum, extract and replace. Ignored by default so offline builds and - /// CI do not depend on the network. - #[test] - #[ignore = "requires network access"] - fn downloads_verifies_and_runs_the_latest_release() { - let client = Client::new().expect("client"); - let release = - github::fetch_latest(&client, &super::super::api_base()).expect("fetch latest release"); - let dir = scratch("network"); - - let installed = update_prebuilt(&client, &release, &dir).expect("update"); - assert_eq!( - installed.file_name().and_then(|n| n.to_str()), - Some("xfetch") - ); - - let output = std::process::Command::new(&installed) - .arg("--version") - .output() - .expect("run installed binary"); - assert!(output.status.success(), "installed binary failed to run"); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.to_ascii_lowercase().contains("xfetch"), - "unexpected version output: {}", - stdout - ); - - let _ = fs::remove_dir_all(&dir); - } -} diff --git a/src/update/install/mod.rs b/src/update/install/mod.rs new file mode 100644 index 0000000..16f4c2b --- /dev/null +++ b/src/update/install/mod.rs @@ -0,0 +1,188 @@ +//! In-place prebuilt updates. +//! +//! The release asset is downloaded, verified against the published +//! `SHA256SUMS`, extracted to a hidden temporary file next to the target and +//! moved over the current executable. The previous binary is kept once as +//! `xfetch.bak` / `xfetch.exe.bak` (overwritten by the next update, so no +//! version graveyard accumulates). +//! +//! Unix uses an atomic rename. Windows cannot delete a running executable, so +//! the replacement is delegated to `self-replace`, which renames the running +//! image aside and moves the new one into place. + +#[cfg(unix)] +mod unix; +#[cfg(windows)] +mod windows; + +use super::github::{self, Client, Release}; +use super::platform; +use crate::hash::sha256_hex; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +/// Upper bound for a release asset (the binaries are a few MiB). +pub(super) const MAX_ASSET: usize = 200 * 1024 * 1024; +/// Upper bound for the `SHA256SUMS` file. +const MAX_SUMS: usize = 1024 * 1024; + +/// Downloads, verifies and installs the release for this platform. +pub fn update_prebuilt( + client: &Client, + release: &Release, + target_dir: &Path, +) -> Result { + let target = platform::release_target() + .ok_or_else(|| "Prebuilt updates are not available for this platform".to_string())?; + + let asset = github::choose_asset(release, target).ok_or_else(|| { + format!( + "Release {} does not publish a prebuilt binary for {}", + release.tag, target + ) + })?; + let sums = github::checksums_asset(release) + .ok_or_else(|| format!("Release {} does not publish SHA256SUMS", release.tag))?; + + let sums_text = client.get_text(&sums.url, MAX_SUMS, Duration::from_secs(30))?; + let expected = github::parse_checksum(&sums_text, &asset.name).ok_or_else(|| { + format!( + "SHA256SUMS does not list {}; refusing to install an unverified binary", + asset.name + ) + })?; + + let bytes = client.get_bytes(&asset.url, MAX_ASSET)?; + let actual = sha256_hex(&bytes); + if actual != expected { + return Err(format!( + "Checksum mismatch for {}: expected {}, got {}", + asset.name, expected, actual + )); + } + + fs::create_dir_all(target_dir) + .map_err(|err| format!("Cannot create '{}': {}", target_dir.display(), err))?; + + let new_binary = extract_binary(&bytes, target_dir)?; + match replace_binary(target_dir, &new_binary) { + Ok(path) => Ok(path), + Err(err) => { + let _ = fs::remove_file(&new_binary); + Err(err) + } + } +} + +fn extract_binary(bytes: &[u8], dir: &Path) -> Result { + #[cfg(unix)] + { + unix::extract_binary(bytes, dir) + } + #[cfg(windows)] + { + windows::extract_binary(bytes, dir) + } + #[cfg(not(any(unix, windows)))] + { + let _ = (bytes, dir); + Err("Prebuilt updates are not available for this platform".to_string()) + } +} + +fn replace_binary(target_dir: &Path, new_binary: &Path) -> Result { + #[cfg(unix)] + { + unix::replace_binary(target_dir, new_binary) + } + #[cfg(windows)] + { + windows::replace_binary(target_dir, new_binary) + } + #[cfg(not(any(unix, windows)))] + { + let _ = (target_dir, new_binary); + Err("Prebuilt updates are not available for this platform".to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sha256_matches_known_vector() { + assert_eq!( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn release_target_is_known_on_supported_platforms() { + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] + assert!(platform::release_target().is_some()); + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + assert!(platform::release_target().is_none()); + } + + #[test] + fn reads_bounded_rejects_oversized_payloads() { + use std::io::Read; + + fn read_bounded(reader: impl Read, cap: usize) -> Result, String> { + let mut body = Vec::new(); + reader + .take(cap as u64 + 1) + .read_to_end(&mut body) + .map_err(|err| format!("Read failed: {}", err))?; + if body.len() > cap { + return Err("Payload exceeds the size limit".to_string()); + } + Ok(body) + } + + let mut small = &b"abcd"[..]; + assert_eq!(read_bounded(&mut small, 8).expect("small"), b"abcd"); + let mut big = &b"abcdef"[..]; + assert!(read_bounded(&mut big, 3).is_err()); + } + + /// Full path against a real GitHub release: fetch, download, verify the + /// checksum, extract and replace. Ignored by default so offline builds and + /// CI do not depend on the network. Unix-only because running it on + /// Windows would replace the test harness itself. + #[cfg(unix)] + #[test] + #[ignore = "requires network access"] + fn downloads_verifies_and_runs_the_latest_release() { + let client = Client::new().expect("client"); + let release = + github::fetch_latest(&client, &super::super::api_base()).expect("fetch latest release"); + let dir = + std::env::temp_dir().join(format!("xfetch-update-test-network-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("create scratch"); + + let installed = update_prebuilt(&client, &release, &dir).expect("update"); + assert_eq!( + installed.file_name().and_then(|n| n.to_str()), + Some("xfetch") + ); + + let output = std::process::Command::new(&installed) + .arg("--version") + .output() + .expect("run installed binary"); + assert!(output.status.success(), "installed binary failed to run"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.to_ascii_lowercase().contains("xfetch"), + "unexpected version output: {}", + stdout + ); + + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/src/update/install/unix.rs b/src/update/install/unix.rs new file mode 100644 index 0000000..f21c260 --- /dev/null +++ b/src/update/install/unix.rs @@ -0,0 +1,156 @@ +//! Unix prebuilt archive extraction and in-place replacement. + +use super::super::platform::binary_name; +use std::fs; +use std::io::Cursor; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +/// Extracts the `xfetch` entry from the release tarball into a temporary file. +pub(super) fn extract_binary(bytes: &[u8], dir: &Path) -> Result { + let decoder = flate2::read::GzDecoder::new(Cursor::new(bytes)); + let mut archive = tar::Archive::new(decoder); + let tmp = dir.join(format!(".xfetch-update-{}", std::process::id())); + + let entries = archive + .entries() + .map_err(|err| format!("Invalid release archive: {}", err))?; + for entry in entries { + let mut entry = entry.map_err(|err| format!("Invalid archive entry: {}", err))?; + let path = entry + .path() + .map_err(|err| format!("Invalid archive path: {}", err))?; + if path.file_name().and_then(|name| name.to_str()) != Some(binary_name()) { + continue; + } + + let mut file = fs::File::create(&tmp) + .map_err(|err| format!("Cannot create '{}': {}", tmp.display(), err))?; + std::io::copy(&mut entry, &mut file) + .map_err(|err| format!("Failed to extract the binary: {}", err))?; + let _ = file.sync_all(); + fs::set_permissions(&tmp, fs::Permissions::from_mode(0o755)) + .map_err(|err| format!("Cannot mark '{}' executable: {}", tmp.display(), err))?; + return Ok(tmp); + } + + let _ = fs::remove_file(&tmp); + Err("The release archive does not contain the xfetch binary".to_string()) +} + +/// Keeps one backup and moves the new binary into place atomically. +pub(super) fn replace_binary(target_dir: &Path, new_binary: &Path) -> Result { + let target = target_dir.join(binary_name()); + let backup = target_dir.join("xfetch.bak"); + + if target.exists() { + fs::copy(&target, &backup) + .map_err(|err| format!("Cannot back up '{}': {}", target.display(), err))?; + } + + fs::rename(new_binary, &target) + .map_err(|err| format!("Cannot replace '{}': {}", target.display(), err))?; + + Ok(target) +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::Compression; + use flate2::write::GzEncoder; + use std::io::Write; + + fn scratch(tag: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("xfetch-update-test-{}-{}", tag, std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("create scratch"); + dir + } + + fn tarball(name: &str, payload: &[u8]) -> Vec { + let encoder = GzEncoder::new(Vec::new(), Compression::fast()); + let mut builder = tar::Builder::new(encoder); + + let mut header = tar::Header::new_gnu(); + header.set_size(payload.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + builder + .append_data(&mut header, format!("./{}", name), payload) + .expect("append"); + + builder + .into_inner() + .expect("finish tar") + .finish() + .expect("finish gzip") + } + + #[test] + fn extracts_xfetch_from_the_archive() { + let dir = scratch("extract"); + let archive = tarball("xfetch", b"binary-bytes"); + let extracted = extract_binary(&archive, &dir).expect("extract"); + assert_eq!(fs::read(&extracted).expect("read"), b"binary-bytes"); + let mode = fs::metadata(&extracted) + .expect("metadata") + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o755); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn rejects_archives_without_the_binary() { + let dir = scratch("missing"); + let archive = tarball("README.md", b"docs"); + assert!(extract_binary(&archive, &dir).is_err()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn replaces_the_binary_and_keeps_one_backup() { + let dir = scratch("replace"); + let target = dir.join("xfetch"); + fs::write(&target, b"old").expect("write old"); + let new_binary = dir.join(".xfetch-update-test-new"); + fs::write(&new_binary, b"new").expect("write new"); + + let replaced = replace_binary(&dir, &new_binary).expect("replace"); + assert_eq!(replaced, target); + assert_eq!(fs::read(&target).expect("read"), b"new"); + assert_eq!(fs::read(dir.join("xfetch.bak")).expect("backup"), b"old"); + assert!(!new_binary.exists()); + + // A second update overwrites the same backup instead of accumulating. + let newer = dir.join(".xfetch-update-test-newer"); + fs::write(&newer, b"newest").expect("write newest"); + replace_binary(&dir, &newer).expect("replace again"); + assert_eq!(fs::read(dir.join("xfetch.bak")).expect("backup"), b"new"); + let backups: Vec<_> = fs::read_dir(&dir) + .expect("read dir") + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().contains(".bak")) + .collect(); + assert_eq!(backups.len(), 1, "only one backup file must exist"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn tarball_helper_roundtrips() { + let dir = scratch("roundtrip"); + let archive = tarball("xfetch", b"payload"); + let extracted = extract_binary(&archive, &dir).expect("extract"); + assert_eq!(fs::read(extracted).expect("read"), b"payload"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn encoder_writes_are_flushed() { + let mut encoder = GzEncoder::new(Vec::new(), Compression::fast()); + encoder.write_all(b"x").expect("write"); + assert!(!encoder.finish().expect("finish").is_empty()); + } +} diff --git a/src/update/install/windows.rs b/src/update/install/windows.rs new file mode 100644 index 0000000..f0b495e --- /dev/null +++ b/src/update/install/windows.rs @@ -0,0 +1,183 @@ +//! Windows prebuilt archive extraction and in-place replacement. + +use super::super::platform::binary_name; +use super::MAX_ASSET; +use std::fs; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use zip::ZipArchive; + +/// Extracts `xfetch.exe` from the release zip into a temporary file. +pub(super) fn extract_binary(bytes: &[u8], dir: &Path) -> Result { + let mut archive = ZipArchive::new(Cursor::new(bytes)) + .map_err(|err| format!("Invalid release archive: {}", err))?; + let tmp = dir.join(format!(".xfetch-update-{}.exe", std::process::id())); + + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .map_err(|err| format!("Invalid archive entry: {}", err))?; + if entry.is_dir() { + continue; + } + let name = entry + .name() + .map_err(|err| format!("Invalid archive entry name: {}", err))?; + if Path::new(name.as_ref()) + .file_name() + .and_then(|name| name.to_str()) + != Some(binary_name()) + { + continue; + } + if entry.size() > MAX_ASSET as u64 { + return Err("The release binary exceeds the size limit".to_string()); + } + + let mut file = fs::File::create(&tmp) + .map_err(|err| format!("Cannot create '{}': {}", tmp.display(), err))?; + std::io::copy(&mut entry, &mut file) + .map_err(|err| format!("Failed to extract the binary: {}", err))?; + let _ = file.sync_all(); + return Ok(tmp); + } + + let _ = fs::remove_file(&tmp); + Err("The release archive does not contain the xfetch binary".to_string()) +} + +/// Keeps one backup and replaces the running executable. +/// +/// `self-replace` renames the running image aside and moves the new binary into +/// place. Windows permits renaming a running executable, but not deleting it. +pub(super) fn replace_binary(target_dir: &Path, new_binary: &Path) -> Result { + let target = target_dir.join(binary_name()); + let backup = target_dir.join("xfetch.exe.bak"); + + if target.exists() { + fs::copy(&target, &backup) + .map_err(|err| format!("Cannot back up '{}': {}", target.display(), err))?; + } + + if is_current_executable(&target) { + // The running image can be renamed but not deleted; let self-replace + // perform the Windows-specific swap. + self_replace::self_replace(new_binary).map_err(|err| { + format!( + "Cannot replace '{}': {}. Close other xfetch processes and try again", + target.display(), + err + ) + })?; + let _ = fs::remove_file(new_binary); + } else { + // `--bin-dir` can point at an installation that is not the running + // process. In that case a normal rename is enough (and works when the + // target file is not in use). + fs::rename(new_binary, &target).map_err(|err| { + format!( + "Cannot replace '{}': {}. Close any xfetch process using it and try again", + target.display(), + err + ) + })?; + } + + Ok(target) +} + +fn is_current_executable(path: &Path) -> bool { + if !path.exists() { + return false; + } + let Ok(current) = std::env::current_exe() else { + return false; + }; + let current = fs::canonicalize(¤t).unwrap_or(current); + let path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + normalized(¤t) == normalized(&path) +} + +fn normalized(path: &Path) -> String { + let mut text = path.to_string_lossy().replace('/', "\\"); + if let Some(rest) = text.strip_prefix("\\\\?\\") { + text = rest.to_string(); + } + text.make_ascii_lowercase(); + text +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn scratch(tag: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("xfetch-update-win-{}-{}", tag, std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("create scratch"); + dir + } + + fn zip_with(name: &str, payload: &[u8]) -> Vec { + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + writer + .start_file(name, zip::write::SimpleFileOptions::default()) + .expect("start zip entry"); + writer.write_all(payload).expect("write zip entry"); + writer.finish().expect("finish zip").into_inner() + } + + #[test] + fn extracts_xfetch_exe_from_the_archive() { + let dir = scratch("extract"); + let archive = zip_with("xfetch.exe", b"binary-bytes"); + let extracted = extract_binary(&archive, &dir).expect("extract"); + assert_eq!(fs::read(&extracted).expect("read"), b"binary-bytes"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn finds_xfetch_exe_in_a_nested_directory() { + let dir = scratch("nested"); + let archive = zip_with("stage/xfetch.exe", b"nested-bytes"); + let extracted = extract_binary(&archive, &dir).expect("extract"); + assert_eq!(fs::read(extracted).expect("read"), b"nested-bytes"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn rejects_archives_without_the_binary() { + let dir = scratch("missing"); + let archive = zip_with("README.md", b"docs"); + assert!(extract_binary(&archive, &dir).is_err()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn rejects_invalid_archives() { + let dir = scratch("invalid"); + assert!(extract_binary(b"not a zip", &dir).is_err()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn replaces_a_non_running_target_and_keeps_one_backup() { + let dir = scratch("replace"); + let target = dir.join("xfetch.exe"); + fs::write(&target, b"old").expect("write old"); + let new_binary = dir.join(".xfetch-update-test-new"); + fs::write(&new_binary, b"new").expect("write new"); + + let replaced = replace_binary(&dir, &new_binary).expect("replace"); + assert_eq!(replaced, target); + assert_eq!(fs::read(&target).expect("read"), b"new"); + assert_eq!( + fs::read(dir.join("xfetch.exe.bak")).expect("backup"), + b"old" + ); + assert!(!new_binary.exists()); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/src/update/mod.rs b/src/update/mod.rs index 57a851f..529d05a 100644 --- a/src/update/mod.rs +++ b/src/update/mod.rs @@ -4,43 +4,48 @@ //! only touches the file when doing so is safe. //! //! - `--check` reports whether a newer release exists without installing. -//! - Installs done with `install-prebuilt.sh` (typically `~/.local/bin`) are -//! updated in place: the release asset is downloaded, verified against the +//! - Installs done with the prebuilt installer are updated in place on Unix +//! and Windows. The release asset is downloaded, verified against the //! published `SHA256SUMS`, extracted to a temporary file and moved over the -//! current executable with an atomic rename. A single `xfetch.bak` keeps the -//! previous binary for rollback (overwritten on every update). +//! current executable. A single backup (`xfetch.bak` / `xfetch.exe.bak`) +//! keeps the previous binary for rollback and is overwritten on every update. //! - `cargo install` binaries are updated through `cargo install --force`. -//! - Package-manager installs and local builds are never replaced; the -//! command prints the right command instead. -//! -//! Windows prebuilt updates are intentionally not implemented yet: the `zip` -//! asset published by the release workflow is not reliable, so the command -//! only reports and suggests `cargo install` or a manual update there. +//! Binaries built from a checkout (`cargo install --path`, `install.ps1`) +//! are rebuilt from their source when the checkout still exists. +//! - winget installs are delegated to `winget upgrade`; xfetch never replaces +//! a winget-managed file itself. +//! - Package-manager installs and unrecognized local builds are never +//! replaced; the command prints the right command instead. mod github; -#[cfg(unix)] mod install; +mod platform; mod provenance; +mod receipt; use crate::update::github::{Release, fetch_latest}; -use crate::update::provenance::InstallSource; +use crate::update::provenance::{DetectedInstall, InstallSource}; use semver::Version; use std::io::{IsTerminal, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::process::Command; /// GitHub API endpoint used to discover the latest release. Overridable for /// mirrors and tests with `XFETCH_UPDATE_API`. const DEFAULT_UPDATE_API: &str = "https://api.github.com/repos/xfetch-cli/xfetch/releases/latest"; +/// Package identifier used by the winget manifest. +#[cfg(windows)] +pub const WINGET_PACKAGE_ID: &str = "Xfetch-cli.Xfetch"; + /// Options collected from the CLI. #[derive(Debug, Default)] pub struct UpdateOptions { /// Only report; never install. pub check: bool, - /// Force the in-place prebuilt update (Unix only). + /// Force the in-place prebuilt update (Unix and Windows). pub prebuilt: bool, /// Directory that holds the xfetch binary (with `--prebuilt`). - #[cfg_attr(not(unix), allow(dead_code))] pub bin_dir: Option, /// Answer yes to confirmation prompts. pub yes: bool, @@ -70,6 +75,9 @@ pub enum UpdateOutcome { /// Checks for updates and, when allowed, installs the new release. pub fn run(options: UpdateOptions) -> Result { + if crate::runtime::offline() { + return Err("network access is disabled by --offline".to_string()); + } let current = Version::parse(env!("CARGO_PKG_VERSION")) .map_err(|err| format!("Invalid built-in version: {}", err))?; let client = github::Client::new()?; @@ -84,31 +92,57 @@ pub fn run(options: UpdateOptions) -> Result { let executable = std::env::current_exe() .map_err(|err| format!("Cannot locate the running xfetch binary: {}", err))?; - let source = provenance::detect(&executable); + let detected = provenance::detect(&executable); if options.check { - let command = recommended_command(source, &latest); + let command = recommended_command(&detected, &latest); return Ok(UpdateOutcome::UpdateAvailable { current, latest: latest.version.clone(), - method: source.label(), + method: detected.source.label(), command, }); } if options.prebuilt { + if detected.source.is_package_managed() { + return Ok(UpdateOutcome::Manual { + message: format!( + "xfetch {} is available, but this binary is managed by {}.\n\ + Refusing to replace it with --prebuilt. Use:\n {}", + latest.version, + detected.source.label(), + recommended_command(&detected, &latest) + ), + }); + } return install_prebuilt(&client, &latest, current, &executable, &options); } - match source { + match detected.source { InstallSource::Prebuilt => { install_prebuilt(&client, &latest, current, &executable, &options) } - InstallSource::Cargo => run_cargo_update(&latest, current, &options), + InstallSource::Cargo => run_cargo_update(&latest, current, &options, None), + InstallSource::SourceBuild => match detected.source_path.as_deref() { + Some(path) => run_cargo_update(&latest, current, &options, Some(path)), + None => Ok(UpdateOutcome::Manual { + message: format!( + "xfetch {} is available, but this is a source build and the checkout is unknown.\n\ + Re-run the installer or rebuild with:\n cargo install --path --force --locked", + latest.version + ), + }), + }, + #[cfg(windows)] + InstallSource::Winget => { + platform::run_winget_update(&latest, current, &executable, &options) + } + #[cfg(unix)] InstallSource::PackageManager => Ok(UpdateOutcome::Manual { message: format!( "xfetch {} is available, but this binary is managed by a package manager.\n\ - Update it with your package manager (pacman, apt, brew, winget, ...).", + Update it with your package manager (pacman, apt, brew, ...).", latest.version ), }), @@ -122,21 +156,96 @@ pub fn run(options: UpdateOptions) -> Result { } } +/// Restores the previous binary kept by an in-place update. +pub fn rollback(bin_dir: Option) -> Result { + let dir = match bin_dir { + Some(dir) => dir, + None => std::env::current_exe() + .map_err(|err| format!("Cannot locate the running xfetch binary: {err}"))? + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| "Cannot determine the xfetch binary directory".to_string())?, + }; + let current = dir.join(platform::binary_name()); + let backup = dir.join(format!("{}.bak", platform::binary_name())); + if !backup.is_file() { + return Err(format!( + "No previous binary found at '{}'; run an update first", + backup.display() + )); + } + + #[cfg(unix)] + { + crate::atomic_fs::copy_atomic(&backup, ¤t) + .map_err(|err| format!("Cannot restore '{}': {}", current.display(), err))?; + } + + #[cfg(windows)] + { + if is_current_executable(¤t) { + self_replace::self_replace(&backup).map_err(|err| { + format!( + "Cannot restore '{}': {}. Close other xfetch processes and try again", + current.display(), + err + ) + })?; + } else { + crate::atomic_fs::copy_atomic(&backup, ¤t) + .map_err(|err| format!("Cannot restore '{}': {}", current.display(), err))?; + } + } + + Ok(current) +} + +#[cfg(windows)] +fn is_current_executable(path: &Path) -> bool { + if !path.exists() { + return false; + } + let Ok(current) = std::env::current_exe() else { + return false; + }; + let current = std::fs::canonicalize(¤t).unwrap_or(current); + let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + current == path +} + /// The API base, honoring the mirror override. fn api_base() -> String { std::env::var("XFETCH_UPDATE_API").unwrap_or_else(|_| DEFAULT_UPDATE_API.to_string()) } /// Suggested command for `--check` output, tailored to the install source. -fn recommended_command(source: InstallSource, release: &Release) -> String { - match source { - InstallSource::Cargo => "cargo install xfetch-cli --force".to_string(), +fn recommended_command(detected: &DetectedInstall, release: &Release) -> String { + match detected.source { + InstallSource::Cargo => "cargo install xfetch-cli --force --locked".to_string(), + InstallSource::SourceBuild => detected.source_path.as_deref().map_or_else( + || { + "rebuild from the source checkout: cargo install --path --force --locked" + .to_string() + }, + |path| { + format!( + "cargo install --path \"{}\" --force --locked", + path.display() + ) + }, + ), + #[cfg(windows)] + InstallSource::Winget => { + format!("winget upgrade --id {} --exact", WINGET_PACKAGE_ID) + } + #[cfg(unix)] InstallSource::PackageManager => { - "update through your package manager (pacman, apt, brew, winget, ...)".to_string() + "update through your package manager (pacman, apt, brew, ...)".to_string() } InstallSource::Prebuilt => format!("xfetch update (installs {})", release.version), InstallSource::Unknown => { - "run install-prebuilt.sh or: xfetch update --prebuilt --bin-dir ".to_string() + "run the prebuilt installer or: xfetch update --prebuilt --bin-dir " + .to_string() } } } @@ -167,25 +276,25 @@ fn confirm(version: &Version, options: &UpdateOptions) -> Result { Ok(answer.is_empty() || answer == "y" || answer == "yes") } -/// Updates a prebuilt install in place (Unix) or explains why it cannot. +/// Updates a prebuilt install in place. fn install_prebuilt( client: &github::Client, release: &Release, current: Version, - executable: &std::path::Path, + executable: &Path, options: &UpdateOptions, ) -> Result { - #[cfg(not(unix))] + #[cfg(not(any(unix, windows)))] { let _ = (client, release, current, executable, options); - Err(format!( - "Prebuilt updates are not supported on this platform yet. \ + return Err(format!( + "Prebuilt updates are not supported on this platform. \ Install xfetch {} with: cargo install xfetch-cli --force", release.version - )) + )); } - #[cfg(unix)] + #[cfg(any(unix, windows))] { if !confirm(&release.version, options)? { return Ok(UpdateOutcome::Manual { @@ -197,7 +306,7 @@ fn install_prebuilt( Some(dir) => dir.clone(), None => executable .parent() - .map(std::path::Path::to_path_buf) + .map(Path::to_path_buf) .ok_or_else(|| "Cannot determine the binary directory".to_string())?, }; let target = install::update_prebuilt(client, release, &target_dir) @@ -211,18 +320,35 @@ fn install_prebuilt( } } -/// Runs `cargo install xfetch-cli --force --locked` for cargo installs. +/// Runs `cargo install` for registry installs, or rebuilds a source checkout. fn run_cargo_update( release: &Release, current: Version, options: &UpdateOptions, + source_path: Option<&Path>, ) -> Result { + let command = source_path.map_or_else( + || "cargo install xfetch-cli --force --locked".to_string(), + |path| { + format!( + "cargo install --path \"{}\" --force --locked", + path.display() + ) + }, + ); + if !options.yes { if !std::io::stdin().is_terminal() { return Ok(UpdateOutcome::Manual { message: format!( - "xfetch {} is available; this is a cargo install.\n cargo install xfetch-cli --force", - release.version + "xfetch {} is available; this is a {} install.\n {}", + release.version, + if source_path.is_some() { + "source" + } else { + "cargo" + }, + command ), }); } @@ -233,39 +359,129 @@ fn run_cargo_update( } } - let status = std::process::Command::new("cargo") - .args(["install", "xfetch-cli", "--force", "--locked"]) - .status() - .map_err(|err| format!("Failed to run cargo: {}", err))?; - if !status.success() { - return Err("cargo install failed".to_string()); + let status = match source_path { + Some(path) => Command::new("cargo") + .arg("install") + .arg("--path") + .arg(path) + .args(["--force", "--locked"]) + .status(), + None => Command::new("cargo") + .args(["install", "xfetch-cli", "--force", "--locked"]) + .status(), + }; + + match status { + Ok(status) if status.success() => { + let path = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("xfetch")); + if installed_version(&path).is_some_and(|version| version >= release.version) { + Ok(UpdateOutcome::Updated { + from: current, + to: release.version.clone(), + path, + }) + } else { + Ok(UpdateOutcome::Manual { + message: format!( + "cargo install reported success, but xfetch is still {}.\n\ + Close any other xfetch processes and run:\n {}", + current, command + ), + }) + } + } + Ok(_) => platform::cargo_failure(&command), + Err(err) => platform::cargo_spawn_failure(&command, &err.to_string()), + } +} + +/// Reads the version printed by `xfetch --version`. +fn installed_version(executable: &Path) -> Option { + let output = Command::new(executable).arg("--version").output().ok()?; + if !output.status.success() { + return None; } + parse_version_output(&String::from_utf8_lossy(&output.stdout)) + .or_else(|| parse_version_output(&String::from_utf8_lossy(&output.stderr))) +} - let path = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("xfetch")); - Ok(UpdateOutcome::Updated { - from: current, - to: release.version.clone(), - path, - }) +fn parse_version_output(output: &str) -> Option { + let token = output.split_whitespace().nth(1)?; + Version::parse(token.trim_start_matches('v')).ok() } #[cfg(test)] mod tests { use super::*; + fn release(version: &str) -> Release { + Release { + tag: format!("v{}", version), + version: Version::parse(version).expect("version"), + assets: Vec::new(), + } + } + + fn detected(source: InstallSource, path: Option<&str>) -> DetectedInstall { + DetectedInstall { + source, + source_path: path.map(PathBuf::from), + } + } + #[test] fn recommended_command_matches_the_install_source() { - let release = Release { - tag: "v9.9.9".to_string(), - version: Version::parse("9.9.9").expect("version"), - assets: Vec::new(), - html_url: String::new(), - }; - assert!(recommended_command(InstallSource::Cargo, &release).contains("cargo install")); + let release = release("9.9.9"); + assert!( + recommended_command(&detected(InstallSource::Cargo, None), &release) + .contains("cargo install") + ); + assert!( + recommended_command( + &detected(InstallSource::SourceBuild, Some("/src/xfetch")), + &release + ) + .contains("--path") + ); + #[cfg(windows)] + assert!( + recommended_command(&detected(InstallSource::Winget, None), &release) + .contains("winget upgrade") + ); + #[cfg(unix)] assert!( - recommended_command(InstallSource::PackageManager, &release) + recommended_command(&detected(InstallSource::PackageManager, None), &release) .contains("package manager") ); - assert!(recommended_command(InstallSource::Prebuilt, &release).contains("xfetch update")); + assert!( + recommended_command(&detected(InstallSource::Prebuilt, None), &release) + .contains("xfetch update") + ); + } + + #[test] + fn parses_version_output() { + assert_eq!( + parse_version_output("xfetch 1.2.3\n"), + Some(Version::parse("1.2.3").expect("version")) + ); + assert_eq!( + parse_version_output("xfetch v1.2.3\n"), + Some(Version::parse("1.2.3").expect("version")) + ); + assert_eq!(parse_version_output("not a version"), None); + } + + #[test] + fn package_managed_sources_refuse_forced_prebuilt() { + for source in [InstallSource::Cargo, InstallSource::SourceBuild] { + assert!(source.is_package_managed(), "{source:?}"); + } + #[cfg(windows)] + assert!(InstallSource::Winget.is_package_managed()); + #[cfg(unix)] + assert!(!InstallSource::PackageManager.is_package_managed()); + assert!(!InstallSource::Prebuilt.is_package_managed()); + assert!(!InstallSource::Unknown.is_package_managed()); } } diff --git a/src/update/platform/mod.rs b/src/update/platform/mod.rs new file mode 100644 index 0000000..420c2db --- /dev/null +++ b/src/update/platform/mod.rs @@ -0,0 +1,20 @@ +//! Platform-specific self-update policy. +//! +//! Shared orchestration lives in `update::mod`; every OS-specific decision +//! (release asset target, executable name, cargo failure policy and winget +//! delegation) lives in its own module so Windows and Unix code never share a +//! runtime branch. + +#[cfg(unix)] +mod unix; +#[cfg(not(any(unix, windows)))] +mod unsupported; +#[cfg(windows)] +mod windows; + +#[cfg(unix)] +pub(super) use unix::*; +#[cfg(not(any(unix, windows)))] +pub(super) use unsupported::*; +#[cfg(windows)] +pub(super) use windows::*; diff --git a/src/update/platform/unix.rs b/src/update/platform/unix.rs new file mode 100644 index 0000000..6c0c8c1 --- /dev/null +++ b/src/update/platform/unix.rs @@ -0,0 +1,47 @@ +//! Unix self-update policy. + +use crate::update::UpdateOutcome; + +/// Release target for the running Unix platform. +pub(crate) fn release_target() -> Option<&'static str> { + if cfg!(all( + target_os = "linux", + target_arch = "x86_64", + target_env = "gnu" + )) { + Some("x86_64-unknown-linux-gnu") + } else if cfg!(all( + target_os = "linux", + target_arch = "x86_64", + target_env = "musl" + )) { + Some("x86_64-unknown-linux-musl") + } else if cfg!(all( + target_os = "linux", + target_arch = "aarch64", + target_env = "gnu" + )) { + Some("aarch64-unknown-linux-gnu") + } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) { + Some("x86_64-apple-darwin") + } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + Some("aarch64-apple-darwin") + } else { + None + } +} + +/// Executable name inside the archive and on disk. +pub(crate) const fn binary_name() -> &'static str { + "xfetch" +} + +/// Unix reports a failed `cargo install` as a hard error. +pub(crate) fn cargo_failure(_command: &str) -> Result { + Err("cargo install failed".to_string()) +} + +/// Unix reports a `cargo` spawn failure as a hard error. +pub(crate) fn cargo_spawn_failure(_command: &str, err: &str) -> Result { + Err(format!("Failed to run cargo: {}", err)) +} diff --git a/src/update/platform/unsupported.rs b/src/update/platform/unsupported.rs new file mode 100644 index 0000000..169470a --- /dev/null +++ b/src/update/platform/unsupported.rs @@ -0,0 +1,19 @@ +//! Fallback policy for operating systems without a native update path. + +use crate::update::UpdateOutcome; + +pub(crate) fn release_target() -> Option<&'static str> { + None +} + +pub(crate) const fn binary_name() -> &'static str { + "xfetch" +} + +pub(crate) fn cargo_failure(command: &str) -> Result { + Err(format!("cargo install failed. Run:\n {}", command)) +} + +pub(crate) fn cargo_spawn_failure(_command: &str, err: &str) -> Result { + Err(format!("Failed to run cargo: {}", err)) +} diff --git a/src/update/platform/windows.rs b/src/update/platform/windows.rs new file mode 100644 index 0000000..dcc3d2c --- /dev/null +++ b/src/update/platform/windows.rs @@ -0,0 +1,122 @@ +//! Windows self-update policy. + +use crate::update::github::Release; +use crate::update::{UpdateOptions, UpdateOutcome}; +use semver::Version; +use std::io::IsTerminal; +use std::path::Path; +use std::process::Command; + +/// Release target for the Windows build. +/// +/// The release workflow publishes the MSVC archive. A GNU-built local binary +/// can still consume it when the user explicitly asks for a prebuilt update. +pub(crate) fn release_target() -> Option<&'static str> { + if cfg!(target_arch = "x86_64") { + Some("x86_64-pc-windows-msvc") + } else { + None + } +} + +/// Executable name inside the archive and on disk. +pub(crate) const fn binary_name() -> &'static str { + "xfetch.exe" +} + +/// Windows reports a locked `cargo install` as actionable instructions instead +/// of a hard failure: the user usually only has to close another xfetch process. +pub(crate) fn cargo_failure(command: &str) -> Result { + Ok(UpdateOutcome::Manual { + message: format!( + "cargo install could not replace the running binary.\n\ + Close any other xfetch processes and run:\n {}", + command + ), + }) +} + +/// A missing `cargo` is actionable on Windows rather than a hard failure. +pub(crate) fn cargo_spawn_failure(command: &str, err: &str) -> Result { + Ok(UpdateOutcome::Manual { + message: format!( + "Failed to run cargo: {}\nClose any other xfetch processes and run:\n {}", + err, command + ), + }) +} + +/// Delegates a winget install to the package manager. +pub(crate) fn run_winget_update( + release: &Release, + current: Version, + executable: &Path, + options: &UpdateOptions, +) -> Result { + let command = format!( + "winget upgrade --id {} --exact --silent --accept-package-agreements --accept-source-agreements", + super::super::WINGET_PACKAGE_ID + ); + + if !options.yes { + if !std::io::stdin().is_terminal() { + return Ok(UpdateOutcome::Manual { + message: format!( + "xfetch {} is available; this is a winget install.\n {}", + release.version, command + ), + }); + } + if !super::super::confirm(&release.version, options)? { + return Ok(UpdateOutcome::Manual { + message: "Update cancelled.".to_string(), + }); + } + } + + let status = Command::new("winget") + .args([ + "upgrade", + "--id", + super::super::WINGET_PACKAGE_ID, + "--exact", + "--silent", + "--accept-package-agreements", + "--accept-source-agreements", + ]) + .status(); + + match status { + Ok(status) if status.success() => { + if super::super::installed_version(executable) + .is_some_and(|version| version >= release.version) + { + Ok(UpdateOutcome::Updated { + from: current, + to: release.version.clone(), + path: executable.to_path_buf(), + }) + } else { + Ok(UpdateOutcome::Manual { + message: format!( + "winget reported success, but xfetch is still {}.\n\ + The winget manifest may not include {} yet. Try again later:\n {}", + current, release.version, command + ), + }) + } + } + Ok(_) => Ok(UpdateOutcome::Manual { + message: format!( + "winget could not upgrade xfetch. Close other xfetch processes and run:\n {}", + command + ), + }), + Err(err) => Ok(UpdateOutcome::Manual { + message: format!( + "winget is not available ({}). Update it manually:\n {}", + err, command + ), + }), + } +} diff --git a/src/update/provenance.rs b/src/update/provenance.rs deleted file mode 100644 index dec9979..0000000 --- a/src/update/provenance.rs +++ /dev/null @@ -1,136 +0,0 @@ -//! Detects how the running binary was installed. -//! -//! The updater uses this to decide whether replacing the file is safe. The -//! check is intentionally conservative: an unrecognized path is never -//! overwritten automatically. - -use std::path::{Path, PathBuf}; - -/// Where the current executable appears to come from. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum InstallSource { - /// Installed with `cargo install` (`~/.cargo/bin`). - Cargo, - /// Owned by a package manager (`/usr/bin`, Homebrew, ...). - PackageManager, - /// Installed by `install-prebuilt.sh` (`~/.local/bin`). - Prebuilt, - /// A local build or an unrecognized location. - Unknown, -} - -impl InstallSource { - /// Short label used in reports. - pub const fn label(self) -> &'static str { - match self { - Self::Cargo => "cargo install", - Self::PackageManager => "package manager", - Self::Prebuilt => "prebuilt install", - Self::Unknown => "local build or unknown", - } - } -} - -/// Classifies `executable` using the real home directory. -pub fn detect(executable: &Path) -> InstallSource { - detect_with_home(executable, dirs::home_dir().as_deref()) -} - -/// Classifies `executable` against an explicit home directory (testable). -pub fn detect_with_home(executable: &Path, home: Option<&Path>) -> InstallSource { - if let Some(home) = home - && executable.starts_with(home.join(".cargo").join("bin")) - { - return InstallSource::Cargo; - } - - if is_package_manager_path(executable) { - return InstallSource::PackageManager; - } - - if let Some(home) = home - && executable.starts_with(home.join(".local").join("bin")) - { - return InstallSource::Prebuilt; - } - - InstallSource::Unknown -} - -/// Paths owned by common package managers. -#[cfg(unix)] -fn is_package_manager_path(executable: &Path) -> bool { - ["/usr/bin", "/bin", "/opt/homebrew", "/usr/local/Cellar"] - .iter() - .any(|prefix| executable.starts_with(prefix)) -} - -/// Windows installs are never treated as package-managed here. -#[cfg(not(unix))] -fn is_package_manager_path(_executable: &Path) -> bool { - false -} - -/// Resolves a home-relative path for tests and reports. -#[allow(dead_code)] -pub fn home_path(home: &Path, rest: &str) -> PathBuf { - home.join(rest) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn classify(path: &str, home: &str) -> InstallSource { - detect_with_home(Path::new(path), Some(Path::new(home))) - } - - #[test] - fn detects_cargo_installs() { - assert_eq!( - classify("/home/u/.cargo/bin/xfetch", "/home/u"), - InstallSource::Cargo - ); - } - - #[test] - fn detects_prebuilt_installs() { - assert_eq!( - classify("/home/u/.local/bin/xfetch", "/home/u"), - InstallSource::Prebuilt - ); - } - - #[test] - fn detects_local_builds() { - assert_eq!( - classify("/home/u/repos/xfetch/target/release/xfetch", "/home/u"), - InstallSource::Unknown - ); - } - - #[cfg(unix)] - #[test] - fn detects_package_manager_paths() { - assert_eq!( - classify("/usr/bin/xfetch", "/home/u"), - InstallSource::PackageManager - ); - assert_eq!( - classify("/bin/xfetch", "/home/u"), - InstallSource::PackageManager - ); - assert_eq!( - classify("/opt/homebrew/bin/xfetch", "/home/u"), - InstallSource::PackageManager - ); - } - - #[test] - fn missing_home_falls_back_to_unknown() { - assert_eq!( - detect_with_home(Path::new("/random/xfetch"), None), - InstallSource::Unknown - ); - } -} diff --git a/src/update/provenance/mod.rs b/src/update/provenance/mod.rs new file mode 100644 index 0000000..372d40f --- /dev/null +++ b/src/update/provenance/mod.rs @@ -0,0 +1,341 @@ +//! Detects how the running binary was installed. +//! +//! The updater never guesses when it can avoid it: install receipts written by +//! the PowerShell installers win over path heuristics, and `cargo install +//! --list` distinguishes a crates.io install from a source checkout. An +//! unrecognized path is never replaced automatically. + +#[cfg(unix)] +mod unix; +#[cfg(windows)] +mod windows; + +use crate::update::receipt::InstallReceipt; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +/// Where the current executable appears to come from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallSource { + /// Installed with `cargo install xfetch-cli` (`~/.cargo/bin`). + Cargo, + /// Built from a source checkout, usually by `install.ps1`. + SourceBuild, + /// Installed with winget on Windows. + #[cfg(windows)] + Winget, + /// Owned by a Unix package manager (`/usr/bin`, Homebrew, ...). + #[cfg(unix)] + PackageManager, + /// Installed by the prebuilt installer (`~/.local/bin`, `%LOCALAPPDATA%`). + Prebuilt, + /// A local build, a manual copy, or an unrecognized location. + Unknown, +} + +impl InstallSource { + /// Short label used in reports. + pub const fn label(self) -> &'static str { + match self { + Self::Cargo => "cargo install", + Self::SourceBuild => "source build", + #[cfg(windows)] + Self::Winget => "winget", + #[cfg(unix)] + Self::PackageManager => "package manager", + Self::Prebuilt => "prebuilt install", + Self::Unknown => "local build or unknown", + } + } + + /// Sources whose files belong to a package manager or build tool. + pub const fn is_package_managed(self) -> bool { + match self { + Self::Cargo | Self::SourceBuild => true, + #[cfg(windows)] + Self::Winget => true, + #[cfg(unix)] + Self::PackageManager => false, + Self::Prebuilt | Self::Unknown => false, + } + } +} + +/// Result of provenance detection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetectedInstall { + /// Detected installation channel. + pub source: InstallSource, + /// Source checkout for [`InstallSource::SourceBuild`], when known. + pub source_path: Option, +} + +impl DetectedInstall { + fn new(source: InstallSource) -> Self { + Self { + source, + source_path: None, + } + } +} + +/// Paths used by the platform classifiers. +pub(crate) struct InstallPaths { + pub home: Option, + #[cfg(windows)] + pub local_app_data: Option, + #[cfg(windows)] + pub program_files: Option, + #[cfg(windows)] + pub program_files_x86: Option, +} + +impl InstallPaths { + fn from_env() -> Self { + Self { + home: dirs::home_dir(), + #[cfg(windows)] + local_app_data: dirs::data_local_dir(), + #[cfg(windows)] + program_files: std::env::var_os("ProgramFiles").map(PathBuf::from), + #[cfg(windows)] + program_files_x86: std::env::var_os("ProgramFiles(x86)").map(PathBuf::from), + } + } +} + +/// Classifies `executable` using the real home directory and environment. +pub fn detect(executable: &Path) -> DetectedInstall { + detect_with( + executable, + &InstallPaths::from_env(), + InstallReceipt::load(), + ) +} + +fn detect_with( + executable: &Path, + paths: &InstallPaths, + receipt: Option, +) -> DetectedInstall { + if let Some(receipt) = receipt + && receipt.matches_executable(executable) + { + let source = match receipt.method { + crate::update::receipt::Method::Prebuilt => InstallSource::Prebuilt, + crate::update::receipt::Method::Source => InstallSource::SourceBuild, + crate::update::receipt::Method::Cargo => InstallSource::Cargo, + #[cfg(windows)] + crate::update::receipt::Method::Winget => InstallSource::Winget, + #[cfg(not(windows))] + crate::update::receipt::Method::Winget => InstallSource::Unknown, + crate::update::receipt::Method::Manual => InstallSource::Unknown, + }; + return DetectedInstall { + source, + source_path: receipt.source_path, + }; + } + + let source = classify_path(executable, paths); + if source == InstallSource::Cargo { + return refine_cargo_install(); + } + DetectedInstall::new(source) +} + +fn classify_path(executable: &Path, paths: &InstallPaths) -> InstallSource { + #[cfg(unix)] + { + unix::classify(executable, paths) + } + #[cfg(windows)] + { + windows::classify(executable, paths) + } + #[cfg(not(any(unix, windows)))] + { + let _ = (executable, paths); + InstallSource::Unknown + } +} + +/// The two shapes reported by `cargo install --list` for `xfetch-cli`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CargoInstallKind { + /// Installed from crates.io. + Registry, + /// Installed from a checkout (`cargo install --path`, `install.ps1`). + Source(PathBuf), +} + +fn refine_cargo_install() -> DetectedInstall { + match cargo_install_kind() { + Some(CargoInstallKind::Source(path)) => DetectedInstall { + source: InstallSource::SourceBuild, + source_path: Some(path), + }, + Some(CargoInstallKind::Registry) | None => DetectedInstall::new(InstallSource::Cargo), + } +} + +fn cargo_install_kind() -> Option { + let output = crate::subprocess::run_cmd_with_stdin_timeout( + Path::new("cargo"), + &["install", "--list"], + None, + Some(Duration::from_secs(15)), + )?; + if !output.status.success() { + return None; + } + parse_cargo_install_list(&String::from_utf8_lossy(&output.stdout)) +} + +/// Parses the `cargo install --list` output for the `xfetch-cli` entry. +pub(crate) fn parse_cargo_install_list(output: &str) -> Option { + for line in output.lines() { + let line = line.trim(); + if !line.starts_with("xfetch-cli v") { + continue; + } + if let (Some(start), Some(end)) = (line.find('('), line.rfind(')')) + && end > start + { + return Some(CargoInstallKind::Source(PathBuf::from( + &line[start + 1..end], + ))); + } + return Some(CargoInstallKind::Registry); + } + None +} + +#[cfg(test)] +fn detect_with_home(executable: &Path, home: Option<&Path>) -> DetectedInstall { + let paths = InstallPaths { + home: home.map(Path::to_path_buf), + #[cfg(windows)] + local_app_data: None, + #[cfg(windows)] + program_files: None, + #[cfg(windows)] + program_files_x86: None, + }; + detect_with(executable, &paths, None) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn classify(path: &str, home: &str) -> InstallSource { + detect_with_home(Path::new(path), Some(Path::new(home))).source + } + + #[test] + fn detects_cargo_installs() { + assert_eq!( + classify("/home/u/.cargo/bin/xfetch", "/home/u"), + InstallSource::Cargo + ); + } + + #[cfg(unix)] + #[test] + fn detects_prebuilt_installs() { + assert_eq!( + classify("/home/u/.local/bin/xfetch", "/home/u"), + InstallSource::Prebuilt + ); + } + + #[test] + fn detects_local_builds() { + assert_eq!( + classify("/home/u/repos/xfetch/target/release/xfetch", "/home/u"), + InstallSource::Unknown + ); + } + + #[cfg(unix)] + #[test] + fn detects_package_manager_paths() { + assert_eq!( + classify("/usr/bin/xfetch", "/home/u"), + InstallSource::PackageManager + ); + assert_eq!( + classify("/bin/xfetch", "/home/u"), + InstallSource::PackageManager + ); + assert_eq!( + classify("/opt/homebrew/bin/xfetch", "/home/u"), + InstallSource::PackageManager + ); + } + + #[test] + fn missing_home_falls_back_to_unknown() { + assert_eq!( + detect_with_home(Path::new("/random/xfetch"), None).source, + InstallSource::Unknown + ); + } + + #[test] + fn parses_registry_and_path_cargo_installs() { + let registry = "xfetch-cli v1.0.3:\n xfetch\n"; + assert_eq!( + parse_cargo_install_list(registry), + Some(CargoInstallKind::Registry) + ); + + let source = "xfetch-cli v1.0.3 (C:\\Users\\u\\repos\\xfetch):\n xfetch.exe\n"; + assert_eq!( + parse_cargo_install_list(source), + Some(CargoInstallKind::Source(PathBuf::from( + "C:\\Users\\u\\repos\\xfetch" + ))) + ); + + assert_eq!(parse_cargo_install_list("other v1.0.0:\n"), None); + } + + #[test] + fn package_managed_sources_are_flagged() { + assert!(InstallSource::Cargo.is_package_managed()); + assert!(InstallSource::SourceBuild.is_package_managed()); + #[cfg(windows)] + assert!(InstallSource::Winget.is_package_managed()); + #[cfg(unix)] + assert!(!InstallSource::PackageManager.is_package_managed()); + assert!(!InstallSource::Prebuilt.is_package_managed()); + assert!(!InstallSource::Unknown.is_package_managed()); + } + + #[test] + fn receipt_wins_over_path_heuristics() { + let receipt = InstallReceipt { + schema_version: 1, + method: crate::update::receipt::Method::Prebuilt, + path: PathBuf::from("/home/u/.cargo/bin/xfetch"), + source_path: None, + }; + let paths = InstallPaths { + home: Some(PathBuf::from("/home/u")), + #[cfg(windows)] + local_app_data: None, + #[cfg(windows)] + program_files: None, + #[cfg(windows)] + program_files_x86: None, + }; + let detected = detect_with( + Path::new("/home/u/.cargo/bin/xfetch"), + &paths, + Some(receipt), + ); + assert_eq!(detected.source, InstallSource::Prebuilt); + } +} diff --git a/src/update/provenance/unix.rs b/src/update/provenance/unix.rs new file mode 100644 index 0000000..aad2d5e --- /dev/null +++ b/src/update/provenance/unix.rs @@ -0,0 +1,32 @@ +//! Unix path-based install provenance. + +use super::{InstallPaths, InstallSource}; +use std::path::Path; + +/// Classifies a Unix executable path. +pub(crate) fn classify(executable: &Path, paths: &InstallPaths) -> InstallSource { + if let Some(home) = &paths.home + && executable.starts_with(home.join(".cargo").join("bin")) + { + return InstallSource::Cargo; + } + + if is_package_manager_path(executable) { + return InstallSource::PackageManager; + } + + if let Some(home) = &paths.home + && executable.starts_with(home.join(".local").join("bin")) + { + return InstallSource::Prebuilt; + } + + InstallSource::Unknown +} + +/// Paths owned by common package managers. +fn is_package_manager_path(executable: &Path) -> bool { + ["/usr/bin", "/bin", "/opt/homebrew", "/usr/local/Cellar"] + .iter() + .any(|prefix| executable.starts_with(prefix)) +} diff --git a/src/update/provenance/windows.rs b/src/update/provenance/windows.rs new file mode 100644 index 0000000..1d90478 --- /dev/null +++ b/src/update/provenance/windows.rs @@ -0,0 +1,164 @@ +//! Windows path-based install provenance. +//! +//! The classifier is deliberately conservative. Windows has no single +//! package-ownership database for portable binaries, so unknown locations are +//! never treated as writable prebuilt installs. The PowerShell installers +//! write a receipt that resolves the default prebuilt directory without +//! guessing. + +use super::{InstallPaths, InstallSource}; +use std::path::{Path, PathBuf}; + +/// Classifies a Windows executable path. +pub(crate) fn classify(executable: &Path, paths: &InstallPaths) -> InstallSource { + if is_winget_path(executable, paths) { + return InstallSource::Winget; + } + + if is_under(executable, paths.home.as_deref(), ".cargo/bin") { + return InstallSource::Cargo; + } + + // `install-prebuilt.ps1` uses this directory, but so can a manually copied + // build. Without an install receipt this is intentionally unknown: the + // user can force an in-place update with `--prebuilt`. + if is_under( + executable, + paths.local_app_data.as_deref(), + "Programs/xfetch/bin", + ) || is_under(executable, paths.home.as_deref(), ".local/bin") + { + return InstallSource::Unknown; + } + + InstallSource::Unknown +} + +fn is_winget_path(executable: &Path, paths: &InstallPaths) -> bool { + let candidates = [ + (paths.local_app_data.as_deref(), "Microsoft/WinGet/Links"), + (paths.local_app_data.as_deref(), "Microsoft/WinGet/Packages"), + (paths.program_files.as_deref(), "WinGet/Links"), + (paths.program_files.as_deref(), "WinGet/Packages"), + (paths.program_files_x86.as_deref(), "WinGet/Packages"), + ]; + candidates + .iter() + .any(|(base, rest)| is_under(executable, *base, rest)) +} + +fn is_under(executable: &Path, base: Option<&Path>, rest: &str) -> bool { + let Some(base) = base else { + return false; + }; + let mut prefix: PathBuf = base.to_path_buf(); + for component in rest.split('/') { + prefix.push(component); + } + starts_with_ignore_case(executable, &prefix) +} + +fn starts_with_ignore_case(path: &Path, prefix: &Path) -> bool { + let path = normalized(path); + let prefix = normalized(prefix); + path == prefix || path.starts_with(&format!("{prefix}\\")) +} + +fn normalized(path: &Path) -> String { + let mut text = path.to_string_lossy().replace('/', "\\"); + if let Some(rest) = text.strip_prefix("\\\\?\\UNC\\") { + text = format!("\\\\{}", rest); + } else if let Some(rest) = text.strip_prefix("\\\\?\\") { + text = rest.to_string(); + } + text.make_ascii_lowercase(); + text.trim_end_matches('\\').to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn paths() -> InstallPaths { + InstallPaths { + home: Some(PathBuf::from("C:\\Users\\u")), + local_app_data: Some(PathBuf::from("C:\\Users\\u\\AppData\\Local")), + program_files: Some(PathBuf::from("C:\\Program Files")), + program_files_x86: Some(PathBuf::from("C:\\Program Files (x86)")), + } + } + + fn classify_path(path: &str) -> InstallSource { + classify(Path::new(path), &paths()) + } + + #[test] + fn detects_winget_links_and_packages() { + assert_eq!( + classify_path("C:\\Users\\u\\AppData\\Local\\Microsoft\\WinGet\\Links\\xfetch.exe"), + InstallSource::Winget + ); + assert_eq!( + classify_path( + "C:\\Users\\u\\AppData\\Local\\Microsoft\\WinGet\\Packages\\Xfetch-cli.Xfetch_Microsoft.Winget.Source_8wekyb3d8bbwe\\xfetch.exe" + ), + InstallSource::Winget + ); + assert_eq!( + classify_path("C:\\Program Files\\WinGet\\Links\\xfetch.exe"), + InstallSource::Winget + ); + assert_eq!( + classify_path("C:\\Program Files\\WinGet\\Packages\\Xfetch-cli.Xfetch_foo\\xfetch.exe"), + InstallSource::Winget + ); + } + + #[test] + fn detects_cargo_installs() { + assert_eq!( + classify_path("C:\\Users\\u\\.cargo\\bin\\xfetch.exe"), + InstallSource::Cargo + ); + } + + #[test] + fn does_not_guess_for_prebuilt_directories_without_a_receipt() { + assert_eq!( + classify_path("C:\\Users\\u\\AppData\\Local\\Programs\\xfetch\\bin\\xfetch.exe"), + InstallSource::Unknown + ); + assert_eq!( + classify_path("C:\\Users\\u\\.local\\bin\\xfetch.exe"), + InstallSource::Unknown + ); + } + + #[test] + fn leaves_local_and_manual_builds_unknown() { + assert_eq!( + classify_path("C:\\Users\\u\\Documents\\repos\\xfetch\\target\\release\\xfetch.exe"), + InstallSource::Unknown + ); + assert_eq!( + classify_path("C:\\Users\\u\\Downloads\\xfetch.exe"), + InstallSource::Unknown + ); + } + + #[test] + fn path_matching_is_case_insensitive_and_handles_verbatim_paths() { + assert_eq!( + classify_path("c:\\users\\U\\appdata\\local\\microsoft\\winget\\links\\XFETCH.EXE"), + InstallSource::Winget + ); + assert_eq!( + classify_path("\\\\?\\C:\\Users\\u\\.cargo\\bin\\xfetch.exe"), + InstallSource::Cargo + ); + assert_eq!( + classify_path("C:/Users/u/AppData/Local/Microsoft/WinGet/Links/xfetch.exe"), + InstallSource::Winget + ); + } +} diff --git a/src/update/receipt.rs b/src/update/receipt.rs new file mode 100644 index 0000000..a1e2b03 --- /dev/null +++ b/src/update/receipt.rs @@ -0,0 +1,181 @@ +//! Install receipts written by the platform installers. +//! +//! A path alone cannot always tell how a binary was installed: the default +//! prebuilt directory on Windows can also receive a manually copied build, and +//! a `cargo install --path` binary lives next to a crates.io install. The +//! installers therefore leave a small JSON receipt that the updater reads +//! first. Receipts are advisory: a missing or malformed file falls back to the +//! path-based detection in `provenance`. + +use serde::Deserialize; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Receipt schema accepted by this version of xfetch. +pub const SCHEMA_VERSION: u32 = 1; + +/// How the binary was installed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Method { + /// Installed by `install-prebuilt.ps1` or `install-prebuilt.sh`. + Prebuilt, + /// Built from a source checkout, usually by `install.ps1`. + Source, + /// Installed with `cargo install xfetch-cli`. + Cargo, + /// Installed with winget. + Winget, + /// Copied manually by the user. + Manual, +} + +/// Contents of `install.json`. +/// +/// Installers may write extra diagnostic fields (version, source, repo, +/// commit, installed_at); unknown fields are intentionally ignored so the +/// receipt schema can grow without breaking older binaries. +#[derive(Debug, Clone, Deserialize)] +pub struct InstallReceipt { + /// Must match [`SCHEMA_VERSION`]. + pub schema_version: u32, + /// Installation method recorded by the installer. + pub method: Method, + /// Path of the installed executable. + pub path: PathBuf, + /// Source checkout when `method` is `source`. + #[serde(default)] + pub source_path: Option, +} + +impl InstallReceipt { + /// Reads the receipt next to the user's data directory, if present. + pub fn load() -> Option { + Self::load_from(&receipt_path()?) + } + + /// Reads and validates a receipt from an explicit path. + pub fn load_from(path: &Path) -> Option { + let text = fs::read_to_string(path).ok()?; + let receipt: Self = serde_json::from_str(&text).ok()?; + (receipt.schema_version == SCHEMA_VERSION).then_some(receipt) + } + + /// Whether this receipt describes `executable`. + /// + /// Paths are canonicalized when possible; Windows comparisons are + /// case-insensitive and tolerate the `\\?\` verbatim prefix. + pub fn matches_executable(&self, executable: &Path) -> bool { + same_path(&self.path, executable) + } +} + +/// Location of the receipt for the current user. +pub fn receipt_path() -> Option { + #[cfg(windows)] + { + dirs::data_local_dir().map(|dir| dir.join("xfetch").join("install.json")) + } + #[cfg(not(windows))] + { + dirs::config_dir().map(|dir| dir.join("xfetch").join("install.json")) + } +} + +fn same_path(left: &Path, right: &Path) -> bool { + let left = fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf()); + let right = fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf()); + path_key(&left) == path_key(&right) +} + +#[cfg(windows)] +fn path_key(path: &Path) -> String { + let mut text = path.to_string_lossy().replace('/', "\\"); + if let Some(rest) = text.strip_prefix("\\\\?\\UNC\\") { + text = format!("\\\\{}", rest); + } else if let Some(rest) = text.strip_prefix("\\\\?\\") { + text = rest.to_string(); + } + text.make_ascii_lowercase(); + text.trim_end_matches('\\').to_string() +} + +#[cfg(not(windows))] +fn path_key(path: &Path) -> String { + path.to_string_lossy().trim_end_matches('/').to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(tag: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("xfetch-receipt-{}-{}", tag, std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("create scratch"); + dir + } + + #[test] + fn parses_a_prebuilt_receipt() { + let text = r#"{ + "schema_version": 1, + "method": "prebuilt", + "path": "C:\\Users\\u\\AppData\\Local\\Programs\\xfetch\\bin\\xfetch.exe", + "version": "1.0.3", + "source": "install-prebuilt.ps1", + "installed_at": "2026-11-05T10:00:00Z" + }"#; + let receipt: InstallReceipt = serde_json::from_str(text).expect("parse"); + assert_eq!(receipt.schema_version, 1); + assert_eq!(receipt.method, Method::Prebuilt); + assert!(receipt.source_path.is_none()); + } + + #[test] + fn parses_a_source_receipt() { + let text = r#"{ + "schema_version": 1, + "method": "source", + "path": "/home/u/.cargo/bin/xfetch", + "source_path": "/home/u/repos/xfetch", + "repo": "https://github.com/xfetch-cli/xfetch.git", + "commit": "abc123" + }"#; + let receipt: InstallReceipt = serde_json::from_str(text).expect("parse"); + assert_eq!(receipt.method, Method::Source); + assert_eq!( + receipt.source_path.as_deref(), + Some(Path::new("/home/u/repos/xfetch")) + ); + } + + #[test] + fn rejects_unsupported_schema_versions() { + let dir = scratch("schema"); + let path = dir.join("install.json"); + fs::write( + &path, + r#"{"schema_version": 2, "method": "prebuilt", "path": "xfetch"}"#, + ) + .expect("write"); + assert!(InstallReceipt::load_from(&path).is_none()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn matches_the_recorded_executable() { + let dir = scratch("match"); + let binary = dir.join("xfetch"); + fs::write(&binary, b"binary").expect("write"); + let text = format!( + r#"{{"schema_version":1,"method":"prebuilt","path":"{}"}}"#, + binary.to_string_lossy().replace('\\', "\\\\") + ); + let receipt: InstallReceipt = serde_json::from_str(&text).expect("parse"); + assert!(receipt.matches_executable(&binary)); + assert!(!receipt.matches_executable(&dir.join("other"))); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/src/wasm/manifest.rs b/src/wasm/manifest.rs index 88329a1..6489b19 100644 --- a/src/wasm/manifest.rs +++ b/src/wasm/manifest.rs @@ -58,8 +58,8 @@ pub struct Manifest { pub name: Option, pub version: Option, pub description: Option, - /// Informational contract: `info_provider`, `logo_animation`, `effect` or - /// `config_provider`. + /// Informational contract: `info_provider`, `logo_animation`, `effect`, + /// `config_provider` or `command`. pub kind: Option, /// Optional runtime hint (`core` or `component`); detection always reads /// the binary header, this only documents intent. diff --git a/src/wasm/mod.rs b/src/wasm/mod.rs index 718ed19..f525130 100644 --- a/src/wasm/mod.rs +++ b/src/wasm/mod.rs @@ -40,6 +40,7 @@ pub enum GuestKind { Plugin, Effect, Extension, + Command, } #[cfg_attr(not(feature = "wasm"), allow(dead_code))] @@ -49,6 +50,7 @@ impl GuestKind { Self::Plugin => "plugin", Self::Effect => "effect", Self::Extension => "extension", + Self::Command => "command", } } } @@ -60,7 +62,9 @@ impl GuestKind { /// drifts from it in a multi-repo checkout. pub const WIT: &str = include_str!("../../wit/xfetch-runtime.wit"); -/// Runs a wasm artifact with the JSON request and returns its JSON response. +/// Runs a wasm artifact with the JSON request and returns its raw response. +/// Plugin, effect and extension responses are JSON; command responses are +/// plain text lines. /// /// `timeout` is the user-configured safety cap (`timeout_secs`); when absent, /// the manifest limit applies, falling back to the runtime default. diff --git a/src/wasm/runner/component_guest.rs b/src/wasm/runner/component_guest.rs index e69188b..363d86f 100644 --- a/src/wasm/runner/component_guest.rs +++ b/src/wasm/runner/component_guest.rs @@ -1,8 +1,9 @@ //! Component-model runner for typed guests. //! //! Components implement one of the worlds in `wit/xfetch-runtime.wit` -//! (`plugin`, `effect` or `extension`). The world's `run` export receives the -//! JSON request string and returns the JSON response string; host +//! (`plugin`, `effect`, `extension` or `command`). The world's `run` export +//! receives the JSON request string and returns the response string (JSON for +//! plugins/effects/extensions, plain text for commands); host //! capabilities arrive as typed WIT imports instead of the JSON bridge used by //! core modules. WASI preview 2 is linked as well, so componentized Python, //! JavaScript and `wit-bindgen` guests get clocks, random and preopened @@ -36,6 +37,9 @@ pub mod bindings { pub mod extension { wasmtime::component::bindgen!({ path: "wit/xfetch-runtime.wit", world: "extension" }); } + pub mod command { + wasmtime::component::bindgen!({ path: "wit/xfetch-runtime.wit", world: "command" }); + } } /// Store data for component guests. @@ -151,6 +155,7 @@ macro_rules! impl_host_interface { impl_host_interface!(plugin, to_host_error_plugin); impl_host_interface!(effect, to_host_error_effect); impl_host_interface!(extension, to_host_error_extension); +impl_host_interface!(command, to_host_error_command); /// Runs a component and returns its JSON response bytes. pub(super) fn run( @@ -253,6 +258,26 @@ pub(super) fn run( guest.call_run(&mut store, request_json(request)), ) } + GuestKind::Command => { + bindings::command::xfetch::runtime::host::add_to_linker::< + ComponentState, + HasSelf, + >(&mut linker, |state| state) + .map_err(|err| format!("Failed to configure host for '{}': {}", name, err))?; + let pre = linker + .instantiate_pre(&component) + .map_err(|err| format!("Failed to prepare '{}': {}", name, err))?; + let guest = bindings::command::CommandPre::new(pre) + .map_err(|err| format!("Invalid command world for '{}': {}", name, err))? + .instantiate(&mut store) + .map_err(|err| format!("Failed to instantiate '{}': {}", name, err))?; + call_guest( + &name, + deadline, + timeout, + guest.call_run(&mut store, request_json(request)), + ) + } }; forward_stderr(&stderr); diff --git a/src/wasm/runner/mod.rs b/src/wasm/runner/mod.rs index f2f063f..db67cec 100644 --- a/src/wasm/runner/mod.rs +++ b/src/wasm/runner/mod.rs @@ -1,7 +1,7 @@ //! Guest execution: routes an artifact to the right runtime. //! -//! `run_request` is the single entry point used by the plugin, effect and -//! extension runners. It loads the manifest, resolves the policy and deadline +//! `run_request` is the single entry point used by the plugin, effect, +//! extension and command runners. It loads the manifest, resolves the policy and deadline //! once, then delegates to the core-module (WASI preview 1) or component //! runner. Both runners return the raw JSON response bytes so the existing //! protocol parsing in the callers is reused unchanged. @@ -18,7 +18,9 @@ use std::fs; use std::path::Path; use std::time::{Duration, Instant}; -/// Runs a wasm artifact with the JSON request and returns its JSON response. +/// Runs a wasm artifact with the JSON request and returns its raw response. +/// Plugin, effect and extension responses are JSON; command responses are +/// plain text. /// /// `timeout` is the user-configured safety cap (`timeout_secs`); when absent, /// the manifest limit applies, falling back to the runtime default. @@ -97,5 +99,6 @@ mod tests { assert_eq!(GuestKind::Plugin.as_str(), "plugin"); assert_eq!(GuestKind::Effect.as_str(), "effect"); assert_eq!(GuestKind::Extension.as_str(), "extension"); + assert_eq!(GuestKind::Command.as_str(), "command"); } } diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..dc354ba --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,922 @@ +use serde_json::Value; +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Output}; + +fn temp_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "xfetch-cli-test-{}-{}-{}", + tag, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("create temp dir"); + dir +} + +fn command(dir: &std::path::Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_xfetch")) + .args(args) + .env("XDG_CONFIG_HOME", dir.join("config")) + .env("XDG_CACHE_HOME", dir.join("cache")) + .env("XDG_STATE_HOME", dir.join("state")) + .env("HOME", dir.join("home")) + .env("TERM", "xterm-256color") + .env_remove("KITTY_WINDOW_ID") + .output() + .expect("run xfetch") +} + +fn stdout_json(output: &Output) -> Value { + serde_json::from_slice(&output.stdout).expect("valid JSON stdout") +} + +#[test] +fn version_and_lists() { + let dir = temp_dir("lists"); + let version = command(&dir, &["--version"]); + assert!(version.status.success()); + assert!(String::from_utf8_lossy(&version.stdout).contains("1.0.3")); + + let modules = command(&dir, &["--list-modules"]); + assert!(modules.status.success()); + let text = String::from_utf8_lossy(&modules.stdout); + assert!(text.contains("os")); + assert!(text.contains("packages:")); + + let layouts = command(&dir, &["--list-layouts"]); + assert!(layouts.status.success()); + assert!(String::from_utf8_lossy(&layouts.stdout).contains("right")); +} + +#[test] +fn json_and_get_outputs() { + let dir = temp_dir("get"); + let json = command(&dir, &["--json", "--get", "os,kernel"]); + assert!(json.status.success()); + let value = stdout_json(&json); + assert_eq!(value["schema_version"], 1); + assert!(value["values"]["os"].is_string()); + assert!(value["values"]["kernel"].is_string()); + + let plain = command(&dir, &["--get", "os"]); + assert!(plain.status.success()); + let text = String::from_utf8_lossy(&plain.stdout); + assert!(!text.trim().is_empty()); + assert!(!text.contains('=')); + + let modules = command(&dir, &["modules", "--json"]); + assert!(modules.status.success()); + let value = stdout_json(&modules); + assert!( + value + .as_array() + .is_some_and(|items| { items.iter().any(|item| item["key"] == "os") }) + ); +} + +#[test] +fn template_and_format_outputs() { + let dir = temp_dir("template"); + let format = command(&dir, &["--format", "os={{os}} missing={{nope}}"]); + assert!(format.status.success()); + let text = String::from_utf8_lossy(&format.stdout); + assert!(text.starts_with("os=")); + assert!(text.contains("missing=")); + + let template_path = dir.join("template.txt"); + fs::write(&template_path, "kernel={{kernel}}\n").unwrap(); + let template = command(&dir, &["--template", template_path.to_str().unwrap()]); + assert!(template.status.success()); + assert!(String::from_utf8_lossy(&template.stdout).starts_with("kernel=")); +} + +#[test] +fn config_roundtrip_preserves_comments() { + let dir = temp_dir("config"); + let path = dir.join("config.jsonc"); + fs::write( + &path, + "{\n // keep me\n \"layout\": \"section\",\n \"modules\": [\"os\"]\n}\n", + ) + .unwrap(); + let config_arg = format!("--config={}", path.display()); + + let validate = command(&dir, &[&config_arg, "config", "validate"]); + assert!(validate.status.success()); + + let get = command(&dir, &[&config_arg, "config", "get", "layout"]); + assert!(get.status.success()); + assert_eq!(String::from_utf8_lossy(&get.stdout).trim(), "section"); + + let set = command(&dir, &[&config_arg, "config", "set", "layout", "right"]); + assert!(set.status.success()); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("// keep me")); + assert!(content.contains("\"layout\": \"right\"")); + + let raw = command(&dir, &[&config_arg, "config", "print", "--raw"]); + assert!(raw.status.success()); + assert!(String::from_utf8_lossy(&raw.stdout).contains("// keep me")); +} + +#[test] +fn config_errors_have_stable_exit_codes() { + let dir = temp_dir("config-errors"); + let path = dir.join("bad.jsonc"); + fs::write(&path, "{ \"layout\": ").unwrap(); + let config_arg = format!("--config={}", path.display()); + + let validate = command(&dir, &[&config_arg, "config", "validate"]); + assert_eq!(validate.status.code(), Some(3)); + + let get = command(&dir, &[&config_arg, "config", "get", "missing"]); + assert_eq!(get.status.code(), Some(7)); +} + +#[test] +fn doctor_json_is_machine_readable() { + let dir = temp_dir("doctor"); + let output = command(&dir, &["doctor", "--json"]); + assert!(output.status.success()); + let value = stdout_json(&output); + assert_eq!(value["schema_version"], 1); + assert!( + value["checks"] + .as_array() + .is_some_and(|items| !items.is_empty()) + ); + assert!(value["platform"]["os"].is_string()); +} + +#[test] +fn completions_and_man() { + let dir = temp_dir("docs"); + let completions = command(&dir, &["completions", "bash"]); + assert!(completions.status.success()); + assert!(String::from_utf8_lossy(&completions.stdout).contains("_xfetch")); + + let man = command(&dir, &["man"]); + assert!(man.status.success()); + assert!(String::from_utf8_lossy(&man.stdout).contains(".TH xfetch")); +} + +#[test] +fn addon_json_and_unified_command() { + let dir = temp_dir("addons"); + let list = command(&dir, &["--json", "addon", "plugin", "list"]); + assert!(list.status.success()); + let value = stdout_json(&list); + assert_eq!(value["schema_version"], 1); + assert!(value["addons"].is_array()); + + let legacy = command(&dir, &["--json", "plugin", "list"]); + assert!(legacy.status.success()); + assert!(String::from_utf8_lossy(&legacy.stdout).contains("\"addons\"")); +} + +#[test] +fn offline_blocks_network_and_exit_codes() { + let dir = temp_dir("offline"); + let search = command(&dir, &["--offline", "plugin", "search", "logo"]); + assert_eq!(search.status.code(), Some(4)); + assert!(String::from_utf8_lossy(&search.stderr).contains("offline")); + + let update = command(&dir, &["--offline", "update", "--check"]); + assert_eq!(update.status.code(), Some(6)); + assert!(String::from_utf8_lossy(&update.stderr).contains("offline")); +} + +#[test] +fn color_flags_control_ansi_output() { + let dir = temp_dir("color"); + let always = command(&dir, &["--color", "always", "--modules", "os,kernel"]); + assert!(always.status.success()); + assert!(always.stdout.contains(&0x1b)); + + let never = command(&dir, &["--color", "never", "--modules", "os,kernel"]); + assert!(never.status.success()); + assert!(!never.stdout.contains(&0x1b)); +} + +#[test] +fn json_config_set_and_theme_list() { + let dir = temp_dir("json-config"); + let path = dir.join("config.jsonc"); + fs::write(&path, "{\n}\n").unwrap(); + let config_arg = format!("--config={}", path.display()); + + let set = command( + &dir, + &[&config_arg, "--json", "config", "set", "layout", "right"], + ); + assert!(set.status.success()); + let value = stdout_json(&set); + assert_eq!(value["key"], "layout"); + assert_eq!(value["value"], "right"); + + let themes = command(&dir, &["--json", "theme", "list"]); + assert!(themes.status.success()); + let value = stdout_json(&themes); + assert_eq!(value["schema_version"], 1); + assert!(value["themes"].is_array()); +} + +#[test] +fn get_version_and_conflicting_output_modes() { + let dir = temp_dir("conflicts"); + let version = command(&dir, &["--get", "version"]); + assert!(version.status.success()); + assert!(!String::from_utf8_lossy(&version.stdout).trim().is_empty()); + + let json_format = command(&dir, &["--json", "--format", "{{os}}"]); + assert_eq!(json_format.status.code(), Some(2)); + + let get_format = command(&dir, &["--get", "os", "--format", "{{os}}"]); + assert_eq!(get_format.status.code(), Some(2)); +} + +#[test] +fn quiet_suppresses_informational_output() { + let dir = temp_dir("quiet"); + let path = dir.join("config.jsonc"); + fs::write(&path, "{\n \"layout\": \"section\"\n}\n").unwrap(); + let config_arg = format!("--config={}", path.display()); + let output = command( + &dir, + &["--quiet", &config_arg, "config", "set", "layout", "right"], + ); + assert!(output.status.success()); + assert!(output.stdout.is_empty()); +} + +#[test] +fn backup_and_restore_simple_config() { + let dir = temp_dir("backup-simple"); + let path = dir.join("config.jsonc"); + fs::write( + &path, + "{\n // keep me\n \"layout\": \"section\",\n \"modules\": [\"os\"]\n}\n", + ) + .unwrap(); + let config_arg = format!("--config={}", path.display()); + let backup_dir = dir.join("backups"); + fs::create_dir_all(&backup_dir).unwrap(); + + let backup = command( + &dir, + &[ + &config_arg, + "config", + "backup", + "--output", + backup_dir.to_str().unwrap(), + ], + ); + assert!(backup.status.success()); + let backup_file = fs::read_dir(&backup_dir) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .find(|path| path.extension().and_then(|ext| ext.to_str()) == Some("json")) + .expect("backup file"); + + fs::write(&path, "{\n \"layout\": \"minimal\"\n}\n").unwrap(); + let restore = command( + &dir, + &[ + &config_arg, + "config", + "restore", + "--from", + backup_file.to_str().unwrap(), + ], + ); + assert!(restore.status.success()); + let restored = fs::read_to_string(&path).unwrap(); + assert!(restored.contains("// keep me")); + assert!(restored.contains("\"layout\": \"section\"")); +} + +#[test] +fn backup_and_restore_config_roulette() { + let dir = temp_dir("backup-roulette"); + let config_dir = dir.join("config").join("xfetch"); + fs::create_dir_all(config_dir.join("themes")).unwrap(); + fs::create_dir_all(config_dir.join("fetchs").join("a")).unwrap(); + fs::create_dir_all(config_dir.join("fetchs").join("b")).unwrap(); + + let theme = config_dir.join("themes").join("nord.jsonc"); + fs::write(&theme, "{ \"layout\": \"compact\" }\n").unwrap(); + let route_a = config_dir.join("fetchs").join("a").join("config.jsonc"); + fs::write(&route_a, "{ \"theme\": \"nord\", \"modules\": [\"os\"] }\n").unwrap(); + let route_b = config_dir.join("fetchs").join("b").join("config.jsonc"); + fs::write( + &route_b, + "{ \"layout\": \"tree\", \"modules\": [\"kernel\"] }\n", + ) + .unwrap(); + let routes = config_dir.join("routes.json"); + let routes_json = serde_json::json!([ + {"_name": "a", "path": route_a.display().to_string()}, + {"_name": "b", "path": route_b.display().to_string()}, + ]); + fs::write(&routes, serde_json::to_string(&routes_json).unwrap()).unwrap(); + let path = config_dir.join("config.jsonc"); + let config_json = serde_json::json!({ + "config_providers": [{ + "extension": "config-roulette", + "args": { + "routes": routes.display().to_string(), + "strategy": "daily" + } + }] + }); + fs::write(&path, serde_json::to_string(&config_json).unwrap()).unwrap(); + let config_arg = format!("--config={}", path.display()); + let backup_file = dir.join("roulette.json"); + let backup = command( + &dir, + &[ + &config_arg, + "config", + "backup", + "--output", + backup_file.to_str().unwrap(), + ], + ); + assert!(backup.status.success()); + + let bundle: Value = serde_json::from_slice(&fs::read(&backup_file).unwrap()).unwrap(); + let roles: Vec<&str> = bundle["files"] + .as_array() + .unwrap() + .iter() + .filter_map(|file| file["role"].as_str()) + .collect(); + assert!(roles.contains(&"config")); + assert!(roles.contains(&"routes")); + assert_eq!( + roles.iter().filter(|role| **role == "route-config").count(), + 2 + ); + assert!(roles.contains(&"theme")); + + for path in [&path, &routes, &route_a, &route_b, &theme] { + fs::remove_file(path).unwrap(); + } + let restore = command( + &dir, + &[ + &config_arg, + "config", + "restore", + "--from", + backup_file.to_str().unwrap(), + ], + ); + assert!(restore.status.success()); + assert!(path.is_file()); + assert!(routes.is_file()); + assert!(route_a.is_file()); + assert!(route_b.is_file()); + assert!(theme.is_file()); +} + +#[test] +fn backup_and_restore_global_flags_and_json() { + let dir = temp_dir("backup-global"); + let config_dir = dir.join("config").join("xfetch"); + fs::create_dir_all(&config_dir).unwrap(); + let path = config_dir.join("config.jsonc"); + fs::write(&path, "{\n \"layout\": \"section\"\n}\n").unwrap(); + + let backup = command(&dir, &["--json", "--backup"]); + assert!(backup.status.success()); + let value = stdout_json(&backup); + assert_eq!(value["schema_version"], 1); + assert!( + value["path"] + .as_str() + .is_some_and(|path| path.ends_with(".json")) + ); + + fs::write(&path, "{\n \"layout\": \"minimal\"\n}\n").unwrap(); + let restore = command(&dir, &["--json", "--restore"]); + assert!(restore.status.success()); + let value = stdout_json(&restore); + assert_eq!(value["dry_run"], false); + assert!( + value["restored"] + .as_array() + .is_some_and(|files| !files.is_empty()) + ); + assert!( + fs::read_to_string(&path) + .unwrap() + .contains("\"layout\": \"section\"") + ); +} + +#[test] +fn backup_conflicts_and_errors() { + let dir = temp_dir("backup-errors"); + let conflict = command(&dir, &["--backup", "--restore"]); + assert_eq!(conflict.status.code(), Some(2)); + + let with_subcommand = command(&dir, &["--backup", "modules"]); + assert_eq!(with_subcommand.status.code(), Some(2)); + + let missing = command(&dir, &["--backup"]); + assert_eq!(missing.status.code(), Some(3)); + + let restore = command(&dir, &["--restore"]); + assert_eq!(restore.status.code(), Some(7)); +} + +#[test] +fn json_addon_rollback_and_remove() { + let dir = temp_dir("addon-json"); + let plugins = dir.join("config").join("xfetch").join("plugins"); + fs::create_dir_all(&plugins).unwrap(); + let ext = if cfg!(windows) { ".exe" } else { "" }; + let artifact = plugins.join(format!("xfetch-plugin-test{ext}")); + let backup = plugins.join(format!("xfetch-plugin-test{ext}.bak")); + fs::write(&artifact, b"new").unwrap(); + fs::write(&backup, b"old").unwrap(); + + let rollback = command(&dir, &["--json", "addon", "plugin", "rollback", "test"]); + assert!(rollback.status.success()); + let value = stdout_json(&rollback); + assert_eq!(value["status"], "rolled-back"); + assert_eq!(fs::read(&artifact).unwrap(), b"old"); + + let remove = command(&dir, &["--json", "addon", "plugin", "remove", "test"]); + assert!(remove.status.success()); + let value = stdout_json(&remove); + assert_eq!(value["status"], "removed"); + assert!(!artifact.exists()); +} + +#[test] +fn config_unset_and_array_set() { + let dir = temp_dir("config-edit"); + let path = dir.join("config.jsonc"); + fs::write( + &path, + "{\n // keep\n \"layout\": \"section\",\n \"modules\": [\"os\", \"cpu\"],\n \"colors\": { \"cpu\": \"red\", \"os\": \"cyan\" }\n}\n", + ) + .unwrap(); + let config_arg = format!("--config={}", path.display()); + + let set = command(&dir, &[&config_arg, "config", "set", "modules.1", "kernel"]); + assert!(set.status.success()); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("// keep")); + assert!(content.contains("\"kernel\"")); + + let unset = command(&dir, &[&config_arg, "config", "unset", "colors.cpu"]); + assert!(unset.status.success()); + let content = fs::read_to_string(&path).unwrap(); + assert!(!content.contains("\"cpu\": \"red\"")); + assert!(content.contains("\"os\": \"cyan\"")); + + let unset_top = command(&dir, &[&config_arg, "config", "unset", "layout"]); + assert!(unset_top.status.success()); + let content = fs::read_to_string(&path).unwrap(); + assert!(!content.contains("\"layout\"")); +} + +#[test] +fn config_reset_backups_and_prune() { + let dir = temp_dir("config-reset"); + let config_dir = dir.join("config").join("xfetch"); + fs::create_dir_all(&config_dir).unwrap(); + let path = config_dir.join("config.jsonc"); + fs::write(&path, "{\n \"layout\": \"section\"\n}\n").unwrap(); + + let reset = command(&dir, &["config", "reset", "--yes"]); + assert!(reset.status.success()); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("// xfetch configuration")); + + let backups = command(&dir, &["--json", "config", "backups"]); + assert!(backups.status.success()); + let value = stdout_json(&backups); + assert!( + value["backups"] + .as_array() + .is_some_and(|items| !items.is_empty()) + ); + + let backup = command(&dir, &["--json", "--backup"]); + assert!(backup.status.success()); + let prune = command(&dir, &["--json", "config", "prune", "--keep", "0"]); + assert!(prune.status.success()); + let value = stdout_json(&prune); + assert!( + value["deleted"] + .as_array() + .is_some_and(|items| !items.is_empty()) + ); +} + +#[test] +fn backup_compress_and_root_remap() { + let dir = temp_dir("backup-compress"); + let original = dir.join("original").join("xfetch"); + fs::create_dir_all(original.join("themes")).unwrap(); + let config = original.join("config.jsonc"); + let theme = original.join("themes").join("nord.jsonc"); + let config_json = serde_json::json!({ + "theme": theme.display().to_string() + }); + fs::write(&config, serde_json::to_string(&config_json).unwrap()).unwrap(); + fs::write(&theme, "{ \"layout\": \"compact\" }\n").unwrap(); + let config_arg = format!("--config={}", config.display()); + let backup_dir = dir.join("backups"); + fs::create_dir_all(&backup_dir).unwrap(); + let backup = command( + &dir, + &[ + &config_arg, + "config", + "backup", + "--output", + backup_dir.to_str().unwrap(), + "--compress", + ], + ); + assert!(backup.status.success()); + let backup_file = fs::read_dir(&backup_dir) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .find(|path| path.to_string_lossy().ends_with(".json.gz")) + .expect("compressed backup"); + + fs::remove_dir_all(&original).unwrap(); + let restored_root = dir.join("restored"); + let restore = command( + &dir, + &[ + "config", + "restore", + "--from", + backup_file.to_str().unwrap(), + "--root", + restored_root.to_str().unwrap(), + ], + ); + assert!(restore.status.success()); + assert!(restored_root.join("config.jsonc").is_file()); + assert!(restored_root.join("themes").join("nord.jsonc").is_file()); +} + +#[test] +fn template_conditionals_run_from_the_cli() { + let dir = temp_dir("template-blocks"); + let output = command( + &dir, + &[ + "--format", + "{{#if kernel}}kernel-ok{{else}}no-kernel{{/if}} {{#unless nope}}unless-ok{{/unless}}", + ], + ); + assert!(output.status.success()); + let text = String::from_utf8_lossy(&output.stdout); + assert!(text.contains("kernel-ok")); + assert!(text.contains("unless-ok")); +} + +#[test] +fn doctor_fix_generates_a_missing_config() { + let dir = temp_dir("doctor-fix"); + let output = command(&dir, &["--json", "doctor", "--fix"]); + assert!(output.status.success()); + let value = stdout_json(&output); + assert!( + value["fixes"] + .as_array() + .is_some_and(|items| !items.is_empty()) + ); + assert!( + dir.join("config") + .join("xfetch") + .join("config.jsonc") + .is_file() + ); +} + +#[test] +fn schema_and_update_rollback() { + let dir = temp_dir("schema-rollback"); + let schema = command(&dir, &["--json", "schema", "fetch"]); + assert!(schema.status.success()); + let value = stdout_json(&schema); + assert_eq!(value["title"], "xfetch fetch output"); + + let bin_dir = dir.join("bin"); + fs::create_dir_all(&bin_dir).unwrap(); + let binary = if cfg!(windows) { + "xfetch.exe" + } else { + "xfetch" + }; + fs::write(bin_dir.join(binary), b"new").unwrap(); + fs::write(bin_dir.join(format!("{binary}.bak")), b"old").unwrap(); + let rollback = command( + &dir, + &[ + "--json", + "update", + "--rollback", + "--bin-dir", + bin_dir.to_str().unwrap(), + ], + ); + assert!(rollback.status.success()); + let value = stdout_json(&rollback); + assert_eq!(value["status"], "rolled-back"); + assert_eq!(fs::read(bin_dir.join(binary)).unwrap(), b"old"); +} + +#[cfg(unix)] +#[test] +fn self_uninstall_removes_a_copied_binary() { + let dir = temp_dir("self-uninstall"); + let bin_dir = dir.join("bin"); + fs::create_dir_all(&bin_dir).unwrap(); + let binary = bin_dir.join("xfetch"); + fs::copy(env!("CARGO_BIN_EXE_xfetch"), &binary).unwrap(); + let output = Command::new(&binary) + .args(["--json", "self-uninstall", "--purge", "--yes"]) + .env("XDG_CONFIG_HOME", dir.join("config")) + .env("XDG_CACHE_HOME", dir.join("cache")) + .env("HOME", dir.join("home")) + .output() + .unwrap(); + assert!(output.status.success()); + let value = serde_json::from_slice::(&output.stdout).unwrap(); + assert_eq!(value["status"], "uninstalled"); + assert!(!binary.exists()); +} + +#[test] +fn backup_encrypt_roundtrip() { + let dir = temp_dir("backup-encrypt"); + let path = dir.join("config.jsonc"); + fs::write(&path, "{\n \"layout\": \"section\"\n}\n").unwrap(); + let config_arg = format!("--config={}", path.display()); + let passphrase_file = dir.join("pass.txt"); + fs::write(&passphrase_file, "correct horse battery staple\n").unwrap(); + let backup_file = dir.join("backup.enc"); + + let backup = command( + &dir, + &[ + &config_arg, + "config", + "backup", + "--output", + backup_file.to_str().unwrap(), + "--encrypt", + "--passphrase-file", + passphrase_file.to_str().unwrap(), + ], + ); + assert!(backup.status.success()); + assert!(fs::read(&backup_file).unwrap().starts_with(b"XFBACKUP1")); + + fs::write(&path, "{\n \"layout\": \"minimal\"\n}\n").unwrap(); + let restore = command( + &dir, + &[ + &config_arg, + "config", + "restore", + "--from", + backup_file.to_str().unwrap(), + "--passphrase-file", + passphrase_file.to_str().unwrap(), + ], + ); + assert!(restore.status.success()); + assert!( + fs::read_to_string(&path) + .unwrap() + .contains("\"layout\": \"section\"") + ); +} + +#[cfg(unix)] +#[test] +fn config_edit_runs_an_editor_and_validates() { + use std::os::unix::fs::PermissionsExt; + let dir = temp_dir("config-edit-editor"); + let path = dir.join("config.jsonc"); + fs::write(&path, "{\n \"layout\": \"section\"\n}\n").unwrap(); + let config_arg = format!("--config={}", path.display()); + let editor = dir.join("editor.sh"); + fs::write( + &editor, + "#!/bin/sh\ncat > \"$1\" <<'EOF'\n{ \"layout\": \"minimal\" }\nEOF\n", + ) + .unwrap(); + fs::set_permissions(&editor, fs::Permissions::from_mode(0o755)).unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_xfetch")) + .args([&config_arg, "config", "edit"]) + .env("VISUAL", &editor) + .env("EDITOR", &editor) + .env("XDG_CONFIG_HOME", dir.join("config")) + .env("XDG_CACHE_HOME", dir.join("cache")) + .env("XDG_STATE_HOME", dir.join("state")) + .env("HOME", dir.join("home")) + .output() + .unwrap(); + assert!( + output.status.success(), + "config edit failed: status={:?}\nstdout={}\nstderr={}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + fs::read_to_string(&path) + .unwrap() + .contains("\"layout\": \"minimal\"") + ); +} + +#[test] +fn diagnostics_paths_env_and_build_info() { + let dir = temp_dir("diagnostics"); + let paths = command(&dir, &["--json", "paths"]); + assert!(paths.status.success()); + let value = stdout_json(&paths); + assert!(value["config"].is_string()); + assert!(value["config_dir"].is_string()); + assert!(value["backups"].is_string()); + + let env = command(&dir, &["--json", "env"]); + assert!(env.status.success()); + let value = stdout_json(&env); + assert!(value["build"]["version"].is_string()); + assert!(value["platform"]["os"].is_string()); + assert!(value["settings"]["log_level"].is_string()); + + let build = command(&dir, &["--json", "build-info"]); + assert!(build.status.success()); + let value = stdout_json(&build); + assert_eq!(value["features"]["encryption"], true); + assert!(value["target"]["arch"].is_string()); +} + +#[test] +fn doctor_bench_and_image_flags() { + let dir = temp_dir("doctor-bench"); + let bench = command(&dir, &["--json", "doctor", "--bench"]); + assert!(bench.status.success()); + let value = stdout_json(&bench); + assert!(value["benchmark"].as_array().is_some()); + + let image = command(&dir, &["doctor", "--image"]); + assert!(image.status.success()); +} + +#[test] +fn structured_logs_and_levels() { + let dir = temp_dir("logs"); + let output = command( + &dir, + &[ + "--log-json", + "--log-level", + "debug", + "--verbose", + "--get", + "os", + ], + ); + assert!(output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + let lines: Vec<&str> = stderr + .lines() + .filter(|line| !line.trim().is_empty()) + .collect(); + assert!(!lines.is_empty(), "expected debug logs"); + for line in lines { + let value: Value = serde_json::from_str(line).expect("JSON log line"); + assert_eq!(value["level"], "debug"); + assert_eq!(value["target"], "xfetch"); + } + + let silent = command(&dir, &["--log-level", "off", "--verbose", "--get", "os"]); + assert!(silent.status.success()); + assert!(silent.stderr.is_empty()); +} + +#[test] +fn help_is_available_for_command_groups() { + let dir = temp_dir("help-groups"); + for args in [ + vec!["help", "plugin", "install"], + vec!["help", "theme", "install"], + vec!["help", "config", "backup"], + vec!["doctor", "--help"], + ] { + let output = command(&dir, &args); + assert!(output.status.success(), "help failed for {:?}", args); + assert!(!output.stdout.is_empty(), "empty help for {:?}", args); + } +} + +#[cfg(unix)] +#[test] +fn command_addon_list_run_and_remove() { + use std::os::unix::fs::PermissionsExt; + let dir = temp_dir("command-addon"); + let commands = dir.join("config").join("xfetch").join("commands"); + fs::create_dir_all(&commands).unwrap(); + let script = commands.join("xfetch-command-echo"); + fs::write( + &script, + "#!/bin/sh\nprintf 'repo: %s\\nauthor: test\\n' \"$1\"\n", + ) + .unwrap(); + fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); + + let list = command(&dir, &["--json", "command", "list"]); + assert!(list.status.success()); + let value = stdout_json(&list); + assert_eq!(value["kind"], "command"); + assert!( + value["addons"] + .as_array() + .is_some_and(|entries| entries.iter().any(|entry| entry["name"] == "echo")) + ); + + let run = command(&dir, &["run", "echo", "--", "owner/repo"]); + assert!(run.status.success()); + let text = String::from_utf8_lossy(&run.stdout); + assert!(text.contains("repo: owner/repo")); + assert!(text.contains("author: test")); + + let json = command(&dir, &["--json", "run", "echo", "--", "owner/repo"]); + assert!(json.status.success()); + let value = stdout_json(&json); + assert_eq!(value["command"], "echo"); + assert!( + value["lines"] + .as_array() + .is_some_and(|lines| lines.iter().any(|line| line == "repo: owner/repo")) + ); + + let remove = command(&dir, &["command", "remove", "echo"]); + assert!(remove.status.success()); + assert!(!script.exists()); +} + +#[cfg(unix)] +#[test] +fn run_does_not_touch_or_require_the_loaded_config() { + use std::os::unix::fs::PermissionsExt; + let dir = temp_dir("command-config-independence"); + let commands = dir.join("config").join("xfetch").join("commands"); + fs::create_dir_all(&commands).unwrap(); + let script = commands.join("xfetch-command-echo"); + fs::write(&script, "#!/bin/sh\nprintf 'repo: %s\\n' \"$1\"\n").unwrap(); + fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); + + // Deliberately invalid config: `run` without --render must not parse it. + let config = dir.join("config.jsonc"); + let sentinel = b"{\n \"layout\": \"this is not valid json\"\n"; + fs::write(&config, sentinel).unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_xfetch")) + .args([ + "--config", + config.to_str().unwrap(), + "run", + "echo", + "--", + "owner/repo", + ]) + .env("XDG_CONFIG_HOME", dir.join("config")) + .env("XDG_CACHE_HOME", dir.join("cache")) + .env("HOME", dir.join("home")) + .output() + .unwrap(); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("repo: owner/repo")); + assert_eq!( + fs::read(&config).unwrap(), + sentinel, + "config must stay untouched" + ); +} diff --git a/tests/help_snapshots.rs b/tests/help_snapshots.rs new file mode 100644 index 0000000..871fd6b --- /dev/null +++ b/tests/help_snapshots.rs @@ -0,0 +1,135 @@ +use std::process::Command; + +fn run(args: &[&str]) -> String { + let output = Command::new(env!("CARGO_BIN_EXE_xfetch")) + .args(args) + .env("COLUMNS", "100") + .env("NO_COLOR", "1") + .output() + .expect("run xfetch"); + assert!( + output.status.success(), + "command failed: {:?}\n{}", + args, + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout) + .expect("utf-8 output") + .replace("xfetch.exe", "xfetch") +} + +fn snapshot(name: &str, actual: &str) { + let expected = match name { + "help_main" => include_str!("snapshots/help_main.txt"), + "help_addon" => include_str!("snapshots/help_addon.txt"), + "help_plugin" => include_str!("snapshots/help_plugin.txt"), + "help_theme" => include_str!("snapshots/help_theme.txt"), + "help_logo" => include_str!("snapshots/help_logo.txt"), + "help_wasm" => include_str!("snapshots/help_wasm.txt"), + "help_update" => include_str!("snapshots/help_update.txt"), + "help_doctor" => include_str!("snapshots/help_doctor.txt"), + "help_modules" => include_str!("snapshots/help_modules.txt"), + "help_config" => include_str!("snapshots/help_config.txt"), + "help_config_backup" => include_str!("snapshots/help_config_backup.txt"), + "help_config_restore" => include_str!("snapshots/help_config_restore.txt"), + "help_completions" => include_str!("snapshots/help_completions.txt"), + "help_man" => include_str!("snapshots/help_man.txt"), + "help_schema" => include_str!("snapshots/help_schema.txt"), + "help_self_uninstall" => include_str!("snapshots/help_self_uninstall.txt"), + "help_paths" => include_str!("snapshots/help_paths.txt"), + "help_env" => include_str!("snapshots/help_env.txt"), + "help_build_info" => include_str!("snapshots/help_build_info.txt"), + "help_plugin_install" => include_str!("snapshots/help_plugin_install.txt"), + "help_theme_install" => include_str!("snapshots/help_theme_install.txt"), + "help_command" => include_str!("snapshots/help_command.txt"), + "help_run" => include_str!("snapshots/help_run.txt"), + "help_command_install" => include_str!("snapshots/help_command_install.txt"), + "list_layouts" => include_str!("snapshots/list_layouts.txt"), + "modules_json" => include_str!("snapshots/modules_json.txt"), + "schema_fetch" => include_str!("snapshots/schema_fetch.txt"), + _ => panic!("unknown snapshot {name}"), + }; + assert_eq!(actual, expected, "snapshot '{name}' changed"); +} + +#[test] +fn help_outputs_match_snapshots() { + snapshot("help_main", &run(&["--help"])); + snapshot("help_addon", &run(&["addon", "--help"])); + snapshot("help_plugin", &run(&["plugin", "--help"])); + snapshot("help_theme", &run(&["theme", "--help"])); + snapshot("help_logo", &run(&["logo", "--help"])); + snapshot("help_wasm", &run(&["wasm", "--help"])); + snapshot("help_update", &run(&["update", "--help"])); + snapshot("help_doctor", &run(&["doctor", "--help"])); + snapshot("help_modules", &run(&["modules", "--help"])); + snapshot("help_config", &run(&["config", "--help"])); + snapshot("help_config_backup", &run(&["config", "backup", "--help"])); + snapshot( + "help_config_restore", + &run(&["config", "restore", "--help"]), + ); + snapshot("help_completions", &run(&["completions", "--help"])); + snapshot("help_man", &run(&["man", "--help"])); + snapshot("help_schema", &run(&["schema", "--help"])); + snapshot("help_self_uninstall", &run(&["self-uninstall", "--help"])); + snapshot("help_paths", &run(&["paths", "--help"])); + snapshot("help_env", &run(&["env", "--help"])); + snapshot("help_build_info", &run(&["build-info", "--help"])); + snapshot( + "help_plugin_install", + &run(&["plugin", "install", "--help"]), + ); + snapshot("help_theme_install", &run(&["theme", "install", "--help"])); + snapshot("help_command", &run(&["command", "--help"])); + snapshot("help_run", &run(&["run", "--help"])); + snapshot( + "help_command_install", + &run(&["command", "install", "--help"]), + ); +} + +#[test] +fn deterministic_outputs_match_snapshots() { + snapshot("list_layouts", &run(&["--list-layouts"])); + snapshot("modules_json", &run(&["--json", "modules"])); + snapshot("schema_fetch", &run(&["--json", "schema", "fetch"])); +} + +#[test] +fn help_output_is_ascii_and_has_no_emojis() { + let commands: &[&[&str]] = &[ + &["--help"], + &["plugin", "--help"], + &["plugin", "install", "--help"], + &["theme", "--help"], + &["theme", "install", "--help"], + &["logo", "--help"], + &["addon", "--help"], + &["wasm", "--help"], + &["update", "--help"], + &["doctor", "--help"], + &["modules", "--help"], + &["config", "--help"], + &["config", "backup", "--help"], + &["config", "restore", "--help"], + &["completions", "--help"], + &["man", "--help"], + &["schema", "--help"], + &["paths", "--help"], + &["env", "--help"], + &["build-info", "--help"], + &["self-uninstall", "--help"], + &["command", "--help"], + &["command", "install", "--help"], + &["run", "--help"], + ]; + for args in commands { + let output = run(args); + assert!( + output.is_ascii(), + "help output for {:?} contains non-ASCII characters", + args + ); + } +} diff --git a/tests/snapshots/help_addon.txt b/tests/snapshots/help_addon.txt new file mode 100644 index 0000000..29dbe2b --- /dev/null +++ b/tests/snapshots/help_addon.txt @@ -0,0 +1,48 @@ +Manage any addon kind with one command family + +Usage: xfetch addon [OPTIONS] + +Commands: + install Install an addon from a catalog name, local path, repository, wasm file or URL + update Update an installed addon, or every installed addon with --all + rollback Restore the previous artifact saved by the last update + list List installed addons + info Show one installed addon and its configuration snippet + search Search the remote catalog. Omit the query to list everything + remove Remove an installed addon + help Print this message or the help of the given subcommand(s) + +Arguments: + [possible values: plugin, effect, extension, command] + +Options: + -c, --config + --gen-config + --logo Logo to embed in the generated config, overriding the detected OS/distro (e.g. `--logo arch`, `--logo windows-11`). Only used with `--gen-config`; requires network access to the logos catalog + --layout Layout name. Overrides the configured layout at runtime; with `--gen-config` it selects the generated layout instead + --theme Theme name. Overrides the configured theme at runtime + --list-layouts Print the layout names accepted by `--layout` and exit + --list-modules Print the module keys accepted in the config and exit + --clean-cache + --backup Back up the config currently in use, including its theme and config-roulette routes + --restore Restore the most recent config backup + --benchmark + --daemon + --daemon-stop + --no-daemon-live Disable the live stats daemon (`daemon_live` in config) from the terminal + --daemon-live-stop Stop the running live stats daemon + --daemon-live-reload Force hot reload in the live stats daemon (equivalent to `"daemon_live_reload": true` in the config) + --json Emit machine-readable JSON + --get Print only specific module keys. Repeat the flag or use commas: `--get os,cpu --get memory` + --modules Override the rendered module list with a comma-separated key list + --color Control color output [default: auto] [possible values: auto, always, never] + --no-color Disable colors; same as `--color never` + --offline Disable network access (public IP, catalogs, update checks, logo fetch) + --no-cache Bypass the package/public-IP cache for this run + --quiet Suppress informational output + --verbose Print extra diagnostics + --log-level Log level: off, error, warn, info, debug or trace. Overrides --quiet/--verbose [possible values: off, error, warn, info, debug, trace] + --log-json Emit logs as JSON lines on stderr + --template