diff --git a/.cli-flags.toml b/.cli-flags.toml new file mode 100644 index 0000000..5be5dd8 --- /dev/null +++ b/.cli-flags.toml @@ -0,0 +1,118 @@ +# flags-2-env config — https://github.com/ORESoftware/flags-2-env +# +# This file is the CLI contract. The binary parses it directly on every +# platform, and `--help`, shell completions, env-var names, types, and defaults +# all render from it at runtime — there is no second copy in Rust to drift. + +[help] +url = "https://github.com/led-dynamo/leddy-cli" +columns = ["options", "env", "type", "default", "description"] + +[parse] +stop_at_first_positional = false +allow_unknown = false + +[env] +# A device API token, when the deployment uses one, is a credential: a flag +# value shows up in `ps` output and shell history. It is read straight from the +# environment and never declared as a flag. +ignore = ["LEDDY_API_TOKEN"] + +[commands.publish] +help = "Publish a scrolling message to the display." +aliases = ["send"] + +[commands.clear] +help = "Clear the display." + +[commands.health] +help = "Check that the Leddy API is reachable." + +[commands.preview] +help = "Render the message locally as ASCII art — no device, no network." + +[commands.preview.flags.at] +env = "LEDDY_PREVIEW_AT_MS" +aliases = ["at"] +type = "integer" +default = 0 +help = "Milliseconds into the scroll to render (0 through 86400000)." + +[commands.preview.flags.width] +env = "LEDDY_WIDTH" +aliases = ["width"] +type = "integer" +default = 128 +help = "Display width in pixels (1 through 4096)." + +[commands.preview.flags.height] +env = "LEDDY_HEIGHT" +aliases = ["height"] +type = "integer" +default = 8 +help = "Display height in pixels (1 through 512)." + +[commands.completion] +help = "Print a shell completion script for this CLI." + +[commands.completion.flags.shell] +env = "LEDDY_COMPLETION_SHELL" +aliases = ["shell"] +short = "s" +type = "string" +default = "bash" +help = "Shell dialect to emit: bash or zsh." + +[flags.url] +env = "LEDDY_API_URL" +aliases = ["url", "api-url"] +short = "u" +type = "string" +default = "http://localhost:8080" +help = "Base URL of the Leddy API." + +[flags.text] +env = "LEDDY_TEXT" +aliases = ["text"] +short = "t" +type = "string" +help = "Message text to scroll. Required by publish and preview." + +[flags.speed] +env = "LEDDY_SCROLL_SPEED" +aliases = ["speed"] +type = "double" +default = 24.0 +help = "Scroll speed in pixels per second (must be positive and finite)." + +[flags.direction] +env = "LEDDY_DIRECTION" +aliases = ["direction"] +short = "d" +type = "string" +default = "left" +help = "Scroll direction: left or right." + +[flags.repeat] +env = "LEDDY_REPEAT" +aliases = ["repeat"] +short = "r" +type = "string" +default = "forever" +help = "Repeat mode: forever, once, or a positive count such as 3." + +[flags.id] +env = "LEDDY_MESSAGE_ID" +aliases = ["id"] +type = "string" +help = "Message id (default: a cli- id)." + +[flags.json] +env = "LEDDY_JSON" +aliases = ["json"] +short = "j" +type = "bool" +default = false +true_aliases = ["1", "yes", "on"] +false_aliases = ["0", "no", "off"] +help = "Emit machine-readable JSON instead of the human output." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ef6c35..b4af4c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,22 +1,78 @@ +# CI for the leddy CLI. +# +# Three things are checked that a plain `cargo test` would not catch: +# * `.cli-flags.toml` passes the flags-2-env audit (ambiguous aliases, +# duplicate shorts, colliding env targets); +# * `src/cli_config.rs` still matches what the generator emits from that +# contract, so the typed struct cannot drift from the flags; +# * the `.zpkg.toml` org dependency graph is exactly what it should be. name: ci on: - pull_request: push: - branches: [main, dev] + branches: [main] + pull_request: + workflow_dispatch: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + jobs: - rust: - runs-on: ubuntu-24.04 + contract: + name: cli contract + runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Build the flags-2-env generator + run: | + set -euo pipefail + git clone --depth 1 https://github.com/ORESoftware/flags-2-env.git /tmp/flags-2-env + git -C /tmp/flags-2-env fetch --depth 1 origin "$FLAGS2ENV_REV" + git -C /tmp/flags-2-env checkout --detach FETCH_HEAD + make -C /tmp/flags-2-env cli + env: + # Keep in step with the `flags2env` rev pinned in Cargo.toml, so the + # audit and the linked parser are the same build. + FLAGS2ENV_REV: 8c8465561075a8d7ebb58074b3a8087138e97d8f + - name: Audit the CLI contract + run: /tmp/flags-2-env/build/flags2env audit .cli-flags.toml + - name: Check the generated typed config for drift + run: | + set -euo pipefail + diff -u src/cli_config.rs \ + <(/tmp/flags-2-env/build/flags2env generate rust .cli-flags.toml --name CliConfig) + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Validate the zed dependency graph + run: python3 scripts/check-zed-dependencies.py + + test: + name: test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@stable with: - components: rustfmt, clippy + components: clippy, rustfmt - uses: Swatinem/rust-cache@v2 - run: cargo fmt --all -- --check - - run: cargo clippy --all-targets --all-features -- -D warnings - - run: cargo test --all-targets --all-features + - run: cargo clippy --all-targets --locked -- -D warnings + - run: cargo test --all-targets --locked diff --git a/.github/workflows/zed-package.yml b/.github/workflows/zed-package.yml index 8855031..b38a6e1 100644 --- a/.github/workflows/zed-package.yml +++ b/.github/workflows/zed-package.yml @@ -16,4 +16,4 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.12' - - run: python3 scripts/check-zed-package.py + - run: python3 scripts/check-zed-dependencies.py diff --git a/.gitignore b/.gitignore index 6ca2637..c7d67dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,26 +1,10 @@ -# Build output -target/ -dist/ -build/ -coverage/ - -# Zed package materialization -.vendor/.zed/ -.zed/ -.zed-pack/ - -# Local configuration +/target +**/*.rs.bk .env .env.* -!.env.example -secrets.h -include/secrets.h - -# Editors and operating systems -.DS_Store -.idea/ -.vscode/ -*.swp - -# Logs +.direnv/ +.zed/ +.zed-pack/ +.vendor/.zed/ *.log +.DS_Store diff --git a/.zpkg.toml b/.zpkg.toml index 12087d1..4b4b349 100644 --- a/.zpkg.toml +++ b/.zpkg.toml @@ -2,7 +2,7 @@ org = "led-dynamo" name = "leddy-cli" version = "0.1.0" -description = "Command-line client for publishing, clearing, inspecting, and automating Leddy displays" +description = "Command-line client for publishing, previewing, clearing, and inspecting Leddy displays" license = "MIT" keywords = ["led-matrix", "cli", "automation", "iot", "rust"] language = "rust" @@ -11,39 +11,36 @@ language = "rust" vcs = "git" url = "https://github.com/led-dynamo/leddy-cli" +# Org dependency graph: interfaces (shapes) -> lib (behaviour) -> clients +# (transport) -> this CLI. All three are real Cargo edges too; +# scripts/check-zed-dependencies.py fails CI if the two manifests disagree. [dependencies] "led-dynamo/leddy-interfaces" = "^0.1.0" "led-dynamo/leddy-lib" = "^0.1.0" "led-dynamo/leddy-clients" = "^0.1.0" [build] -command = "cargo build --release" -outputs = ["target/release/leddy-cli"] +command = "cargo build --release --locked --bin leddy" +outputs = ["target/release/leddy"] [bin] -leddy = "target/release/leddy-cli" +leddy = "target/release/leddy" + +[install] +adapter = "none" +dir = ".vendor/.zed" [publish] include_readme = true tag_format = "v{version}" -smoke_test = "test -x \"$ZED_PKG_TEST_TARGET/target/release/leddy-cli\" && \"$ZED_PKG_TEST_TARGET/target/release/leddy-cli\" --help" -exclude = [ - ".env", - ".env.*", - ".vendor/.zed/**", - ".zed/**", - ".zed-pack/**", - "target/**", - "**/*.log", -] +smoke_test = "test -x \"$ZED_PKG_TEST_TARGET/target/release/leddy\" && \"$ZED_PKG_TEST_TARGET/target/release/leddy\" --help >/dev/null" +exclude = [".env", ".env.*", ".direnv/**", ".zed/**", ".zed-pack/**", ".vendor/.zed/**", "target/**", "tmp/**", "**/*.log", ".DS_Store"] [publish.native] registry = "crates-io" package = "leddy-cli" -[install] -adapter = "none" -dir = ".vendor/.zed" - [scripts] -test = "cargo test --all-targets" +test = "cargo test --all-targets --locked" +lint = "cargo clippy --all-targets --locked -- -D warnings" +check-deps = "python3 scripts/check-zed-dependencies.py" diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ffdd8ad --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1325 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +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 = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flags2env" +version = "0.1.0" +source = "git+https://github.com/ORESoftware/flags-2-env.git?rev=8c8465561075a8d7ebb58074b3a8087138e97d8f#8c8465561075a8d7ebb58074b3a8087138e97d8f" +dependencies = [ + "cc", + "libloading", + "serde", + "serde_json", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "leddy-cli" +version = "0.1.0" +dependencies = [ + "flags2env", + "leddy-client-rust", + "leddy-interfaces", + "leddy-lib", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "leddy-client-rust" +version = "0.1.0" +source = "git+https://github.com/led-dynamo/leddy-clients#fdd6c02fb84c7d0ca95f97f0e33441550d6bd3e8" +dependencies = [ + "leddy-interfaces", + "reqwest", + "serde", +] + +[[package]] +name = "leddy-interfaces" +version = "0.1.0" +source = "git+https://github.com/led-dynamo/leddy-interfaces#7d8ef79f1353aa6ed23e47739172a7500cab6f25" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "leddy-lib" +version = "0.1.0" +source = "git+https://github.com/led-dynamo/leddy-lib#2a9a0cd43180d4be1f09b1fd98ec00ead9203795" +dependencies = [ + "leddy-interfaces", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +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 = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[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 = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index d457a59..99f2158 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,46 @@ +# Cargo manifest for leddy-cli: builds the `leddy` binary and its library half. [package] name = "leddy-cli" version = "0.1.0" edition = "2024" +description = "Command-line client for publishing, previewing, clearing, and inspecting Leddy displays" license = "MIT" repository = "https://github.com/led-dynamo/leddy-cli" +[lib] +name = "leddy_cli" +path = "src/lib.rs" + +[[bin]] +name = "leddy" +path = "src/main.rs" + +[lints.rust] +# Denied, not forbidden: `src/help.rs` binds to the flags-2-env C core and opts +# itself out with a module-level allow. Nothing else in the crate may. +unsafe_code = "deny" + [dependencies] -clap = { version = "4", features = ["derive", "env"] } -leddy-client-rust = { git = "https://github.com/led-dynamo/leddy-clients", package = "leddy-client-rust" } +# The CLI contract. `BundledFlags2Env` statically links the vendored C parser +# through this crate's build script, so the released binary needs no +# libflags2env at runtime and --help/completions render from .cli-flags.toml. +flags2env = { git = "https://github.com/ORESoftware/flags-2-env.git", rev = "8c8465561075a8d7ebb58074b3a8087138e97d8f" } + +# Org dependencies — the same three edges declared in `.zpkg.toml`. +# +# Deliberately spelled with no `rev`: leddy-lib and leddy-clients both depend on +# leddy-interfaces this way, and cargo only unifies git sources whose specs +# match. Pinning a rev here would fork leddy-interfaces into two crates and the +# shared types would stop being the same type. Cargo.lock still pins the exact +# commits, so builds stay reproducible. +# +# Wire contract: MessageEnvelope, DisplayConfig, and their validators. leddy-interfaces = { git = "https://github.com/led-dynamo/leddy-interfaces" } +# Behaviour: the framebuffer and scrolling-text renderer `preview` draws with. leddy-lib = { git = "https://github.com/led-dynamo/leddy-lib" } +# Transport: publish/clear/health all go through the org client. +leddy-client-rust = { git = "https://github.com/led-dynamo/leddy-clients", package = "leddy-client-rust" } + +serde = { version = "1", features = ["derive"] } +serde_json = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/LICENSE b/LICENSE index 74d827c..391a2a7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 led-dynamo contributors +Copyright (c) 2026 LED Dynamo Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index bc7a4d8..9b16814 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,144 @@ # leddy-cli -Operator and automation CLI for the Leddy API. Every option supports an -environment-variable equivalent, preserving the same flags-to-environment -workflow used across the broader toolchain. +The `leddy` command-line client for [Leddy](https://github.com/led-dynamo) LED +matrix displays. ```sh -cargo run -- send "HELLO WORLD" --speed 28 -cargo run -- clear -cargo run -- health +leddy publish --text "DEPLOY OK" --speed 30 --repeat 3 +leddy preview --text "DEPLOY OK" --width 128 --height 8 --at 2000 +leddy clear +leddy health ``` -Set `LEDDY_API_URL` to target another server. +## Preview without a device + +`leddy preview` renders the message locally as ASCII art. It uses **the same +renderer the panel runs** — `leddy_lib::render_message_frame` — so what you see +is what the display would show at that moment, not a lookalike: + +``` +$ leddy preview --text "HI LEDDY" --width 48 --height 8 --at 2000 +cli-1786061928883 48x8 at 2000 ms (47 px wide, 3959 ms/cycle) +#...#..###........#.....#####.###...###...#...#. +#...#...#.........#.....#.....#..#..#..#..#...#. +#...#...#.........#.....#.....#...#.#...#.#...#. +#####...#.........#.....####..#...#.#...#..#.#.. +#...#...#.........#.....#.....#...#.#...#...#... +#...#...#.........#.....#.....#..#..#..#....#... +#...#..###........#####.#####.###...###.....#... +................................................ +``` + +It needs no device and no network, which makes it the fastest way to check +whether a message fits before publishing it. A `--repeat once` message that has +finished by `--at` reports that the display would be blank rather than failing. + +## Configuration — flags-2-env + +Flags are declared once in [`.cli-flags.toml`](.cli-flags.toml), the +[flags-2-env](https://github.com/ORESoftware/flags-2-env) config format. Each +flag maps to an environment variable, and precedence is +**CLI flags > environment > TOML defaults**: + +```sh +export LEDDY_API_URL=http://leddy.local:8080 # = --url / -u +export LEDDY_SCROLL_SPEED=18 # = --speed +export LEDDY_JSON=1 # = --json / -j +leddy publish --text hi # uses the environment +leddy publish --text hi --speed 40 # the flag still wins +``` + +`--help` is rendered by the flags-2-env core from that file at runtime — there +is no usage string in the Rust source to drift — and it is subcommand-aware: + +```sh +leddy --help # global flags + the command table +leddy preview --help # preview's own flags, plus inherited global ones +``` + +`--width`, `--height`, and `--at` are **command-scoped** to `preview`; outside it +they are rejected unknown options rather than silently ignored ones. + +A device API token, where a deployment uses one, is a credential: a flag value is +visible in `ps` output and shell history, so `LEDDY_API_TOKEN` is an +`[env] ignore` entry read from the environment and is never a flag. + +Shell completions come from the same contract and are **static** — no TOML read +and no process spawn while you are pressing Tab: + +```sh +leddy completion --shell bash > "${XDG_DATA_HOME:-$HOME/.local/share}/bash-completion/completions/leddy" +leddy completion --shell zsh > "${ZDOTDIR:-$HOME}/.zfunc/_leddy" +``` + +[`src/cli_config.rs`](src/cli_config.rs) is generated from the contract and CI +diffs it against fresh generator output, so the typed struct cannot drift from +the flags. + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | success | +| `1` | the invocation was valid but the work failed (device unreachable, non-2xx) | +| `2` | bad invocation: unknown flag or command, bad `--direction`/`--repeat`, message the protocol rejects | +| `3` | `.cli-flags.toml` could not be found, read, or audited | + +## Org dependencies + +All three Leddy layers are real dependencies here, not decoration. The same +edges appear in [`Cargo.toml`](Cargo.toml) and in [`.zpkg.toml`](.zpkg.toml) as +the [zed](https://github.com/zed-pkg/zed-cli) dependency graph: + +```toml +[dependencies] +"led-dynamo/leddy-interfaces" = "^0.1.0" # shapes: MessageEnvelope, DisplayConfig + validators +"led-dynamo/leddy-lib" = "^0.1.0" # behaviour: framebuffer + scrolling renderer +"led-dynamo/leddy-clients" = "^0.1.0" # transport: publish / clear / health +``` + +Message and geometry validation is `leddy-interfaces`' own `validate()`, called +from [`src/message.rs`](src/message.rs) — the protocol limits are not restated +here, so they cannot quietly diverge. Cycle length comes from `leddy-lib`, the +same function the renderer uses. + +The Cargo entries deliberately carry **no `rev`**: `leddy-lib` and +`leddy-clients` both depend on `leddy-interfaces` by plain git URL, and cargo +only unifies git sources whose specs match. Pinning a rev here would fork +`leddy-interfaces` into two crates and the shared types would stop being the same +type. `Cargo.lock` still pins the exact commits. + +[`scripts/check-zed-dependencies.py`](scripts/check-zed-dependencies.py) fails CI +if the two manifests disagree, or if a declared zed edge is not also a real Cargo +dependency. + +## Layout + +No module does two jobs, and `main.rs` does almost nothing: + +| File | Responsibility | +|------|----------------| +| `src/main.rs` | argv in, exit code out | +| `src/lib.rs` | module wiring + top-level `run` | +| `src/flags.rs` | contract audit, parse, precedence, coercion, range checks | +| `src/cli_config.rs` | generated typed representation of `.cli-flags.toml` | +| `src/help.rs` | help tables + completion scripts from the native core | +| `src/message.rs` | flags → a validated `MessageEnvelope`/`DisplayConfig` | +| `src/commands/` | one module per subcommand | +| `src/output.rs` / `src/error.rs` | human-vs-JSON, and `CliError` | + +`preview` and `completion` never start a runtime or an HTTP client — being +offline is part of what they are, so `Command::needs_network` decides that once, +in the dispatcher. + +`unsafe_code` is denied crate-wide; `src/help.rs` is the single module that opts +itself out, because binding to the flags-2-env C core needs it. + +## Build + +```sh +cargo build --locked --release # target/release/leddy +cargo test --all-targets --locked +cargo clippy --all-targets --locked -- -D warnings +python3 scripts/check-zed-dependencies.py # needs Python 3.11+ +``` diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..802fcbd --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +# Pinned so the vendored C parser in flags2env and the Rust edition below are +# built by the same compiler everywhere. +[toolchain] +channel = "stable" +components = ["clippy", "rustfmt"] diff --git a/scripts/check-zed-dependencies.py b/scripts/check-zed-dependencies.py new file mode 100755 index 0000000..81aa3df --- /dev/null +++ b/scripts/check-zed-dependencies.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Enforce the org dependency graph declared in `.zpkg.toml`. + +A CLI is the bottom of its org's stack: it consumes the shared contracts +(`*-interfaces`), the shared behaviour (`*-lib`) where the org has one, and the +transport (`*-clients`). Getting the org segment wrong — or naming a repo that +does not exist — produces a manifest that looks right and never resolves, which +is exactly the failure this script exists to catch. + +Every declared edge must also be a real Cargo dependency, so the zed graph +describes the build rather than decorating it. +""" + +from pathlib import Path +import sys + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - Python < 3.11 + sys.exit( + "check-zed-dependencies needs Python 3.11+ for tomllib " + f"(running {sys.version.split()[0]})" + ) + +ROOT = Path(__file__).resolve().parents[1] + +# org/repo -> the Cargo package name that edge must also appear as. +EXPECTED: dict[str, str] = { + "led-dynamo/leddy-interfaces": "leddy-interfaces", + "led-dynamo/leddy-lib": "leddy-lib", + "led-dynamo/leddy-clients": "leddy-client-rust", +} + +PACKAGE_NAME = "leddy-cli" + + +def main() -> int: + manifest = tomllib.loads((ROOT / ".zpkg.toml").read_text(encoding="utf-8")) + cargo = tomllib.loads((ROOT / "Cargo.toml").read_text(encoding="utf-8")) + errors: list[str] = [] + + if manifest.get("package", {}).get("name") != PACKAGE_NAME: + errors.append(f"package.name must be {PACKAGE_NAME}") + + declared = set(manifest.get("dependencies", {})) + missing = set(EXPECTED) - declared + unexpected = declared - set(EXPECTED) + if missing: + errors.append("missing zed dependencies: " + ", ".join(sorted(missing))) + if unexpected: + errors.append("unexpected zed dependencies: " + ", ".join(sorted(unexpected))) + + cargo_dependencies = set(cargo.get("dependencies", {})) + for edge, crate in EXPECTED.items(): + if crate and crate not in cargo_dependencies: + errors.append(f"{edge} is declared in .zpkg.toml but {crate} is not a Cargo dependency") + + if manifest.get("install", {}).get("dir") != ".vendor/.zed": + errors.append("install.dir must be .vendor/.zed") + if manifest.get("install", {}).get("adapter") != "none": + # A CLI is a universal executable package; it must not be wired into + # node_modules, a Java classpath, or a Go workspace. + errors.append("install.adapter must be none") + + if errors: + print(f"{PACKAGE_NAME} zed dependency validation failed:", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + return 1 + + print(f"validated the {PACKAGE_NAME} zed dependency graph") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-zed-package.py b/scripts/check-zed-package.py deleted file mode 100755 index 3ad5f44..0000000 --- a/scripts/check-zed-package.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path -import sys -import tomllib - -root = Path(__file__).resolve().parents[1] -data = tomllib.loads((root / ".zpkg.toml").read_text(encoding="utf-8")) -expected = { - "led-dynamo/leddy-interfaces", - "led-dynamo/leddy-lib", - "led-dynamo/leddy-clients", -} -actual = set(data.get("dependencies", {})) -missing = expected - actual -unexpected = actual - expected -errors = [] -if data.get("package", {}).get("name") != "leddy-cli": - errors.append("package.name must be leddy-cli") -if missing: - errors.append("missing dependencies: " + ", ".join(sorted(missing))) -if unexpected: - errors.append("unexpected dependencies: " + ", ".join(sorted(unexpected))) -if errors: - print("Zed package validation failed:", file=sys.stderr) - for error in errors: - print(f" - {error}", file=sys.stderr) - raise SystemExit(1) -print("validated leddy-cli Zed dependency graph") diff --git a/src/cli_config.rs b/src/cli_config.rs new file mode 100644 index 0000000..52477e2 --- /dev/null +++ b/src/cli_config.rs @@ -0,0 +1,25 @@ +// Generated by flags2env from .cli-flags.toml. Do not edit. + +#[allow(non_snake_case)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CliConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub LEDDY_PREVIEW_AT_MS: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub LEDDY_WIDTH: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub LEDDY_HEIGHT: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub LEDDY_COMPLETION_SHELL: Option, + pub LEDDY_API_URL: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub LEDDY_TEXT: Option, + pub LEDDY_SCROLL_SPEED: f64, + pub LEDDY_DIRECTION: String, + pub LEDDY_REPEAT: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub LEDDY_MESSAGE_ID: Option, + pub LEDDY_JSON: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub FLAGS2ENV_COMMAND: Option, +} diff --git a/src/commands/clear.rs b/src/commands/clear.rs new file mode 100644 index 0000000..883ceda --- /dev/null +++ b/src/commands/clear.rs @@ -0,0 +1,33 @@ +//! `leddy clear` — blank the display. + +use leddy_client_rust::LeddyClient; +use serde::Serialize; + +use crate::commands::request_failed; +use crate::error::CliError; +use crate::flags::CliArgs; +use crate::output::{Format, Report, emit}; + +#[derive(Debug, Serialize)] +pub struct Cleared { + pub status: u16, +} + +impl Report for Cleared { + fn render_human(&self) -> String { + format!("clear accepted (HTTP {})", self.status) + } +} + +pub async fn run(client: &LeddyClient, args: &CliArgs) -> Result { + let status = client + .clear() + .await + .map_err(|error| request_failed("clearing the display", &error))?; + emit( + &Cleared { + status: status.as_u16(), + }, + Format::from_json_flag(args.json), + ) +} diff --git a/src/commands/completion.rs b/src/commands/completion.rs new file mode 100644 index 0000000..9a15b25 --- /dev/null +++ b/src/commands/completion.rs @@ -0,0 +1,18 @@ +//! `leddy completion --shell ` — print a static completion script. +//! +//! Generated by the flags-2-env core from `.cli-flags.toml`, so it stays correct +//! as flags change and does no TOML reading or process spawning while the shell +//! is completing. + +use std::path::Path; + +use crate::error::CliError; +use crate::flags::CliArgs; + +pub fn run(args: &CliArgs, config_path: &Path) -> Result { + let script = crate::help::completion_script(config_path, &args.shell, crate::PROGRAM)?; + // Deliberately not `emit`: the output is a shell script, so `--json` does + // not apply and must not wrap it. + print!("{script}"); + Ok(0) +} diff --git a/src/commands/health.rs b/src/commands/health.rs new file mode 100644 index 0000000..11f7fa8 --- /dev/null +++ b/src/commands/health.rs @@ -0,0 +1,37 @@ +//! `leddy health` — check that the Leddy API answers. + +use leddy_client_rust::LeddyClient; +use serde::Serialize; + +use crate::commands::request_failed; +use crate::error::CliError; +use crate::flags::CliArgs; +use crate::output::{Format, Report, emit}; + +#[derive(Debug, Serialize)] +pub struct Health { + pub url: String, + pub healthy: bool, +} + +impl Report for Health { + fn render_human(&self) -> String { + format!("ok {}", self.url) + } +} + +pub async fn run(client: &LeddyClient, args: &CliArgs) -> Result { + // A non-2xx answer is an error, not `healthy: false` — a health check that + // reports success while the device is down is worse than no check. + client + .health() + .await + .map_err(|error| request_failed("health check", &error))?; + emit( + &Health { + url: args.api_url.clone(), + healthy: true, + }, + Format::from_json_flag(args.json), + ) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..e9eca7d --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,159 @@ +//! One module per subcommand, plus the dispatch table. +//! +//! Which command ran is decided by flags-2-env from the `[commands.*]` tables +//! in `.cli-flags.toml`, never by hand-matching argv here. [`Command`] is the +//! closed set that contract may resolve to, and `command_set_matches_config` +//! fails the build if the two lists disagree. + +pub mod clear; +pub mod completion; +pub mod health; +pub mod preview; +pub mod publish; + +use std::path::Path; + +use leddy_client_rust::LeddyClient; + +use crate::error::CliError; +use crate::flags::CliArgs; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Command { + /// Publish a scrolling message. + Publish, + /// Clear the display. + Clear, + /// Check that the API answers. + Health, + /// Render the message locally, with no device and no network. + Preview, + /// Print a shell completion script. + Completion, +} + +impl Command { + /// The canonical `[commands.*]` key, which is also what flags-2-env reports + /// in `FLAGS2ENV_COMMAND`. + pub const fn as_str(self) -> &'static str { + match self { + Self::Publish => "publish", + Self::Clear => "clear", + Self::Health => "health", + Self::Preview => "preview", + Self::Completion => "completion", + } + } + + pub fn parse(label: &str) -> Result { + match label { + "publish" | "send" => Ok(Self::Publish), + "clear" => Ok(Self::Clear), + "health" => Ok(Self::Health), + "preview" => Ok(Self::Preview), + "completion" => Ok(Self::Completion), + other => Err(CliError::usage(format!("unsupported command {other:?}"))), + } + } + + pub const ALL: [Self; 5] = [ + Self::Publish, + Self::Clear, + Self::Health, + Self::Preview, + Self::Completion, + ]; + + /// True for commands that talk to a device. `preview` and `completion` are + /// deliberately offline, so neither starts a runtime or a client. + pub const fn needs_network(self) -> bool { + matches!(self, Self::Publish | Self::Clear | Self::Health) + } +} + +/// Runs the selected command and returns its exit code. +pub fn dispatch(args: &CliArgs, config_path: &Path) -> Result { + if !args.command.needs_network() { + return match args.command { + Command::Preview => preview::run(args), + Command::Completion => completion::run(args, config_path), + _ => unreachable!("needs_network covers every other command"), + }; + } + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| { + CliError::runtime(format!("could not start the async runtime: {error}")) + })?; + + runtime.block_on(async { + let client = LeddyClient::new(args.api_url.clone()); + match args.command { + Command::Publish => publish::run(&client, args).await, + Command::Clear => clear::run(&client, args).await, + Command::Health => health::run(&client, args).await, + _ => unreachable!("offline commands returned above"), + } + }) +} + +/// Maps a transport failure to a CLI error. +/// +/// Generic over the error type rather than naming `reqwest::Error`: the HTTP +/// stack belongs to `leddy-client-rust`, and depending on it directly here +/// would pin this crate to the client's transitive version and feature set for +/// no gain. `reqwest::Error` renders the URL it was given but never a response +/// body, so this stays a thin wrapper that adds what the CLI was doing. +pub fn request_failed(what: &str, error: &impl std::fmt::Display) -> CliError { + CliError::runtime(format!("{what} failed: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_set_matches_config() { + let config = include_str!("../../.cli-flags.toml"); + for command in Command::ALL { + let table = format!("[commands.{}]", command.as_str()); + assert!( + config.contains(&table), + "{table} is missing from .cli-flags.toml" + ); + } + + let declared = config + .lines() + .filter_map(|line| line.trim().strip_prefix("[commands.")) + .filter_map(|line| line.strip_suffix(']')) + // Ignore nested tables such as `[commands.x.flags.y]`. + .filter(|name| !name.contains('.')) + .count(); + assert_eq!( + declared, + Command::ALL.len(), + ".cli-flags.toml declares {declared} commands but Command::ALL has {}", + Command::ALL.len() + ); + } + + #[test] + fn send_is_an_alias_for_publish() { + assert_eq!(Command::parse("send").unwrap(), Command::Publish); + } + + #[test] + fn preview_and_completion_are_offline() { + assert!(!Command::Preview.needs_network()); + assert!(!Command::Completion.needs_network()); + assert!(Command::Publish.needs_network()); + } + + #[test] + fn unknown_commands_are_usage_errors() { + assert_eq!(Command::parse("blink").unwrap_err().exit_code(), 2); + } +} diff --git a/src/commands/preview.rs b/src/commands/preview.rs new file mode 100644 index 0000000..9b8feb8 --- /dev/null +++ b/src/commands/preview.rs @@ -0,0 +1,156 @@ +//! `leddy preview` — render the message locally as ASCII art. +//! +//! No device, no network. The framebuffer comes from `leddy-lib`, the same +//! renderer the panel runs, so what shows in the terminal is what the display +//! would show at that moment — this is not a lookalike reimplementation. +//! +//! It is the one command that works with nothing deployed, which makes it the +//! fastest way to check whether a message fits before publishing it. + +use serde::Serialize; + +use crate::error::CliError; +use crate::flags::CliArgs; +use crate::message; +use crate::output::{Format, Report, emit}; + +#[derive(Debug, Serialize)] +pub struct Preview { + pub id: String, + pub width: u16, + pub height: u16, + pub at_ms: u64, + pub content_width_pixels: usize, + pub cycle_ms: Option, + /// `false` once a non-`forever` message has finished its repeats, in which + /// case the display would be blank at `--at`. + pub active: bool, + /// One string per pixel row, `#` lit and `.` dark. + pub rows: Vec, +} + +impl Report for Preview { + fn render_human(&self) -> String { + if !self.active { + return format!( + "message {} has finished repeating by {} ms — the display is blank", + self.id, self.at_ms + ); + } + let mut out = format!( + "{} {}x{} at {} ms ({} px wide{})", + self.id, + self.width, + self.height, + self.at_ms, + self.content_width_pixels, + self.cycle_ms + .map(|milliseconds| format!(", {milliseconds} ms/cycle")) + .unwrap_or_default(), + ); + for row in &self.rows { + out.push('\n'); + out.push_str(row); + } + out + } + + fn exit_code(&self) -> i32 { + // A blank frame is a real answer, not a failure. + 0 + } +} + +pub fn run(args: &CliArgs) -> Result { + let text = args.require_text()?; + let config = message::display(args.width, args.height)?; + let envelope = message::envelope( + args.id.as_deref(), + text, + args.speed, + args.direction, + args.repeat, + message::now_unix_ms(), + )?; + + let content_width_pixels = leddy_lib::content_width(&envelope.text); + let cycle_ms = leddy_lib::scroll_cycle_duration_ms( + envelope.speed_pixels_per_second, + content_width_pixels, + usize::from(config.width), + ); + + // `None` means the message has stopped repeating by now, which is a state + // worth reporting rather than an error. + let frame = leddy_lib::render_message_frame(&config, &envelope, args.at_ms) + .map_err(|error| CliError::usage(format!("cannot render this message: {error}")))?; + + let (active, rows) = match frame { + None => (false, Vec::new()), + Some(frame) => (true, ascii_rows(&frame)), + }; + + emit( + &Preview { + id: envelope.id, + width: config.width, + height: config.height, + at_ms: args.at_ms, + content_width_pixels, + cycle_ms, + active, + rows, + }, + Format::from_json_flag(args.json), + ) +} + +/// One `#`/`.` string per row. A pixel is "lit" at any non-zero brightness, +/// because the renderer writes full-intensity glyph pixels and zero elsewhere. +fn ascii_rows(frame: &leddy_lib::FrameBuffer) -> Vec { + (0..frame.height()) + .map(|y| { + (0..frame.width()) + .map(|x| if frame.get(x, y) > 0 { '#' } else { '.' }) + .collect() + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use leddy_interfaces::{RepeatMode, ScrollDirection}; + + fn frame_rows(at_ms: u64, repeat: RepeatMode) -> Option> { + let config = message::display(64, 8).unwrap(); + let envelope = + message::envelope(Some("t"), "HI", 24.0, ScrollDirection::Left, repeat, 0).unwrap(); + leddy_lib::render_message_frame(&config, &envelope, at_ms) + .unwrap() + .map(|frame| ascii_rows(&frame)) + } + + #[test] + fn a_rendered_frame_has_one_row_per_display_line() { + let rows = frame_rows(0, RepeatMode::Forever).expect("forever is always active"); + assert_eq!(rows.len(), 8); + assert!(rows.iter().all(|row| row.chars().count() == 64)); + } + + #[test] + fn scrolling_moves_the_glyphs() { + // Same message, two moments: the frames must differ, or the preview is + // not actually rendering the scroll. + let early = frame_rows(0, RepeatMode::Forever).unwrap(); + let later = frame_rows(1_500, RepeatMode::Forever).unwrap(); + assert_ne!(early, later); + } + + #[test] + fn a_finished_message_renders_no_frame() { + // `once` past its cycle is blank, which the report shows as inactive + // rather than treating as an error. + assert!(frame_rows(10_000_000, RepeatMode::Once).is_none()); + } +} diff --git a/src/commands/publish.rs b/src/commands/publish.rs new file mode 100644 index 0000000..de99765 --- /dev/null +++ b/src/commands/publish.rs @@ -0,0 +1,78 @@ +//! `leddy publish` — send a scrolling message to the display. +//! +//! The envelope is validated by `leddy-interfaces` before the request is made, +//! so a message the protocol would reject never reaches the device. The +//! estimated cycle length comes from `leddy-lib`, which is the same code the +//! renderer uses — a second formula here would drift from what the panel does. + +use leddy_client_rust::LeddyClient; +use serde::Serialize; + +use crate::commands::request_failed; +use crate::error::CliError; +use crate::flags::CliArgs; +use crate::message; +use crate::output::{Format, Report, emit}; + +#[derive(Debug, Serialize)] +pub struct Published { + pub id: String, + pub text: String, + pub content_width_pixels: usize, + /// One full scroll, in milliseconds, for the display width assumed below. + pub cycle_ms: Option, + pub status: u16, +} + +impl Report for Published { + fn render_human(&self) -> String { + let cycle = self + .cycle_ms + .map(|milliseconds| format!("{milliseconds} ms/cycle")) + .unwrap_or_else(|| "unknown cycle".into()); + format!( + "accepted {} (HTTP {})\n{} rendered pixels, {}", + self.id, self.status, self.content_width_pixels, cycle + ) + } +} + +/// Width assumed when estimating the cycle for the summary line. The device +/// owns the real geometry; `leddy preview --width` is where an exact answer +/// comes from. +const ASSUMED_DISPLAY_WIDTH: usize = 128; + +pub async fn run(client: &LeddyClient, args: &CliArgs) -> Result { + let text = args.require_text()?; + let envelope = message::envelope( + args.id.as_deref(), + text, + args.speed, + args.direction, + args.repeat, + message::now_unix_ms(), + )?; + + let content_width_pixels = leddy_lib::content_width(&envelope.text); + let cycle_ms = leddy_lib::scroll_cycle_duration_ms( + envelope.speed_pixels_per_second, + content_width_pixels, + ASSUMED_DISPLAY_WIDTH, + ); + + let status = client + .publish_message(&envelope) + .await + .map_err(|error| request_failed("publishing the message", &error))?; + + emit( + &Published { + id: envelope.id, + text: envelope.text, + content_width_pixels, + cycle_ms, + status: status.as_u16(), + }, + Format::from_json_flag(args.json), + ) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..a02836a --- /dev/null +++ b/src/error.rs @@ -0,0 +1,65 @@ +//! One error type for the whole binary, carrying the process exit code. +//! +//! Exit codes are part of the CLI contract — scripts branch on them — so they +//! live next to the variants that produce them rather than being scattered +//! across `std::process::exit` calls. + +use std::fmt; + +#[derive(Debug)] +pub enum CliError { + /// Bad invocation: unknown flag, unknown command, out-of-range value. + Usage(String), + /// `.cli-flags.toml` is missing, unreadable, or fails the flags2env audit. + Config(String), + /// The command ran but the work failed (unreachable region, HTTP error). + Runtime(String), +} + +impl CliError { + pub fn usage(message: impl Into) -> Self { + Self::Usage(message.into()) + } + + pub fn config(message: impl Into) -> Self { + Self::Config(message.into()) + } + + pub fn runtime(message: impl Into) -> Self { + Self::Runtime(message.into()) + } + + /// `2` for usage (the shell convention), `3` for a broken config, `1` for + /// everything else. + pub fn exit_code(&self) -> i32 { + match self { + Self::Usage(_) => 2, + Self::Config(_) => 3, + Self::Runtime(_) => 1, + } + } + + /// Usage errors print the help table after the message; runtime errors do + /// not, because the invocation was fine. + pub fn wants_help(&self) -> bool { + matches!(self, Self::Usage(_)) + } +} + +impl fmt::Display for CliError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Usage(message) | Self::Config(message) | Self::Runtime(message) => { + formatter.write_str(message) + } + } + } +} + +impl std::error::Error for CliError {} + +impl From for CliError { + fn from(error: serde_json::Error) -> Self { + Self::Runtime(format!("could not encode JSON output: {error}")) + } +} diff --git a/src/flags.rs b/src/flags.rs new file mode 100644 index 0000000..db3bf62 --- /dev/null +++ b/src/flags.rs @@ -0,0 +1,347 @@ +//! argv + environment → a validated [`CliArgs`], through flags-2-env. +//! +//! The order of operations is the same in every one of our CLIs: +//! +//! 1. **audit** `.cli-flags.toml` — a malformed contract is a config error, not +//! a mysterious parse failure later; +//! 2. **`parse_structured`** — argv-derived values, the resolved command, and +//! the diagnostic channels come back *separately*, so a real environment +//! variable can never be mistaken for something the user typed; +//! 3. **fail closed** on unknown options, invalid values, and stray operands; +//! 4. **layer** schema defaults < process environment < argv, then `coerce`; +//! 5. **range-check** the typed values here, where the message can name the flag. +//! +//! Step 4 is why `provided_flags` is used rather than `flags`: `flags` carries +//! TOML defaults, and spreading those over the real environment would let a +//! default silently beat an environment value the operator set. + +use std::collections::{BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; + +use flags2env::BundledFlags2Env; +use leddy_interfaces::{RepeatMode, ScrollDirection}; + +use crate::cli_config::CliConfig; +use crate::commands::Command; +use crate::error::CliError; +use crate::help::SUPPORTED_SHELLS; +use crate::message; + +/// Everything a command needs, already validated. +#[derive(Clone, Debug, PartialEq)] +pub struct CliArgs { + pub command: Command, + pub api_url: String, + pub text: Option, + pub speed: f64, + pub direction: ScrollDirection, + pub repeat: RepeatMode, + pub id: Option, + pub json: bool, + + // `preview` + pub at_ms: u64, + pub width: u16, + pub height: u16, + + // `completion` + pub shell: String, +} + +impl CliArgs { + /// The message text `publish` and `preview` both need. + pub fn require_text(&self) -> Result<&str, CliError> { + self.text + .as_deref() + .map(str::trim) + .filter(|text| !text.is_empty()) + .ok_or_else(|| CliError::usage("--text is required (the message to scroll)")) + } +} + +/// Finds `.cli-flags.toml`: an explicit override, then the working directory, +/// then next to the installed binary — which is what makes a globally installed +/// `leddy` work from any directory. +pub fn resolve_config_path() -> Result { + if let Some(path) = std::env::var_os("LEDDY_FLAGS_CONFIG").filter(|value| !value.is_empty()) { + let path = PathBuf::from(path); + return path + .is_file() + .then_some(path) + .ok_or_else(|| "LEDDY_FLAGS_CONFIG does not point to a readable file".to_owned()); + } + + let mut candidates = Vec::new(); + if let Ok(current) = std::env::current_dir() { + candidates.push(current.join(".cli-flags.toml")); + } + if let Ok(executable) = std::env::current_exe() + && let Some(parent) = executable.parent() + { + candidates.push(parent.join(".cli-flags.toml")); + candidates.push(parent.join("../share/leddy-cli/.cli-flags.toml")); + } + + candidates + .into_iter() + .find(|candidate| candidate.is_file()) + .ok_or_else(|| { + "cannot locate .cli-flags.toml; set LEDDY_FLAGS_CONFIG to its path".to_owned() + }) +} + +pub fn parse_cli_args(argv: &[String], config_path: &Path) -> Result { + let environment = std::env::vars_os() + .filter_map(|(name, value)| Some((name.into_string().ok()?, value.into_string().ok()?))); + parse_cli_args_with_env(argv, config_path, environment) +} + +fn parse_cli_args_with_env( + argv: &[String], + config_path: &Path, + environment: impl IntoIterator, +) -> Result { + let config_path = config_path + .to_str() + .ok_or_else(|| ".cli-flags.toml path is not valid UTF-8".to_owned())?; + let parser = BundledFlags2Env::new(); + parser + .audit_config(Some(config_path)) + .map_err(|error| format!("flags-2-env configuration audit failed: {error}"))?; + let parsed = parser + .parse_structured(argv, Some(config_path)) + .map_err(|error| format!("flags-2-env parse failed: {error}"))?; + + if !parsed.unknown_options.is_empty() { + let option_names = parsed + .unknown_options + .iter() + .map(|option| diagnostic_option_name(option)) + .collect::>() + .into_iter() + .collect::>() + .join(", "); + return Err(format!("unknown command-line option(s): {option_names}")); + } + if !parsed.errors.is_empty() { + return Err(format!( + "invalid command-line value(s): {}", + parsed.errors.join("; ") + )); + } + if !parsed.extras.is_empty() { + // Values are not echoed: an operand is as likely to be a credential as + // a typo, and the count is enough to spot the mistake. + return Err(format!( + "unknown command or unexpected positional argument(s): {}", + parsed.extras.len() + )); + } + + let mut raw_config = environment.into_iter().collect::>(); + // Command metadata is parser output, never operator input. + raw_config.remove("FLAGS2ENV_COMMAND"); + raw_config.extend(parsed.provided_flags); + let typed = parser + .coerce::(&raw_config, Some(config_path)) + .map_err(|error| format!("invalid typed configuration: {error}"))?; + + let command = match typed.FLAGS2ENV_COMMAND.as_deref() { + None | Some("") => { + return Err( + "a command is required: publish, clear, health, preview, or completion".to_owned(), + ); + } + Some(label) => Command::parse(label).map_err(|error| error.to_string())?, + }; + + let api_url = typed.LEDDY_API_URL; + if !(api_url.starts_with("https://") || api_url.starts_with("http://")) { + return Err("--url must start with http:// or https://".to_owned()); + } + + // Direction and repeat are validated against the shared enums, so an + // unsupported spelling fails here rather than at the device. + let direction = + message::direction(&typed.LEDDY_DIRECTION).map_err(|error| error.to_string())?; + let repeat = message::repeat(&typed.LEDDY_REPEAT).map_err(|error| error.to_string())?; + + // Scoped defaults only apply when their command runs, so each scoped flag + // restates its default for the other commands' sake. + let at_ms = bounded( + typed.LEDDY_PREVIEW_AT_MS.unwrap_or(0), + "LEDDY_PREVIEW_AT_MS", + 0, + 86_400_000, + )? as u64; + let width = bounded(typed.LEDDY_WIDTH.unwrap_or(128), "LEDDY_WIDTH", 1, 4_096)? as u16; + let height = bounded(typed.LEDDY_HEIGHT.unwrap_or(8), "LEDDY_HEIGHT", 1, 512)? as u16; + + let shell = typed + .LEDDY_COMPLETION_SHELL + .unwrap_or_else(|| "bash".into()); + if command == Command::Completion && !SUPPORTED_SHELLS.contains(&shell.as_str()) { + return Err(format!( + "--shell must be one of: {}", + SUPPORTED_SHELLS.join(", ") + )); + } + + Ok(CliArgs { + command, + api_url, + text: typed.LEDDY_TEXT, + speed: typed.LEDDY_SCROLL_SPEED, + direction, + repeat, + id: typed.LEDDY_MESSAGE_ID, + json: typed.LEDDY_JSON, + at_ms, + width, + height, + shell, + }) +} + +/// Strips any `=value` before an unknown option reaches a diagnostic, so a +/// mistyped `--api-token=secret` cannot echo the secret. +fn diagnostic_option_name(option: &str) -> String { + if let Some(long) = option.strip_prefix("--") { + return format!("--{}", long.split('=').next().unwrap_or_default()); + } + option.chars().take(2).collect() +} + +fn bounded(value: i64, name: &str, min: i64, max: i64) -> Result { + (min..=max) + .contains(&value) + .then_some(value) + .ok_or_else(|| format!("{name} must be between {min} and {max}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join(".cli-flags.toml") + } + + fn parse(tokens: &[&str], environment: &[(&str, &str)]) -> Result { + parse_cli_args_with_env( + &tokens + .iter() + .map(|token| (*token).to_owned()) + .collect::>(), + &config_path(), + environment + .iter() + .map(|(name, value)| ((*name).to_owned(), (*value).to_owned())), + ) + } + + #[test] + fn parses_a_publish_invocation() { + let parsed = parse( + &[ + "leddy", + "publish", + "--text=hello", + "--speed=30", + "--repeat=3", + ], + &[], + ) + .expect("valid publish"); + assert_eq!(parsed.command, Command::Publish); + assert_eq!(parsed.require_text().unwrap(), "hello"); + assert_eq!(parsed.speed, 30.0); + assert_eq!(parsed.repeat, RepeatMode::Count(3)); + } + + #[test] + fn send_is_an_alias_for_publish() { + assert_eq!( + parse(&["leddy", "send", "--text=hi"], &[]).unwrap().command, + Command::Publish + ); + } + + #[test] + fn a_command_is_required() { + assert!( + parse(&["leddy", "--text=hi"], &[]) + .expect_err("no command") + .contains("a command is required") + ); + } + + #[test] + fn cli_flags_beat_environment_which_beats_defaults() { + assert_eq!(parse(&["leddy", "clear"], &[]).unwrap().speed, 24.0); + assert_eq!( + parse(&["leddy", "clear"], &[("LEDDY_SCROLL_SPEED", "12.5")]) + .unwrap() + .speed, + 12.5 + ); + assert_eq!( + parse( + &["leddy", "clear", "--speed=8"], + &[("LEDDY_SCROLL_SPEED", "12.5")] + ) + .unwrap() + .speed, + 8.0 + ); + } + + #[test] + fn environment_cannot_spoof_the_resolved_command() { + let error = parse(&["leddy"], &[("FLAGS2ENV_COMMAND", "clear")]) + .expect_err("command metadata is parser output"); + assert!(error.contains("a command is required")); + } + + #[test] + fn unsupported_direction_and_repeat_fail_closed() { + assert!(parse(&["leddy", "clear", "--direction=sideways"], &[]).is_err()); + assert!(parse(&["leddy", "clear", "--repeat=0"], &[]).is_err()); + } + + #[test] + fn command_scoped_flags_stay_in_their_command() { + let parsed = parse(&["leddy", "preview", "--text=hi", "--width=64"], &[]) + .expect("scoped flag under its command"); + assert_eq!(parsed.width, 64); + + let error = parse(&["leddy", "clear", "--width=64"], &[]).expect_err("out of scope"); + assert!(error.contains("unknown command-line option")); + } + + #[test] + fn out_of_range_geometry_is_rejected_by_name() { + let error = + parse(&["leddy", "preview", "--text=hi", "--height=0"], &[]).expect_err("below range"); + assert!(error.contains("LEDDY_HEIGHT")); + } + + #[test] + fn a_device_token_is_never_accepted_as_a_flag() { + // LEDDY_API_TOKEN is an `[env] ignore` entry: usable from the + // environment, rejected on the command line where `ps` would see it. + let error = parse(&["leddy", "health", "--api-token=must-not-appear"], &[]) + .expect_err("credential flag"); + assert!(error.contains("unknown command-line option")); + assert!(!error.contains("must-not-appear")); + + assert!(parse(&["leddy", "health"], &[("LEDDY_API_TOKEN", "t")]).is_ok()); + } + + #[test] + fn unknown_flags_do_not_reflect_their_values() { + let error = parse(&["leddy", "clear", "--nope=sentinel-value"], &[]).unwrap_err(); + assert!(error.contains("--nope")); + assert!(!error.contains("sentinel-value")); + } +} diff --git a/src/help.rs b/src/help.rs new file mode 100644 index 0000000..e25b007 --- /dev/null +++ b/src/help.rs @@ -0,0 +1,155 @@ +//! `--help` and shell completions, rendered by the flags-2-env C core. +//! +//! Everything here is derived from `.cli-flags.toml` at runtime, so there is no +//! second copy of the flag list to keep in sync. The table is subcommand-aware: +//! `leddy --help` lists the commands, `leddy publish --help` shows that +//! command's own flags plus the inherited global ones. +//! +//! The `flags2env` crate compiles the vendored C parser through its build +//! script and statically links it into this binary, which is why these symbols +//! resolve without a `libflags2env` shared library at runtime. Cargo only links +//! that object when the `flags2env` Rust crate is actually referenced, so every +//! entry point here starts by constructing the client — see [`native_core`]. + +//! FFI is confined to this module: the crate denies `unsafe_code` everywhere +//! else, and this is the only place that needs it. +#![allow(unsafe_code)] + +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_int}; +use std::path::Path; + +use flags2env::BundledFlags2Env; + +use crate::error::CliError; + +unsafe extern "C" { + fn f2e_is_help_requested_json_argv(argv_json: *const c_char) -> c_int; + fn f2e_help_table_for_json_argv_from_file( + config_path: *const c_char, + command_name: *const c_char, + argv_json: *const c_char, + terminal_columns: c_int, + ) -> *mut c_char; + fn f2e_completion_script_from_file( + config_path: *const c_char, + shell: *const c_char, + command_name: *const c_char, + ) -> *mut c_char; + fn f2e_free(value: *mut c_char); +} + +/// Shells `flags2env completion` knows how to emit. +pub const SUPPORTED_SHELLS: [&str; 2] = ["bash", "zsh"]; + +/// Anchors the statically linked C core into the link graph. Constructing the +/// zero-sized client is free; dropping this call would let Cargo omit the +/// native object and the `f2e_*` symbols above would fail to resolve. +#[inline] +fn native_core() -> BundledFlags2Env { + BundledFlags2Env::new() +} + +/// True when argv contains the exact `--help`/`-h` token, per the core's own +/// rules (so the binary and `flags2env` agree on what "asked for help" means). +pub fn is_help_requested(argv: &[String]) -> bool { + let _core = native_core(); + let Ok(argv_json) = encode_argv(argv) else { + return false; + }; + // SAFETY: `argv_json` is a valid NUL-terminated JSON array of strings and + // outlives the call; the core only reads it. + unsafe { f2e_is_help_requested_json_argv(argv_json.as_ptr()) == 1 } +} + +/// The help table for whichever command `argv` selects. +pub fn help_table(config_path: &Path, program: &str, argv: &[String]) -> Result { + let _core = native_core(); + let config = c_string(config_path_str(config_path)?)?; + let program = c_string(program)?; + let argv_json = encode_argv(argv)?; + // SAFETY: all three CStrings outlive the call, and the returned pointer is + // owned by the caller — `take_owned` releases it through `f2e_free`. + let table = unsafe { + take_owned(f2e_help_table_for_json_argv_from_file( + config.as_ptr(), + program.as_ptr(), + argv_json.as_ptr(), + terminal_columns(), + )) + }; + table.ok_or_else(|| CliError::config("flags-2-env could not render the help table")) +} + +/// A static completion script for `shell` — it does no TOML reading or process +/// spawning at tab-completion time. +pub fn completion_script( + config_path: &Path, + shell: &str, + program: &str, +) -> Result { + let _core = native_core(); + if !SUPPORTED_SHELLS.contains(&shell) { + return Err(CliError::usage(format!( + "unsupported shell {shell:?}; expected one of: {}", + SUPPORTED_SHELLS.join(", ") + ))); + } + let config = c_string(config_path_str(config_path)?)?; + let shell = c_string(shell)?; + let program = c_string(program)?; + // SAFETY: as above — borrowed inputs outlive the call, result is owned. + let script = unsafe { + take_owned(f2e_completion_script_from_file( + config.as_ptr(), + shell.as_ptr(), + program.as_ptr(), + )) + }; + script.ok_or_else(|| CliError::config("flags-2-env could not render the completion script")) +} + +/// Width used for the help table. `COLUMNS` is honoured when it is a sane +/// number so piped output stays reproducible; otherwise the core picks a +/// layout for the default width. +fn terminal_columns() -> c_int { + std::env::var("COLUMNS") + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|columns| (40..=400).contains(columns)) + .unwrap_or(100) +} + +fn encode_argv(argv: &[String]) -> Result { + let json = serde_json::to_string(argv)?; + c_string(&json) +} + +fn c_string(value: &str) -> Result { + CString::new(value) + .map_err(|_| CliError::usage("arguments must not contain interior NUL bytes")) +} + +fn config_path_str(config_path: &Path) -> Result<&str, CliError> { + config_path + .to_str() + .ok_or_else(|| CliError::config(".cli-flags.toml path is not valid UTF-8")) +} + +/// Takes ownership of a heap string returned by the C core, copying it into a +/// `String` and releasing the original through `f2e_free`. +/// +/// # Safety +/// +/// `value` must be null or a pointer returned by an `F2E_OWNED_RESULT` function +/// that has not already been freed. +unsafe fn take_owned(value: *mut c_char) -> Option { + if value.is_null() { + return None; + } + let owned = unsafe { CStr::from_ptr(value) } + .to_string_lossy() + .into_owned(); + unsafe { f2e_free(value) }; + Some(owned) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..af3c080 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,83 @@ +//! `leddy` — the command-line client for [Leddy](https://github.com/led-dynamo) +//! LED matrix displays. +//! +//! Publish a scrolling message, clear the panel, check the API, or render the +//! same message locally as ASCII art without touching a device. +//! +//! The binary in `src/main.rs` is deliberately thin: argv in, exit code out. +//! Everything else is a module with one job: +//! +//! | module | job | +//! | --- | --- | +//! | [`flags`] | argv + environment → a validated [`flags::CliArgs`], via flags-2-env | +//! | [`cli_config`] | the env-keyed struct generated from `.cli-flags.toml` | +//! | [`help`] | `--help` tables and shell completions, rendered by the C core | +//! | [`message`] | builds and validates a `MessageEnvelope` from the flags | +//! | [`commands`] | one module per subcommand, each returning an [`output::Report`] | +//! | [`output`] | human output vs. `--json` | +//! | [`error`] | [`error::CliError`] and the exit codes it maps to | +//! +//! All three org layers are real dependencies, not decoration: +//! `leddy-interfaces` owns the wire shapes and their validators, +//! `leddy-lib` owns the framebuffer and renderer `preview` draws with, and +//! `leddy-client-rust` owns every HTTP call. + +// Regenerate after editing `.cli-flags.toml`: +// flags2env generate rust .cli-flags.toml --name CliConfig > src/cli_config.rs +// CI diffs the file against fresh generator output, so it stays byte-identical. +// Command-scoped flags land there as `Option` even when they declare a default, +// because a scoped default only applies when its own command runs. +pub mod cli_config; +pub mod commands; +pub mod error; +pub mod flags; +pub mod help; +pub mod message; +pub mod output; + +pub use error::CliError; +pub use output::{Format, Report}; + +/// The program name used in help tables, completion scripts, and diagnostics. +pub const PROGRAM: &str = "leddy"; + +/// Parses `argv`, runs the selected command, and returns the process exit code. +pub fn run(argv: &[String]) -> i32 { + let config_path = match flags::resolve_config_path() { + Ok(path) => path, + Err(error) => return report(&CliError::config(error), None, argv), + }; + + if help::is_help_requested(argv) { + return match help::help_table(&config_path, PROGRAM, argv) { + Ok(table) => { + print!("{table}"); + 0 + } + Err(error) => report(&error, None, argv), + }; + } + + let args = match flags::parse_cli_args(argv, &config_path) { + Ok(args) => args, + Err(error) => return report(&CliError::usage(error), Some(&config_path), argv), + }; + + match commands::dispatch(&args, &config_path) { + Ok(code) => code, + Err(error) => report(&error, Some(&config_path), argv), + } +} + +/// Prints a diagnostic on stderr, follows usage errors with the generated help +/// table, and returns the error's exit code. +fn report(error: &CliError, config_path: Option<&std::path::Path>, argv: &[String]) -> i32 { + eprintln!("{PROGRAM}: {error}"); + if error.wants_help() + && let Some(config_path) = config_path + && let Ok(table) = help::help_table(config_path, PROGRAM, argv) + { + eprint!("\n{table}"); + } + error.exit_code() +} diff --git a/src/main.rs b/src/main.rs index e1d98fd..f618ae5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,80 +1,10 @@ -#![forbid(unsafe_code)] - -use clap::{Parser, Subcommand, ValueEnum}; -use leddy_client_rust::LeddyClient; -use leddy_interfaces::{MessageEnvelope, RepeatMode, ScrollDirection}; -use std::time::{SystemTime, UNIX_EPOCH}; - -#[derive(Parser)] -#[command(name = "leddy", version, about = "Control Leddy LED displays")] -struct Arguments { - #[arg(long, env = "LEDDY_API_URL", default_value = "http://localhost:8080")] - api_url: String, - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand)] -enum Command { - Send { - text: String, - #[arg(long, env = "LEDDY_SCROLL_SPEED", default_value_t = 24.0)] - speed: f32, - #[arg(long, value_enum, default_value_t = Direction::Left)] - direction: Direction, - }, - Clear, - Health, -} - -#[derive(Clone, Copy, ValueEnum)] -enum Direction { - Left, - Right, -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - let arguments = Arguments::parse(); - let client = LeddyClient::new(arguments.api_url); - - match arguments.command { - Command::Send { - text, - speed, - direction, - } => { - let message = MessageEnvelope { - id: format!("cli-{}", now_unix_ms()), - text, - speed_pixels_per_second: speed, - direction: match direction { - Direction::Left => ScrollDirection::Left, - Direction::Right => ScrollDirection::Right, - }, - repeat: RepeatMode::Forever, - issued_at_unix_ms: now_unix_ms(), - }; - message.validate()?; - let width = leddy_lib::content_width(&message.text); - let status = client.publish_message(&message).await?; - println!( - "accepted {} ({} rendered pixels, HTTP {})", - message.id, width, status - ); - } - Command::Clear => println!("clear accepted (HTTP {})", client.clear().await?), - Command::Health => { - client.health().await?; - println!("ok"); - } - } - Ok(()) -} - -fn now_unix_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64 +//! `leddy` — command-line tool for Leddy LED displays. +//! +//! This file stays thin on purpose: argv in, exit code out. The CLI surface is +//! declared in `.cli-flags.toml` and the behaviour lives in the library modules +//! documented in `src/lib.rs`. + +fn main() { + let argv = std::env::args().collect::>(); + std::process::exit(leddy_cli::run(&argv)); } diff --git a/src/message.rs b/src/message.rs new file mode 100644 index 0000000..aa6c6b3 --- /dev/null +++ b/src/message.rs @@ -0,0 +1,173 @@ +//! Turning flags into a `MessageEnvelope`. +//! +//! The envelope and its rules belong to `leddy-interfaces`, so this module only +//! translates flag strings into the shared enums and then calls the contract's +//! own `validate()`. Re-checking text length or scroll speed here would be a +//! second, quietly divergent copy of the protocol limits. + +use leddy_interfaces::{DisplayConfig, MessageEnvelope, RepeatMode, ScrollDirection}; + +use crate::error::CliError; + +/// Parses the `--direction` flag. +pub fn direction(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "left" => Ok(ScrollDirection::Left), + "right" => Ok(ScrollDirection::Right), + other => Err(CliError::usage(format!( + "--direction must be left or right, got {other:?}" + ))), + } +} + +/// Parses the `--repeat` flag: `forever`, `once`, or a positive count. +pub fn repeat(value: &str) -> Result { + let value = value.trim().to_ascii_lowercase(); + match value.as_str() { + "forever" => Ok(RepeatMode::Forever), + "once" => Ok(RepeatMode::Once), + count => count + .parse::() + .ok() + .filter(|count| *count > 0) + .map(RepeatMode::Count) + .ok_or_else(|| { + CliError::usage(format!( + "--repeat must be forever, once, or a positive count, got {count:?}" + )) + }), + } +} + +/// Builds the envelope and hands it to the contract's validator. +pub fn envelope( + id: Option<&str>, + text: &str, + speed: f64, + direction: ScrollDirection, + repeat: RepeatMode, + now_unix_ms: u64, +) -> Result { + let envelope = MessageEnvelope { + id: id + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| format!("cli-{now_unix_ms}")), + text: text.to_owned(), + speed_pixels_per_second: speed as f32, + direction, + repeat, + issued_at_unix_ms: now_unix_ms, + }; + // The protocol limits live in leddy-interfaces; this is the only check. + envelope + .validate() + .map_err(|error| CliError::usage(format!("invalid message: {error}")))?; + Ok(envelope) +} + +/// Builds and validates the display geometry `preview` renders into. +pub fn display(width: u16, height: u16) -> Result { + let config = DisplayConfig { + width, + height, + brightness: 96, + serpentine: false, + origin: leddy_interfaces::PixelOrigin::TopLeft, + }; + config + .validate() + .map_err(|error| CliError::usage(format!("invalid display geometry: {error}")))?; + Ok(config) +} + +/// Wall-clock milliseconds, used for the default message id and `issued_at`. +pub fn now_unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn direction_and_repeat_accept_their_documented_spellings() { + assert_eq!(direction("LEFT").unwrap(), ScrollDirection::Left); + assert_eq!(direction(" right ").unwrap(), ScrollDirection::Right); + assert!(direction("sideways").is_err()); + + assert_eq!(repeat("forever").unwrap(), RepeatMode::Forever); + assert_eq!(repeat("Once").unwrap(), RepeatMode::Once); + assert_eq!(repeat("3").unwrap(), RepeatMode::Count(3)); + // Zero repeats would render nothing, which is a mistake, not a mode. + assert!(repeat("0").is_err()); + assert!(repeat("-1").is_err()); + assert!(repeat("many").is_err()); + } + + #[test] + fn an_omitted_id_gets_a_timestamped_default() { + let envelope = envelope( + None, + "hi", + 24.0, + ScrollDirection::Left, + RepeatMode::Once, + 1234, + ) + .expect("valid message"); + assert_eq!(envelope.id, "cli-1234"); + assert_eq!(envelope.issued_at_unix_ms, 1234); + + let explicit = envelope_with_id(" banner "); + assert_eq!(explicit.id, "banner"); + // A blank --id falls back rather than producing an envelope the + // contract would reject. + assert_eq!(envelope_with_id(" ").id, "cli-1234"); + } + + fn envelope_with_id(id: &str) -> MessageEnvelope { + envelope( + Some(id), + "hi", + 24.0, + ScrollDirection::Left, + RepeatMode::Once, + 1234, + ) + .expect("valid message") + } + + #[test] + fn contract_validation_is_not_duplicated_here() { + // Empty text and a non-positive speed are rejected by + // leddy-interfaces' own validator, surfaced as usage errors. + let empty = envelope( + None, + " ", + 24.0, + ScrollDirection::Left, + RepeatMode::Once, + 1, + ) + .unwrap_err(); + assert_eq!(empty.exit_code(), 2); + + let stopped = + envelope(None, "hi", 0.0, ScrollDirection::Left, RepeatMode::Once, 1).unwrap_err(); + assert!(stopped.to_string().contains("speed")); + } + + #[test] + fn display_geometry_is_validated_by_the_contract() { + assert!(display(128, 8).is_ok()); + assert!(display(0, 8).is_err()); + assert!(display(128, 0).is_err()); + // Beyond the protocol safety limits in leddy-interfaces. + assert!(display(4097, 8).is_err()); + } +} diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..a48fa3c --- /dev/null +++ b/src/output.rs @@ -0,0 +1,41 @@ +//! Human tables versus `--json`. +//! +//! Every command returns a value that knows both renderings, so the `--json` +//! branch is decided once here instead of in each command body. + +use serde::Serialize; + +use crate::error::CliError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Format { + Human, + Json, +} + +impl Format { + pub fn from_json_flag(json: bool) -> Self { + if json { Self::Json } else { Self::Human } + } +} + +/// A command result that can be printed either way. `Serialize` covers `--json`; +/// `render_human` covers the default table. +pub trait Report: Serialize { + fn render_human(&self) -> String; + + /// Exit code for a *successful parse* whose result is still a failure — + /// e.g. every region was unreachable. Defaults to success. + fn exit_code(&self) -> i32 { + 0 + } +} + +/// Prints `report` in the requested format and returns its exit code. +pub fn emit(report: &R, format: Format) -> Result { + match format { + Format::Human => println!("{}", report.render_human()), + Format::Json => println!("{}", serde_json::to_string_pretty(report)?), + } + Ok(report.exit_code()) +} diff --git a/tests/cli_contract.rs b/tests/cli_contract.rs new file mode 100644 index 0000000..5ebdd33 --- /dev/null +++ b/tests/cli_contract.rs @@ -0,0 +1,128 @@ +//! End-to-end assertions on the built binary. +//! +//! The unit tests in `src/flags.rs` cover parsing; these cover the things only +//! a real process can show: that `--help` is rendered from `.cli-flags.toml` +//! rather than a Rust string, that command-scoped flags stay scoped, and that +//! the documented exit codes are what a script actually observes. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf() +} + +fn leddy(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_leddy")) + .args(args) + .current_dir(repo_root()) + // Pin the width so the table layout does not depend on the terminal + // running the test. + .env("COLUMNS", "100") + .output() + .expect("the leddy binary should run") +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +#[test] +fn root_help_lists_every_declared_command() { + let output = leddy(&["--help"]); + assert!(output.status.success()); + let help = stdout(&output); + + // Sourced from .cli-flags.toml, so this is really asserting that the two + // stay in sync without a hand-maintained usage string in between. + for command in [ + "publish", + "send", + "clear", + "health", + "preview", + "completion", + ] { + assert!(help.contains(command), "root help omits {command}:\n{help}"); + } + for flag in ["--url", "--text", "--speed", "--json", "LEDDY_SCROLL_SPEED"] { + assert!(help.contains(flag), "root help omits {flag}:\n{help}"); + } +} + +#[test] +fn command_scoped_flags_appear_only_under_their_command() { + let scoped = stdout(&leddy(&["preview", "--help"])); + assert!( + scoped.contains("--width"), + "preview help omits --width:\n{scoped}" + ); + + let root = stdout(&leddy(&["--help"])); + assert!( + !root.contains("--width"), + "a command-scoped flag leaked into the root help table:\n{root}" + ); + + // And it is rejected outright outside its command rather than ignored. + let output = leddy(&["clear", "--width=8"]); + assert_eq!(output.status.code(), Some(2)); + assert!(stderr(&output).contains("unknown command-line option")); +} + +#[test] +fn completion_scripts_are_emitted_for_both_shells() { + for shell in ["bash", "zsh"] { + let output = leddy(&["completion", "--shell", shell]); + assert!(output.status.success(), "{shell} completion failed"); + let script = stdout(&output); + assert!(script.contains("leddy"), "{shell} script names no command"); + // The point of a static script: no runtime dependency on the parser. + assert!( + !script.contains("flags2env audit"), + "{shell} completion shells out at completion time" + ); + } + + let rejected = leddy(&["completion", "--shell", "fish"]); + assert_eq!(rejected.status.code(), Some(2)); +} + +#[test] +fn exit_codes_match_the_documented_contract() { + // 2 — bad invocation. + assert_eq!(leddy(&["clear", "--nope"]).status.code(), Some(2)); + assert_eq!(leddy(&["definitely-not-a-command"]).status.code(), Some(2)); + + // 3 — the contract itself could not be read. + let broken = Command::new(env!("CARGO_BIN_EXE_leddy")) + .arg("clear") + .current_dir(repo_root()) + .env("LEDDY_FLAGS_CONFIG", "/nonexistent/.cli-flags.toml") + .output() + .expect("the leddy binary should run"); + assert_eq!(broken.status.code(), Some(3)); + + // 1 — the invocation was fine, the work failed. + // 1 — the invocation was fine, the work failed: nothing is listening. + assert_eq!( + leddy(&["health", "--url=http://127.0.0.1:1"]).status.code(), + Some(1) + ); +} + +#[test] +fn rejected_option_values_are_not_reflected_back() { + // A mistyped flag is as likely to carry a secret as a typo, so diagnostics + // name the option and never its value. + let sentinel = "must-remain-environment-only"; + let output = leddy(&["clear", &format!("--api-token={sentinel}")]); + assert_eq!(output.status.code(), Some(2)); + let combined = format!("{}{}", stdout(&output), stderr(&output)); + assert!(combined.contains("--api-token")); + assert!(!combined.contains(sentinel)); +}