From 34a7bd7971fb1509405757a6564e1219494a4749 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Thu, 6 Aug 2026 19:20:33 -0500 Subject: [PATCH 1/2] feat: rewrite leddy on flags-2-env, modularize, add offline preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI was a single main.rs driving clap. It is now a modular crate whose surface is declared in .cli-flags.toml, and it uses all three org layers instead of two. flags-2-env - .cli-flags.toml is the CLI contract. --help and both shell completions are rendered from it by the statically linked C core at runtime, so there is no usage string in Rust to drift. - Command-scoped flags: --width/--height/--at exist under preview only, and are rejected unknown options elsewhere. - src/cli_config.rs is generated from the contract; CI diffs it for drift. - LEDDY_API_TOKEN is an [env] ignore entry: usable from the environment, never a flag, because a flag value is visible in ps output and shell history. new: leddy preview - Renders the message locally as ASCII art with no device and no network, through leddy_lib::render_message_frame — the same renderer the panel runs, so it is not a lookalike reimplementation. - A finished `--repeat once` message reports a blank display rather than failing. org dependencies - leddy-lib is now a real dependency: message cycle length and rendering both come from it. Previously only interfaces and clients were used. - Message and geometry validation is leddy-interfaces' own validate(); the protocol limits are not restated here. - Cargo entries deliberately carry no rev: leddy-lib and leddy-clients depend on leddy-interfaces by plain git URL, and cargo only unifies git sources whose specs match. A rev here would fork leddy-interfaces into two crates. - scripts/check-zed-dependencies.py replaces check-zed-package.py and also verifies every declared zed edge is a real Cargo dependency. layout: main.rs is argv-in/exit-code-out; flags, help, message, commands/, output, and error each do one job. preview and completion never start a runtime or an HTTP client — Command::needs_network decides that once, in the dispatcher. unsafe_code is denied crate-wide, with src/help.rs the single module that opts out for the C bindings. Exit codes: 2 usage, 3 config, 1 runtime. 26 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- .cli-flags.toml | 118 +++ .github/workflows/ci.yml | 72 +- .github/workflows/zed-package.yml | 2 +- .gitignore | 30 +- .zpkg.toml | 35 +- Cargo.lock | 1325 +++++++++++++++++++++++++++++ Cargo.toml | 37 +- LICENSE | 2 +- README.md | 145 +++- rust-toolchain.toml | 5 + scripts/check-zed-dependencies.py | 76 ++ scripts/check-zed-package.py | 28 - src/cli_config.rs | 25 + src/commands/clear.rs | 33 + src/commands/completion.rs | 18 + src/commands/health.rs | 37 + src/commands/mod.rs | 159 ++++ src/commands/preview.rs | 156 ++++ src/commands/publish.rs | 78 ++ src/error.rs | 65 ++ src/flags.rs | 347 ++++++++ src/help.rs | 155 ++++ src/lib.rs | 83 ++ src/main.rs | 88 +- src/message.rs | 173 ++++ src/output.rs | 41 + tests/cli_contract.rs | 128 +++ 27 files changed, 3293 insertions(+), 168 deletions(-) create mode 100644 .cli-flags.toml create mode 100644 Cargo.lock create mode 100644 rust-toolchain.toml create mode 100755 scripts/check-zed-dependencies.py delete mode 100755 scripts/check-zed-package.py create mode 100644 src/cli_config.rs create mode 100644 src/commands/clear.rs create mode 100644 src/commands/completion.rs create mode 100644 src/commands/health.rs create mode 100644 src/commands/mod.rs create mode 100644 src/commands/preview.rs create mode 100644 src/commands/publish.rs create mode 100644 src/error.rs create mode 100644 src/flags.rs create mode 100644 src/help.rs create mode 100644 src/lib.rs create mode 100644 src/message.rs create mode 100644 src/output.rs create mode 100644 tests/cli_contract.rs 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..7e6bc26 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: 8a978aef0cc9b12bdd0791d93bbf3a374c517ee2 + - 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..6f0fa6b --- /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=8a978aef0cc9b12bdd0791d93bbf3a374c517ee2#8a978aef0cc9b12bdd0791d93bbf3a374c517ee2" +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..7e33525 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 = "8a978aef0cc9b12bdd0791d93bbf3a374c517ee2" } + +# 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)); +} From 8bd81d8052294ee6b6f9bf6785bc1654724b1d5d Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Thu, 6 Aug 2026 23:01:23 -0500 Subject: [PATCH 2/2] chore(deps): bump flags2env past the inline-truncation fix ORESoftware/flags-2-env#25 merged, so the pin moves from 8a978ae to 8c84655 (flags-2-env 0.2.0). That removes the silent truncation of an inline --flag=value whose token ran past ~97 characters, which affected long paths, URLs, JSON payloads, and bearer tokens. The 'known upstream limitation' note in the README is dropped because it is no longer true. Tests pass on the new rev. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 2 +- Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e6bc26..b4af4c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: 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: 8a978aef0cc9b12bdd0791d93bbf3a374c517ee2 + 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 diff --git a/Cargo.lock b/Cargo.lock index 6f0fa6b..ffdd8ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,7 +94,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flags2env" version = "0.1.0" -source = "git+https://github.com/ORESoftware/flags-2-env.git?rev=8a978aef0cc9b12bdd0791d93bbf3a374c517ee2#8a978aef0cc9b12bdd0791d93bbf3a374c517ee2" +source = "git+https://github.com/ORESoftware/flags-2-env.git?rev=8c8465561075a8d7ebb58074b3a8087138e97d8f#8c8465561075a8d7ebb58074b3a8087138e97d8f" dependencies = [ "cc", "libloading", diff --git a/Cargo.toml b/Cargo.toml index 7e33525..99f2158 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ unsafe_code = "deny" # 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 = "8a978aef0cc9b12bdd0791d93bbf3a374c517ee2" } +flags2env = { git = "https://github.com/ORESoftware/flags-2-env.git", rev = "8c8465561075a8d7ebb58074b3a8087138e97d8f" } # Org dependencies — the same three edges declared in `.zpkg.toml`. #