Compare commits
5 Commits
7c286a0dc0
...
2656395a22
| Author | SHA1 | Date | |
|---|---|---|---|
| 2656395a22 | |||
| 15fe1b65d2 | |||
| ac76bb95f4 | |||
| 4cbfd92dad | |||
| f0604a4aee |
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebFetch(domain:manual.uberspace.de)",
|
||||
"WebFetch(domain:u8manual.uberspace.de)",
|
||||
"Bash(rustc:*)",
|
||||
"Bash(git:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/target/
|
||||
/dist/
|
||||
.DS_Store
|
||||
45
.mise/tasks/build
Executable file
45
.mise/tasks/build
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Build static uc binaries for all targets"
|
||||
set -euo pipefail
|
||||
|
||||
# Produces fully static, zero-dependency binaries:
|
||||
# dist/uc-aarch64-apple-darwin (macOS Apple Silicon)
|
||||
# dist/uc-x86_64-unknown-linux-musl (Linux x86_64, static)
|
||||
#
|
||||
# Requirements:
|
||||
# - mise (manages rust toolchain)
|
||||
# - zig + cargo-zigbuild (for Linux cross-compilation)
|
||||
# - rustup target x86_64-unknown-linux-musl (one-time setup)
|
||||
#
|
||||
# Usage:
|
||||
# mise run build build all targets
|
||||
# mise run build -- macos build macOS only
|
||||
# mise run build -- linux build Linux only
|
||||
|
||||
DIST="dist"
|
||||
mkdir -p "$DIST"
|
||||
|
||||
build_macos() {
|
||||
echo "==> building macOS (aarch64-apple-darwin) ..."
|
||||
cargo build --release
|
||||
cp target/release/uc "$DIST/uc-aarch64-apple-darwin"
|
||||
echo "[ok] $DIST/uc-aarch64-apple-darwin"
|
||||
}
|
||||
|
||||
build_linux() {
|
||||
echo "==> building Linux (x86_64-unknown-linux-musl) ..."
|
||||
cargo zigbuild --release --target x86_64-unknown-linux-musl
|
||||
cp target/x86_64-unknown-linux-musl/release/uc "$DIST/uc-x86_64-unknown-linux-musl"
|
||||
echo "[ok] $DIST/uc-x86_64-unknown-linux-musl"
|
||||
}
|
||||
|
||||
case "${1:-all}" in
|
||||
macos) build_macos ;;
|
||||
linux) build_linux ;;
|
||||
all) build_macos; build_linux ;;
|
||||
*) echo "usage: mise run build -- [macos|linux|all]"; exit 1 ;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "==> done"
|
||||
ls -lh "$DIST"/uc-*
|
||||
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