rewrite uberspace-cli in rust (phase 1 + 2)
single binary `uc` replaces bash asteroids script, unifies v7/v8 behind a stable interface with TOML config. implements registry CRUD, SSH passthrough with v7/v8 translation, status collection/caching, legacy import. 24 unit tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target/
|
||||||
46
AI_AGENT_REPORT.md
Normal file
46
AI_AGENT_REPORT.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# AI Agent Report — uberspace-cli
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Phase 1 + 2 of the Rust rewrite are complete. The `uc` binary compiles and all 24 unit tests pass.
|
||||||
|
|
||||||
|
## What was done
|
||||||
|
|
||||||
|
- Created Rust project (`Cargo.toml`, binary name `uc`, CalVer `2026.3.3`)
|
||||||
|
- Implemented all Phase 1 + 2 modules:
|
||||||
|
- `registry.rs` — TOML-based asteroid CRUD (`~/.config/uc/registry.toml`)
|
||||||
|
- `ssh.rs` — passthrough (`ssh -t`) and capture (for status collection)
|
||||||
|
- `cache.rs` — per-asteroid TOML status cache (`~/.config/uc/cache/*.toml`)
|
||||||
|
- `translate.rs` — v7/v8 command translation (mail, port, tools, web backend)
|
||||||
|
- `status.rs` — remote status collection via SSH, colored display, age/staleness
|
||||||
|
- `cli.rs` — clap-derived CLI with fallback to asteroid passthrough
|
||||||
|
- `uc import <path>` migrates legacy `asteroids.list` + cache files
|
||||||
|
|
||||||
|
## CLI command tree
|
||||||
|
|
||||||
|
```
|
||||||
|
uc add <name> <server> <version> # register asteroid
|
||||||
|
uc list # list registered
|
||||||
|
uc remove <name> # deregister
|
||||||
|
uc status [--refresh] # aggregate from cache (or refresh all)
|
||||||
|
uc <name> status # refresh + show one asteroid
|
||||||
|
uc <name> <args...> # passthrough → SSH with v7/v8 translation
|
||||||
|
uc import <path> # migrate from asteroids.list
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture notes
|
||||||
|
|
||||||
|
- Clap `try_parse()` + manual fallback handles the `uc <name> <args...>` passthrough pattern
|
||||||
|
- SSH passthrough preserves the user's SSH config/keys — no Rust SSH library needed
|
||||||
|
- Remote status script is the same bash heredoc from the original, embedded as a const
|
||||||
|
- v8 Rich-table box-drawing chrome is stripped by the remote script before parsing
|
||||||
|
|
||||||
|
## What's next (Phase 3)
|
||||||
|
|
||||||
|
- Dashboard HTTP API: `reqwest` + `serde_json` for login, create-asteroid, add-ssh-key, delete
|
||||||
|
- Session management: cookies + CSRF token in `~/.config/uc/session.toml`
|
||||||
|
|
||||||
|
## Known considerations
|
||||||
|
|
||||||
|
- The `cache::load()` function exists but is currently only used transitively via `load_all()` — produces a dead-code warning
|
||||||
|
- Edition 2024 requires Rust 1.85+; current toolchain is 1.93
|
||||||
928
Cargo.lock
generated
Normal file
928
Cargo.lock
generated
Normal file
@@ -0,0 +1,928 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android_system_properties"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "0.6.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "0.2.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyhow"
|
||||||
|
version = "1.0.102"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "autocfg"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bumpalo"
|
||||||
|
version = "3.20.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.2.56"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chrono"
|
||||||
|
version = "0.4.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||||
|
dependencies = [
|
||||||
|
"iana-time-zone",
|
||||||
|
"num-traits",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.5.60"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.5.60"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
"strsim",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_derive"
|
||||||
|
version = "4.5.55"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colored"
|
||||||
|
version = "3.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation-sys"
|
||||||
|
version = "0.8.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||||
|
dependencies = [
|
||||||
|
"dirs-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs-sys"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"option-ext",
|
||||||
|
"redox_users",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
"wasip2",
|
||||||
|
"wasip3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.15.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
|
dependencies = [
|
||||||
|
"foldhash",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.16.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone"
|
||||||
|
version = "0.1.65"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||||
|
dependencies = [
|
||||||
|
"android_system_properties",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"iana-time-zone-haiku",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone-haiku"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "id-arena"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown 0.16.1",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "js-sys"
|
||||||
|
version = "0.3.91"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "leb128fmt"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.182"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libredox"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-traits"
|
||||||
|
version = "0.2.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "option-ext"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prettyplease"
|
||||||
|
version = "0.2.37"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_users"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libredox",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "semver"
|
||||||
|
version = "1.0.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.149"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "0.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.117"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.26.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom 0.4.2",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.8.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_edit",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.6.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.22.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_write",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_write"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uc"
|
||||||
|
version = "2026.3.3"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"clap",
|
||||||
|
"colored",
|
||||||
|
"dirs",
|
||||||
|
"serde",
|
||||||
|
"tempfile",
|
||||||
|
"toml",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-xid"
|
||||||
|
version = "0.2.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasip2"
|
||||||
|
version = "1.0.2+wasi-0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasip3"
|
||||||
|
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen"
|
||||||
|
version = "0.2.114"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"rustversion",
|
||||||
|
"wasm-bindgen-macro",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro"
|
||||||
|
version = "0.2.114"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"wasm-bindgen-macro-support",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro-support"
|
||||||
|
version = "0.2.114"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-shared"
|
||||||
|
version = "0.2.114"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-encoder"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||||
|
dependencies = [
|
||||||
|
"leb128fmt",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-metadata"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"indexmap",
|
||||||
|
"wasm-encoder",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasmparser"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"hashbrown 0.15.5",
|
||||||
|
"indexmap",
|
||||||
|
"semver",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.62.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-implement",
|
||||||
|
"windows-interface",
|
||||||
|
"windows-link",
|
||||||
|
"windows-result",
|
||||||
|
"windows-strings",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.60.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.59.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-strings"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen-rust-macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-core"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"heck",
|
||||||
|
"wit-parser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-rust"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"heck",
|
||||||
|
"indexmap",
|
||||||
|
"prettyplease",
|
||||||
|
"syn",
|
||||||
|
"wasm-metadata",
|
||||||
|
"wit-bindgen-core",
|
||||||
|
"wit-component",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-rust-macro"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"prettyplease",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"wit-bindgen-core",
|
||||||
|
"wit-bindgen-rust",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-component"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"bitflags",
|
||||||
|
"indexmap",
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"wasm-encoder",
|
||||||
|
"wasm-metadata",
|
||||||
|
"wasmparser",
|
||||||
|
"wit-parser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-parser"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"id-arena",
|
||||||
|
"indexmap",
|
||||||
|
"log",
|
||||||
|
"semver",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"unicode-xid",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
19
Cargo.toml
Normal file
19
Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "uc"
|
||||||
|
version = "2026.3.3"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "uc"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
toml = "0.8"
|
||||||
|
dirs = "6"
|
||||||
|
colored = "3"
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
111
src/cache.rs
Normal file
111
src/cache.rs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct CachedStatus {
|
||||||
|
pub updated: String,
|
||||||
|
pub name: String,
|
||||||
|
pub server: String,
|
||||||
|
pub version: u8,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ports: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub web_domains: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mail_domains: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mail_users: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cache_dir() -> Result<PathBuf, String> {
|
||||||
|
let config_dir =
|
||||||
|
dirs::config_dir().ok_or_else(|| "cannot determine config directory".to_string())?;
|
||||||
|
Ok(config_dir.join("uc").join("cache"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(status: &CachedStatus) -> Result<(), String> {
|
||||||
|
let dir = cache_dir()?;
|
||||||
|
fs::create_dir_all(&dir).map_err(|e| format!("failed to create cache directory: {e}"))?;
|
||||||
|
let path = dir.join(format!("{}.toml", status.name));
|
||||||
|
let content =
|
||||||
|
toml::to_string_pretty(status).map_err(|e| format!("failed to serialize cache: {e}"))?;
|
||||||
|
fs::write(&path, content).map_err(|e| format!("failed to write cache: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(name: &str) -> Result<Option<CachedStatus>, String> {
|
||||||
|
let path = cache_dir()?.join(format!("{name}.toml"));
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let content = fs::read_to_string(&path).map_err(|e| format!("failed to read cache: {e}"))?;
|
||||||
|
let status: CachedStatus =
|
||||||
|
toml::from_str(&content).map_err(|e| format!("failed to parse cache: {e}"))?;
|
||||||
|
Ok(Some(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_all() -> Result<Vec<CachedStatus>, String> {
|
||||||
|
let dir = cache_dir()?;
|
||||||
|
if !dir.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let entries = fs::read_dir(&dir).map_err(|e| format!("failed to read cache directory: {e}"))?;
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|e| format!("failed to read cache entry: {e}"))?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|e| e.to_str()) == Some("toml") {
|
||||||
|
let content =
|
||||||
|
fs::read_to_string(&path).map_err(|e| format!("failed to read cache file: {e}"))?;
|
||||||
|
match toml::from_str::<CachedStatus>(&content) {
|
||||||
|
Ok(status) => results.push(status),
|
||||||
|
Err(e) => eprintln!("warning: skipping {}: {e}", path.display()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(name: &str) -> Result<(), String> {
|
||||||
|
let path = cache_dir()?.join(format!("{name}.toml"));
|
||||||
|
if path.exists() {
|
||||||
|
fs::remove_file(&path).map_err(|e| format!("failed to remove cache: {e}"))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_serialize() {
|
||||||
|
let status = CachedStatus {
|
||||||
|
updated: "2026-02-25T17:31:06".into(),
|
||||||
|
name: "danger".into(),
|
||||||
|
server: "cetus.uberspace.de".into(),
|
||||||
|
version: 7,
|
||||||
|
ports: vec![],
|
||||||
|
web_domains: vec!["danger.uber.space".into(), "rhqq2.de".into()],
|
||||||
|
mail_domains: vec!["danger.uber.space".into()],
|
||||||
|
mail_users: vec![],
|
||||||
|
};
|
||||||
|
let serialized = toml::to_string_pretty(&status).unwrap();
|
||||||
|
let deserialized: CachedStatus = toml::from_str(&serialized).unwrap();
|
||||||
|
assert_eq!(status, deserialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deserialize_empty_lists() {
|
||||||
|
let toml_str = r#"
|
||||||
|
updated = "2026-01-01T00:00:00"
|
||||||
|
name = "test"
|
||||||
|
server = "test.uberspace.de"
|
||||||
|
version = 7
|
||||||
|
"#;
|
||||||
|
let status: CachedStatus = toml::from_str(toml_str).unwrap();
|
||||||
|
assert!(status.ports.is_empty());
|
||||||
|
assert!(status.web_domains.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
310
src/cli.rs
Normal file
310
src/cli.rs
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::process;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use colored::Colorize;
|
||||||
|
|
||||||
|
use crate::cache;
|
||||||
|
use crate::registry::{self, Asteroid, Registry};
|
||||||
|
use crate::ssh;
|
||||||
|
use crate::status;
|
||||||
|
use crate::translate;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "uc", about = "uberspace account manager")]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<Commands>,
|
||||||
|
|
||||||
|
/// Asteroid name (for passthrough commands)
|
||||||
|
#[arg(num_args = 1..)]
|
||||||
|
args: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Register an asteroid
|
||||||
|
Add {
|
||||||
|
/// Account name
|
||||||
|
name: String,
|
||||||
|
/// Server hostname (e.g. cetus.uberspace.de)
|
||||||
|
server: String,
|
||||||
|
/// Uberspace version (7 or 8)
|
||||||
|
version: u8,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// List registered asteroids
|
||||||
|
List,
|
||||||
|
|
||||||
|
/// Deregister an asteroid
|
||||||
|
Remove {
|
||||||
|
/// Account name
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Show aggregate status from cache (or refresh all via SSH)
|
||||||
|
Status {
|
||||||
|
/// Refresh all accounts via SSH before showing
|
||||||
|
#[arg(long)]
|
||||||
|
refresh: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Import asteroids from the legacy asteroids.list file
|
||||||
|
Import {
|
||||||
|
/// Path to the legacy asteroids.list file
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run() {
|
||||||
|
// clap's derive approach doesn't easily support `uc <name> <args...>` alongside
|
||||||
|
// named subcommands. We handle this by trying clap first, and falling back to
|
||||||
|
// manual parsing for asteroid passthrough.
|
||||||
|
let result = match Cli::try_parse() {
|
||||||
|
Ok(cli) => dispatch(cli),
|
||||||
|
Err(e) => {
|
||||||
|
// If clap fails because args don't match a subcommand, try passthrough
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
if args.is_empty() {
|
||||||
|
e.exit();
|
||||||
|
}
|
||||||
|
// Check if first arg is a registered asteroid
|
||||||
|
let reg_path = match registry::registry_path() {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => e.exit(),
|
||||||
|
};
|
||||||
|
let reg = match Registry::load(®_path) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => e.exit(),
|
||||||
|
};
|
||||||
|
if reg.lookup(&args[0]).is_some() {
|
||||||
|
handle_passthrough(&args[0], &args[1..])
|
||||||
|
} else {
|
||||||
|
e.exit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(msg) = result {
|
||||||
|
eprintln!("{} {msg}", "[error]".red());
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch(cli: Cli) -> Result<(), String> {
|
||||||
|
match cli.command {
|
||||||
|
Some(Commands::Add {
|
||||||
|
name,
|
||||||
|
server,
|
||||||
|
version,
|
||||||
|
}) => cmd_add(&name, &server, version),
|
||||||
|
Some(Commands::List) => cmd_list(),
|
||||||
|
Some(Commands::Remove { name }) => cmd_remove(&name),
|
||||||
|
Some(Commands::Status { refresh }) => cmd_status(refresh),
|
||||||
|
Some(Commands::Import { path }) => cmd_import(&path),
|
||||||
|
None => {
|
||||||
|
// Fall through to passthrough if args present
|
||||||
|
if cli.args.is_empty() {
|
||||||
|
Cli::parse_from(["uc", "--help"]);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
handle_passthrough(&cli.args[0], &cli.args[1..])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_add(name: &str, server: &str, version: u8) -> Result<(), String> {
|
||||||
|
if version != 7 && version != 8 {
|
||||||
|
return Err(format!("version must be 7 or 8, got {version}"));
|
||||||
|
}
|
||||||
|
let path = registry::registry_path()?;
|
||||||
|
let mut reg = Registry::load(&path)?;
|
||||||
|
reg.add(Asteroid {
|
||||||
|
name: name.to_string(),
|
||||||
|
server: server.to_string(),
|
||||||
|
version,
|
||||||
|
});
|
||||||
|
reg.save(&path)?;
|
||||||
|
let ver_info = format!("u{version}");
|
||||||
|
eprintln!(
|
||||||
|
"{} registered: {name} @ {server} ({ver_info})",
|
||||||
|
"[ok]".green()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_list() -> Result<(), String> {
|
||||||
|
let path = registry::registry_path()?;
|
||||||
|
let reg = Registry::load(&path)?;
|
||||||
|
if reg.asteroid.is_empty() {
|
||||||
|
eprintln!(
|
||||||
|
"{} no accounts registered. Use: uc add <name> <server> <version>",
|
||||||
|
"[warn]".yellow()
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
" {}",
|
||||||
|
format!("{:<12} {:<28} {}", "NAME", "SERVER", "VER").bold()
|
||||||
|
);
|
||||||
|
println!("{}", "\u{2500}".repeat(49).dimmed());
|
||||||
|
for a in ®.asteroid {
|
||||||
|
println!(
|
||||||
|
" {:<12} {:<28} u{}",
|
||||||
|
a.name, a.server, a.version
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_remove(name: &str) -> Result<(), String> {
|
||||||
|
let path = registry::registry_path()?;
|
||||||
|
let mut reg = Registry::load(&path)?;
|
||||||
|
if !reg.remove(name) {
|
||||||
|
return Err(format!("'{name}' not found in registry"));
|
||||||
|
}
|
||||||
|
reg.save(&path)?;
|
||||||
|
cache::remove(name)?;
|
||||||
|
eprintln!("{} removed: {name}", "[ok]".green());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_status(refresh: bool) -> Result<(), String> {
|
||||||
|
let path = registry::registry_path()?;
|
||||||
|
let reg = Registry::load(&path)?;
|
||||||
|
if reg.asteroid.is_empty() {
|
||||||
|
eprintln!(
|
||||||
|
"{} no accounts registered. Use: uc add <name> <server> <version>",
|
||||||
|
"[warn]".yellow()
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
status::show_all(refresh, ®.asteroid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_import(legacy_path: &str) -> Result<(), String> {
|
||||||
|
let content =
|
||||||
|
fs::read_to_string(legacy_path).map_err(|e| format!("failed to read {legacy_path}: {e}"))?;
|
||||||
|
|
||||||
|
let reg_path = registry::registry_path()?;
|
||||||
|
let mut reg = Registry::load(®_path)?;
|
||||||
|
let mut count = 0;
|
||||||
|
|
||||||
|
for line in content.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if parts.len() < 2 {
|
||||||
|
eprintln!(
|
||||||
|
"{} skipping malformed line: {line}",
|
||||||
|
"[warn]".yellow()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = parts[0];
|
||||||
|
let server = parts[1];
|
||||||
|
let version: u8 = parts
|
||||||
|
.get(2)
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(7);
|
||||||
|
|
||||||
|
reg.add(Asteroid {
|
||||||
|
name: name.to_string(),
|
||||||
|
server: server.to_string(),
|
||||||
|
version,
|
||||||
|
});
|
||||||
|
count += 1;
|
||||||
|
|
||||||
|
// Try to import the corresponding cache file
|
||||||
|
import_cache_file(legacy_path, name)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
reg.save(®_path)?;
|
||||||
|
eprintln!(
|
||||||
|
"{} imported {count} asteroid(s) to {}",
|
||||||
|
"[ok]".green(),
|
||||||
|
reg_path.display()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a single cache file from the legacy format (KEY=VALUE) to TOML.
|
||||||
|
fn import_cache_file(legacy_list_path: &str, name: &str) -> Result<(), String> {
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
let legacy_dir = Path::new(legacy_list_path)
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| "cannot determine legacy directory".to_string())?;
|
||||||
|
let cache_path = legacy_dir.join("cache").join(name);
|
||||||
|
|
||||||
|
if !cache_path.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&cache_path)
|
||||||
|
.map_err(|e| format!("failed to read cache for {name}: {e}"))?;
|
||||||
|
|
||||||
|
let mut status = cache::CachedStatus::default();
|
||||||
|
status.name = name.to_string();
|
||||||
|
|
||||||
|
for line in content.lines() {
|
||||||
|
let line = line.trim_end_matches('\r');
|
||||||
|
if let Some(val) = line.strip_prefix("UPDATED=") {
|
||||||
|
status.updated = val.to_string();
|
||||||
|
} else if let Some(val) = line.strip_prefix("SERVER=") {
|
||||||
|
status.server = val.to_string();
|
||||||
|
} else if let Some(val) = line.strip_prefix("VERSION=") {
|
||||||
|
status.version = val.parse().unwrap_or(7);
|
||||||
|
} else if let Some(val) = line.strip_prefix("PORTS=") {
|
||||||
|
status.ports = parse_legacy_csv(val);
|
||||||
|
} else if let Some(val) = line.strip_prefix("WEB_DOMAINS=") {
|
||||||
|
status.web_domains = parse_legacy_csv(val);
|
||||||
|
} else if let Some(val) = line.strip_prefix("MAIL_DOMAINS=") {
|
||||||
|
status.mail_domains = parse_legacy_csv(val);
|
||||||
|
} else if let Some(val) = line.strip_prefix("MAIL_USERS=") {
|
||||||
|
status.mail_users = parse_legacy_csv(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cache::save(&status)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_legacy_csv(s: &str) -> Vec<String> {
|
||||||
|
if s.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
s.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty() && s != "No mailboxes found.")
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_passthrough(name: &str, rest: &[String]) -> Result<(), String> {
|
||||||
|
let reg_path = registry::registry_path()?;
|
||||||
|
let reg = Registry::load(®_path)?;
|
||||||
|
let asteroid = reg
|
||||||
|
.lookup(name)
|
||||||
|
.ok_or_else(|| format!("'{name}' not found in registry"))?;
|
||||||
|
|
||||||
|
// Special case: `uc <name> status`
|
||||||
|
if rest.first().is_some_and(|s| s == "status") {
|
||||||
|
let s = status::refresh_one(asteroid)?;
|
||||||
|
println!();
|
||||||
|
let age = "just now".to_string();
|
||||||
|
status::print_status(&s, &age);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate v7/v8 and pass through to SSH
|
||||||
|
let args: Vec<String> = rest.to_vec();
|
||||||
|
let translated = translate::translate(asteroid.version, &args)?;
|
||||||
|
let code = ssh::passthrough(asteroid, &translated)?;
|
||||||
|
if code != 0 {
|
||||||
|
process::exit(code);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
10
src/main.rs
Normal file
10
src/main.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
mod cache;
|
||||||
|
mod cli;
|
||||||
|
mod registry;
|
||||||
|
mod ssh;
|
||||||
|
mod status;
|
||||||
|
mod translate;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
cli::run();
|
||||||
|
}
|
||||||
167
src/registry.rs
Normal file
167
src/registry.rs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct Asteroid {
|
||||||
|
pub name: String,
|
||||||
|
pub server: String,
|
||||||
|
pub version: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Registry {
|
||||||
|
#[serde(default)]
|
||||||
|
pub asteroid: Vec<Asteroid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Registry {
|
||||||
|
pub fn load(path: &PathBuf) -> Result<Self, String> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Registry::default());
|
||||||
|
}
|
||||||
|
let content =
|
||||||
|
fs::read_to_string(path).map_err(|e| format!("failed to read registry: {e}"))?;
|
||||||
|
toml::from_str(&content).map_err(|e| format!("failed to parse registry: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, path: &PathBuf) -> Result<(), String> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("failed to create config directory: {e}"))?;
|
||||||
|
}
|
||||||
|
let content =
|
||||||
|
toml::to_string_pretty(self).map_err(|e| format!("failed to serialize registry: {e}"))?;
|
||||||
|
fs::write(path, content).map_err(|e| format!("failed to write registry: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add(&mut self, asteroid: Asteroid) {
|
||||||
|
self.asteroid.retain(|a| a.name != asteroid.name);
|
||||||
|
self.asteroid.push(asteroid);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&mut self, name: &str) -> bool {
|
||||||
|
let before = self.asteroid.len();
|
||||||
|
self.asteroid.retain(|a| a.name != name);
|
||||||
|
self.asteroid.len() < before
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lookup(&self, name: &str) -> Option<&Asteroid> {
|
||||||
|
self.asteroid.iter().find(|a| a.name == name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn registry_path() -> Result<PathBuf, String> {
|
||||||
|
let config_dir =
|
||||||
|
dirs::config_dir().ok_or_else(|| "cannot determine config directory".to_string())?;
|
||||||
|
Ok(config_dir.join("uc").join("registry.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
fn temp_path() -> (NamedTempFile, PathBuf) {
|
||||||
|
let f = NamedTempFile::new().unwrap();
|
||||||
|
let p = f.path().to_path_buf();
|
||||||
|
(f, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_and_lookup() {
|
||||||
|
let mut reg = Registry::default();
|
||||||
|
reg.add(Asteroid {
|
||||||
|
name: "danger".into(),
|
||||||
|
server: "cetus.uberspace.de".into(),
|
||||||
|
version: 7,
|
||||||
|
});
|
||||||
|
let a = reg.lookup("danger").unwrap();
|
||||||
|
assert_eq!(a.server, "cetus.uberspace.de");
|
||||||
|
assert_eq!(a.version, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_replaces_existing() {
|
||||||
|
let mut reg = Registry::default();
|
||||||
|
reg.add(Asteroid {
|
||||||
|
name: "danger".into(),
|
||||||
|
server: "old.uberspace.de".into(),
|
||||||
|
version: 7,
|
||||||
|
});
|
||||||
|
reg.add(Asteroid {
|
||||||
|
name: "danger".into(),
|
||||||
|
server: "new.uberspace.de".into(),
|
||||||
|
version: 8,
|
||||||
|
});
|
||||||
|
assert_eq!(reg.asteroid.len(), 1);
|
||||||
|
assert_eq!(reg.lookup("danger").unwrap().server, "new.uberspace.de");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_existing() {
|
||||||
|
let mut reg = Registry::default();
|
||||||
|
reg.add(Asteroid {
|
||||||
|
name: "danger".into(),
|
||||||
|
server: "cetus.uberspace.de".into(),
|
||||||
|
version: 7,
|
||||||
|
});
|
||||||
|
assert!(reg.remove("danger"));
|
||||||
|
assert!(reg.lookup("danger").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_nonexistent() {
|
||||||
|
let mut reg = Registry::default();
|
||||||
|
assert!(!reg.remove("nope"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_save_load() {
|
||||||
|
let (_f, path) = temp_path();
|
||||||
|
let mut reg = Registry::default();
|
||||||
|
reg.add(Asteroid {
|
||||||
|
name: "danger".into(),
|
||||||
|
server: "cetus.uberspace.de".into(),
|
||||||
|
version: 7,
|
||||||
|
});
|
||||||
|
reg.add(Asteroid {
|
||||||
|
name: "impstr".into(),
|
||||||
|
server: "pandora.uberspace.de".into(),
|
||||||
|
version: 8,
|
||||||
|
});
|
||||||
|
reg.save(&path).unwrap();
|
||||||
|
let loaded = Registry::load(&path).unwrap();
|
||||||
|
assert_eq!(loaded.asteroid.len(), 2);
|
||||||
|
assert_eq!(loaded.lookup("danger").unwrap().version, 7);
|
||||||
|
assert_eq!(loaded.lookup("impstr").unwrap().version, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_nonexistent_returns_empty() {
|
||||||
|
let path = PathBuf::from("/tmp/uc-test-nonexistent-registry.toml");
|
||||||
|
let reg = Registry::load(&path).unwrap();
|
||||||
|
assert!(reg.asteroid.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_toml_format() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[asteroid]]
|
||||||
|
name = "danger"
|
||||||
|
server = "cetus.uberspace.de"
|
||||||
|
version = 7
|
||||||
|
|
||||||
|
[[asteroid]]
|
||||||
|
name = "impstr"
|
||||||
|
server = "pandora.uberspace.de"
|
||||||
|
version = 8
|
||||||
|
"#;
|
||||||
|
let (_f, path) = temp_path();
|
||||||
|
let mut file = fs::File::create(&path).unwrap();
|
||||||
|
file.write_all(toml_str.as_bytes()).unwrap();
|
||||||
|
let reg = Registry::load(&path).unwrap();
|
||||||
|
assert_eq!(reg.asteroid.len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/ssh.rs
Normal file
58
src/ssh.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use crate::registry::Asteroid;
|
||||||
|
|
||||||
|
/// Run `ssh -t <name>@<server> uberspace <args...>` and inherit stdio.
|
||||||
|
/// Returns the exit code from ssh.
|
||||||
|
pub fn passthrough(asteroid: &Asteroid, args: &[String]) -> Result<i32, String> {
|
||||||
|
let host = format!("{}@{}", asteroid.name, asteroid.server);
|
||||||
|
|
||||||
|
let mut cmd = Command::new("ssh");
|
||||||
|
cmd.arg("-t").arg("-o").arg("BatchMode=no").arg(&host);
|
||||||
|
cmd.arg("uberspace");
|
||||||
|
cmd.args(args);
|
||||||
|
|
||||||
|
let status = cmd
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("failed to execute ssh: {e}"))?;
|
||||||
|
|
||||||
|
Ok(status.code().unwrap_or(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a command on the remote via SSH and capture stdout.
|
||||||
|
/// Used for status collection where we need to parse output.
|
||||||
|
pub fn capture(asteroid: &Asteroid, remote_script: &str) -> Result<String, String> {
|
||||||
|
let host = format!("{}@{}", asteroid.name, asteroid.server);
|
||||||
|
|
||||||
|
let output = Command::new("ssh")
|
||||||
|
.arg("-o")
|
||||||
|
.arg("BatchMode=no")
|
||||||
|
.arg(&host)
|
||||||
|
.arg("bash")
|
||||||
|
.arg("-s")
|
||||||
|
.arg(asteroid.version.to_string())
|
||||||
|
.stdin(std::process::Stdio::piped())
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::inherit())
|
||||||
|
.spawn()
|
||||||
|
.and_then(|mut child| {
|
||||||
|
use std::io::Write;
|
||||||
|
if let Some(ref mut stdin) = child.stdin {
|
||||||
|
stdin.write_all(remote_script.as_bytes())?;
|
||||||
|
}
|
||||||
|
child.wait_with_output()
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("ssh to {} failed: {e}", asteroid.name))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"ssh to {}@{} failed with exit code {}",
|
||||||
|
asteroid.name,
|
||||||
|
asteroid.server,
|
||||||
|
output.status.code().unwrap_or(1)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
String::from_utf8(output.stdout)
|
||||||
|
.map_err(|e| format!("invalid utf-8 from ssh output: {e}"))
|
||||||
|
}
|
||||||
290
src/status.rs
Normal file
290
src/status.rs
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use colored::Colorize;
|
||||||
|
|
||||||
|
use crate::cache::{self, CachedStatus};
|
||||||
|
use crate::registry::Asteroid;
|
||||||
|
use crate::ssh;
|
||||||
|
|
||||||
|
/// Remote script sent to the asteroid via SSH to collect status data.
|
||||||
|
/// Mirrors the bash heredoc from the original `asteroids` script.
|
||||||
|
const REMOTE_STATUS_SCRIPT: &str = r#"
|
||||||
|
version="$1"
|
||||||
|
set +e +u
|
||||||
|
tmpdir=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$tmpdir"' EXIT
|
||||||
|
|
||||||
|
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$HOME/bin:$HOME/.local/bin:$PATH"
|
||||||
|
|
||||||
|
if [ "$version" = "8" ]; then
|
||||||
|
uberspace web backend list </dev/null > "$tmpdir/ports" 2>"$tmpdir/e.ports" &
|
||||||
|
else
|
||||||
|
uberspace port list </dev/null > "$tmpdir/ports" 2>"$tmpdir/e.ports" &
|
||||||
|
fi
|
||||||
|
uberspace web domain list </dev/null > "$tmpdir/web" 2>"$tmpdir/e.web" &
|
||||||
|
uberspace mail domain list </dev/null > "$tmpdir/mdom" 2>"$tmpdir/e.mdom" &
|
||||||
|
if [ "$version" = "8" ]; then
|
||||||
|
uberspace mail address list </dev/null > "$tmpdir/musr" 2>"$tmpdir/e.musr" &
|
||||||
|
else
|
||||||
|
uberspace mail user list </dev/null > "$tmpdir/musr" 2>"$tmpdir/e.musr" &
|
||||||
|
fi
|
||||||
|
wait
|
||||||
|
|
||||||
|
for _ef in "$tmpdir"/e.*; do
|
||||||
|
[ -s "$_ef" ] && cat "$_ef" >&2
|
||||||
|
done
|
||||||
|
|
||||||
|
parse_list() {
|
||||||
|
local f="$1"
|
||||||
|
[ -s "$f" ] || return 0
|
||||||
|
if grep -q $'\xe2\x94' "$f" 2>/dev/null; then
|
||||||
|
grep -v $'\xe2\x94' "$f" \
|
||||||
|
| awk 'NR==1{next} /^[[:space:]]*$/{next} {gsub(/^[[:space:]]+|[[:space:]]+$/,""); print}'
|
||||||
|
else
|
||||||
|
grep -v '^[[:space:]]*$' "$f"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ports=$(parse_list "$tmpdir/ports" | tr '\n' ',' | sed 's/,$//')
|
||||||
|
web=$(parse_list "$tmpdir/web" | tr '\n' ',' | sed 's/,$//')
|
||||||
|
mdom=$(parse_list "$tmpdir/mdom" | tr '\n' ',' | sed 's/,$//')
|
||||||
|
musr=$(parse_list "$tmpdir/musr" | tr '\n' ',' | sed 's/,$//')
|
||||||
|
|
||||||
|
printf 'PORTS=%s\n' "$ports"
|
||||||
|
printf 'WEB=%s\n' "$web"
|
||||||
|
printf 'MDOM=%s\n' "$mdom"
|
||||||
|
printf 'MUSR=%s\n' "$musr"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Refresh status for a single asteroid via SSH, cache the result, and return it.
|
||||||
|
pub fn refresh_one(asteroid: &Asteroid) -> Result<CachedStatus, String> {
|
||||||
|
eprintln!(
|
||||||
|
"{} refreshing status for {} @ {} ...",
|
||||||
|
"==>".cyan(),
|
||||||
|
asteroid.name,
|
||||||
|
asteroid.server
|
||||||
|
);
|
||||||
|
|
||||||
|
let raw = ssh::capture(asteroid, REMOTE_STATUS_SCRIPT)?;
|
||||||
|
let updated = Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string();
|
||||||
|
|
||||||
|
let mut ports = Vec::new();
|
||||||
|
let mut web = Vec::new();
|
||||||
|
let mut mdom = Vec::new();
|
||||||
|
let mut musr = Vec::new();
|
||||||
|
|
||||||
|
for line in raw.lines() {
|
||||||
|
let line = line.trim_end_matches('\r');
|
||||||
|
if let Some(val) = line.strip_prefix("PORTS=") {
|
||||||
|
ports = parse_csv(val);
|
||||||
|
} else if let Some(val) = line.strip_prefix("WEB=") {
|
||||||
|
web = parse_csv(val);
|
||||||
|
} else if let Some(val) = line.strip_prefix("MDOM=") {
|
||||||
|
mdom = parse_csv(val);
|
||||||
|
} else if let Some(val) = line.strip_prefix("MUSR=") {
|
||||||
|
musr = parse_csv(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ports.is_empty() && web.is_empty() && mdom.is_empty() && musr.is_empty() {
|
||||||
|
eprintln!(
|
||||||
|
"{} no data from {} — check stderr above",
|
||||||
|
"[warn]".yellow(),
|
||||||
|
asteroid.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = CachedStatus {
|
||||||
|
updated,
|
||||||
|
name: asteroid.name.clone(),
|
||||||
|
server: asteroid.server.clone(),
|
||||||
|
version: asteroid.version,
|
||||||
|
ports,
|
||||||
|
web_domains: web,
|
||||||
|
mail_domains: mdom,
|
||||||
|
mail_users: musr,
|
||||||
|
};
|
||||||
|
|
||||||
|
cache::save(&status)?;
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display a single status block with colored formatting.
|
||||||
|
pub fn print_status(status: &CachedStatus, age_display: &str) {
|
||||||
|
let ver = format!("u{}", status.version);
|
||||||
|
let header_right = format!("{} {}", status.server, ver);
|
||||||
|
|
||||||
|
// header line
|
||||||
|
println!(
|
||||||
|
" {} {}{}",
|
||||||
|
status.name.bold(),
|
||||||
|
header_right.dimmed(),
|
||||||
|
format_age_suffix(age_display),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (ports_label, users_label) = if status.version == 8 {
|
||||||
|
("backends", "addrs")
|
||||||
|
} else {
|
||||||
|
("ports", "users")
|
||||||
|
};
|
||||||
|
|
||||||
|
print_field(ports_label, &status.ports);
|
||||||
|
print_field("web", &status.web_domains);
|
||||||
|
print_field("mail", &status.mail_domains);
|
||||||
|
print_field(users_label, &status.mail_users);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show aggregate status from cache for all asteroids.
|
||||||
|
pub fn show_all(refresh: bool, asteroids: &[Asteroid]) -> Result<(), String> {
|
||||||
|
if refresh {
|
||||||
|
for asteroid in asteroids {
|
||||||
|
match refresh_one(asteroid) {
|
||||||
|
Ok(status) => {
|
||||||
|
println!();
|
||||||
|
let age = compute_age(&status.updated);
|
||||||
|
print_status(&status, &age);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("{} {}: {e}", "[error]".red(), asteroid.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached = cache::load_all()?;
|
||||||
|
if cached.is_empty() {
|
||||||
|
eprintln!(
|
||||||
|
"{} no cached status — run: {} {}",
|
||||||
|
"[warn]".yellow(),
|
||||||
|
"uc".blue(),
|
||||||
|
"status --refresh".blue()
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stale_count = 0;
|
||||||
|
let mut first = true;
|
||||||
|
|
||||||
|
for status in &cached {
|
||||||
|
if !first {
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
|
||||||
|
let age = compute_age(&status.updated);
|
||||||
|
if is_stale(&status.updated) {
|
||||||
|
stale_count += 1;
|
||||||
|
}
|
||||||
|
print_status(status, &age);
|
||||||
|
}
|
||||||
|
|
||||||
|
if stale_count > 0 {
|
||||||
|
println!();
|
||||||
|
eprintln!(
|
||||||
|
"{} {stale_count} account(s) have stale cache (>24h) — run: uc status --refresh",
|
||||||
|
"[warn]".yellow()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_field(label: &str, values: &[String]) {
|
||||||
|
if values.is_empty() {
|
||||||
|
println!(" {} (none)", format!("{label:<9}").dimmed());
|
||||||
|
} else if values.len() == 1 {
|
||||||
|
println!(" {} {}", format!("{label:<9}").dimmed(), values[0]);
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
" {} \u{251c}\u{2500}\u{2500} {}",
|
||||||
|
format!("{label:<9}").dimmed(),
|
||||||
|
values[0]
|
||||||
|
);
|
||||||
|
for v in &values[1..values.len() - 1] {
|
||||||
|
println!(" {} \u{2502} {v}", " ".repeat(9).dimmed());
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
" {} \u{2514}\u{2500}\u{2500} {}",
|
||||||
|
" ".repeat(9).dimmed(),
|
||||||
|
values[values.len() - 1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_csv(s: &str) -> Vec<String> {
|
||||||
|
if s.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
s.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_age(updated: &str) -> String {
|
||||||
|
let Ok(ts) = chrono::NaiveDateTime::parse_from_str(updated, "%Y-%m-%dT%H:%M:%S") else {
|
||||||
|
return updated.to_string();
|
||||||
|
};
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let age = now.signed_duration_since(ts);
|
||||||
|
let secs = age.num_seconds();
|
||||||
|
|
||||||
|
if secs < 0 {
|
||||||
|
return "just now".into();
|
||||||
|
} else if secs < 60 {
|
||||||
|
format!("{secs}s ago")
|
||||||
|
} else if secs < 3600 {
|
||||||
|
format!("{}m ago", secs / 60)
|
||||||
|
} else if secs < 86400 {
|
||||||
|
format!("{}h ago", secs / 3600)
|
||||||
|
} else {
|
||||||
|
format!("{}d ago", secs / 86400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_stale(updated: &str) -> bool {
|
||||||
|
let Ok(ts) = chrono::NaiveDateTime::parse_from_str(updated, "%Y-%m-%dT%H:%M:%S") else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let age = now.signed_duration_since(ts);
|
||||||
|
age.num_seconds() >= 86400
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_age_suffix(age: &str) -> String {
|
||||||
|
if age.ends_with("d ago") {
|
||||||
|
format!(" {} {}", age.dimmed(), "!".yellow())
|
||||||
|
} else {
|
||||||
|
format!(" {}", age.dimmed())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_csv_basic() {
|
||||||
|
let result = parse_csv("a.de,b.de,c.de");
|
||||||
|
assert_eq!(result, vec!["a.de", "b.de", "c.de"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_csv_empty() {
|
||||||
|
assert!(parse_csv("").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn age_format() {
|
||||||
|
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string();
|
||||||
|
let age = compute_age(&now);
|
||||||
|
assert!(age.contains("s ago") || age == "just now");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_detection() {
|
||||||
|
assert!(is_stale("2020-01-01T00:00:00"));
|
||||||
|
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string();
|
||||||
|
assert!(!is_stale(&now));
|
||||||
|
}
|
||||||
|
}
|
||||||
144
src/translate.rs
Normal file
144
src/translate.rs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
/// Translates canonical (unified) CLI args to version-specific `uberspace` args.
|
||||||
|
///
|
||||||
|
/// The canonical form uses v7-style commands. For v8 asteroids, certain commands
|
||||||
|
/// are rewritten to the v8 equivalents. For v7, args pass through unchanged.
|
||||||
|
pub fn translate(version: u8, args: &[String]) -> Result<Vec<String>, String> {
|
||||||
|
if version == 7 || args.is_empty() {
|
||||||
|
return Ok(args.to_vec());
|
||||||
|
}
|
||||||
|
if version != 8 {
|
||||||
|
return Err(format!("unsupported uberspace version: {version}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let joined = args.iter().map(|s| s.as_str()).collect::<Vec<_>>();
|
||||||
|
|
||||||
|
match joined.as_slice() {
|
||||||
|
// mail user list → mail address list
|
||||||
|
["mail", "user", "list"] => Ok(words("mail address list")),
|
||||||
|
|
||||||
|
// mail user add <x> → mail address add <x>
|
||||||
|
["mail", "user", "add", addr] => Ok(words_with("mail address add", addr)),
|
||||||
|
|
||||||
|
// mail user del <x> → mail address del <x>
|
||||||
|
["mail", "user", "del", addr] => Ok(words_with("mail address del", addr)),
|
||||||
|
|
||||||
|
// port list → web backend list
|
||||||
|
["port", "list"] => Ok(words("web backend list")),
|
||||||
|
|
||||||
|
// tools version use <tool> <ver> → tool version set <tool> <ver>
|
||||||
|
["tools", "version", "use", tool, ver] => {
|
||||||
|
Ok(vec![
|
||||||
|
"tool".into(),
|
||||||
|
"version".into(),
|
||||||
|
"set".into(),
|
||||||
|
(*tool).into(),
|
||||||
|
(*ver).into(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// tools restart <tool> → web <tool> reload
|
||||||
|
["tools", "restart", tool] => {
|
||||||
|
Ok(vec!["web".into(), (*tool).into(), "reload".into()])
|
||||||
|
}
|
||||||
|
|
||||||
|
// web backend set <path> --http --port <n> → web backend add <path> port <n>
|
||||||
|
["web", "backend", "set", path, "--http", "--port", port] => {
|
||||||
|
Ok(vec![
|
||||||
|
"web".into(),
|
||||||
|
"backend".into(),
|
||||||
|
"add".into(),
|
||||||
|
(*path).into(),
|
||||||
|
"port".into(),
|
||||||
|
(*port).into(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else passes through unchanged
|
||||||
|
_ => Ok(args.to_vec()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn words(s: &str) -> Vec<String> {
|
||||||
|
s.split_whitespace().map(String::from).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn words_with(prefix: &str, suffix: &str) -> Vec<String> {
|
||||||
|
let mut v = words(prefix);
|
||||||
|
v.push(suffix.to_string());
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn args(s: &str) -> Vec<String> {
|
||||||
|
s.split_whitespace().map(String::from).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v7_passthrough() {
|
||||||
|
let input = args("mail user list");
|
||||||
|
assert_eq!(translate(7, &input).unwrap(), input);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v8_mail_user_list() {
|
||||||
|
let result = translate(8, &args("mail user list")).unwrap();
|
||||||
|
assert_eq!(result, args("mail address list"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v8_mail_user_add() {
|
||||||
|
let result = translate(8, &args("mail user add foo@bar.de")).unwrap();
|
||||||
|
assert_eq!(result, args("mail address add foo@bar.de"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v8_mail_user_del() {
|
||||||
|
let result = translate(8, &args("mail user del foo@bar.de")).unwrap();
|
||||||
|
assert_eq!(result, args("mail address del foo@bar.de"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v8_port_list() {
|
||||||
|
let result = translate(8, &args("port list")).unwrap();
|
||||||
|
assert_eq!(result, args("web backend list"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v8_tools_version_use() {
|
||||||
|
let result = translate(8, &args("tools version use php 8.2")).unwrap();
|
||||||
|
assert_eq!(result, args("tool version set php 8.2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v8_tools_restart() {
|
||||||
|
let result = translate(8, &args("tools restart php")).unwrap();
|
||||||
|
assert_eq!(result, args("web php reload"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v8_web_backend_set() {
|
||||||
|
let result = translate(8, &args("web backend set / --http --port 8080")).unwrap();
|
||||||
|
assert_eq!(result, args("web backend add / port 8080"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v8_unknown_passthrough() {
|
||||||
|
let input = args("web domain list");
|
||||||
|
assert_eq!(translate(8, &input).unwrap(), input);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_args() {
|
||||||
|
let result = translate(8, &[]).unwrap();
|
||||||
|
assert!(result.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_version() {
|
||||||
|
let result = translate(9, &args("port list"));
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user