From de40d6dcda3df1a1f36d2f1bb094ed7a3ca6090c Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Tue, 15 Sep 2026 18:03:00 +0200 Subject: [PATCH 01/10] docs: add AGENTS.md knowledge base Init-deep inline path (S~345KB, N_quick=1). Root only, no subdirs warranted. --- AGENTS.md | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..268de7c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,77 @@ +# PROJECT KNOWLEDGE BASE + +**Generated:** 2026-09-15 +**Commit:** bc0f758 +**Branch:** main + +## OVERVIEW +Rust CLI for Graylog (search/aggregate/streams/system). Hexagonal single crate, tokio + clap + reqwest/rustls. + +## STRUCTURE +``` +graylog-cli/ +├── src/main.rs # composition root, updater worker +├── src/lib.rs # pub mods only +├── src/domain/ # config, models, timerange, error +├── src/application/ # service, updater_service, ports/ +├── src/infrastructure/ # graylog_client, config_store, updater +├── src/presentation/ # cli (clap), output (json/table) +├── tests/cli_integration.rs +└── benches/ +``` + +## WHERE TO LOOK +| Task | Location | Notes | +|------|----------|-------| +| CLI commands/flags | `src/presentation/cli.rs` | clap derive, `validate()` + `to_input()` | +| Use-cases | `src/application/service.rs` | `ApplicationService`, 1969 lines | +| HTTP + normalization | `src/infrastructure/graylog_client.rs` | 1847 lines, `X-Requested-By` | +| Config/cache files | `src/infrastructure/config_store.rs` | `~/.config/graylog-cli/config.toml`, 0700 | +| Output/exit codes | `src/presentation/output.rs` | JSON envelope, codes 1-6 | +| Self-update | `src/application/updater_service.rs`, `src/main.rs` | `__self-update-worker`, 24h throttle | +| Release | `.github/workflows/release.yml` | tag `v*` must be on main | + +## CODE MAP +| Symbol | Type | Location | Role | +|--------|------|----------|------| +| `ApplicationService` | struct | `application/service.rs` | search/aggregate/auth/ping/streams/system/fields | +| `Config/GraylogConfig/UpdaterConfig` | struct | `domain/config.rs` | TOML config, `SecretString` token | +| `CliError/HttpError/ValidationError` | enum | `domain/error.rs` | layered thiserror + exn | +| `ConfigStore/CacheStore/GraylogGateway/UpdaterGateway` | trait | `application/ports/` | DI seams, glob re-export | +| `FileConfigStore` | struct | `infrastructure/config_store.rs` | atomic write, implements both stores | +| `Cli/Commands` | enum | `presentation/cli.rs` | `auth/search/aggregate/count-by-level/streams/system/ping/fields/upgrade` | +| `print_json/print_table/exit_code_for_cli_error` | fn | `presentation/output.rs` | machine contract | + +## CONVENTIONS +- Hexagonal: `application` never does I/O directly, only via `ports` traits; adapters in `infrastructure`. +- Errors: `thiserror` layer enums + `exn::Result`; `main()` returns `()`, single `emit_cli_error` prints JSON to stderr. +- Success = JSON on stdout; `--format table` only exception. No prompts, non-interactive. +- Token via `--token` or `GRAYLOG_TOKEN` env (clap `env`). Auto-update via `GRAYLOG_CLI_AUTO_UPDATE` or `[updater]` TOML. +- Edition 2024 (let-chains), `max_width=100`, treefmt via pre-commit. Conventional Commits. + +## ANTI-PATTERNS (THIS PROJECT) +- Don't return `Result` from `main` or add `anyhow`; use `exn` + JSON envelope + semantic exit codes. +- Don't I/O in `ApplicationService`; go through `ports`. +- Don't set `target-cpu=native` in `.cargo/config.toml` (breaks cross builds). +- No `unsafe`, no `#[allow]` in `src/`; clippy `-D warnings` in CI. +- Never serialize `GraylogConfig` to stdout (token is plaintext in TOML serde). + +## UNIQUE STYLES +- `lib.rs` is 4 lines; `main.rs` wires `Arc` twice (config + cache). +- Fields cache single global key `"fields"` with TTL 300s. +- Release patches `Cargo.toml` version from tag; `publish=false`, ships binaries only. + +## COMMANDS +```bash +nix develop --command cargo test --all --locked +nix develop --command cargo clippy --all-targets --locked -- -D warnings +nix develop --command cargo deny --all-features check +cargo bench # timerange_parsing, json_normalization +nix build +nix build .#graylog-cli-windows +``` + +## NOTES +- Two hotspots hold ~52% LOC: `service.rs`, `graylog_client.rs`. `cli.rs` (852) holds full command tree. +- `publish=false`; tags `v*` must point at `main` (verified in release.yml). +- `dirs::config_dir()/graylog-cli/` holds `config.toml` + `.json` caches. From cc34c6e0ee974839c0d01d3abc4681050e2e7aee Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Tue, 15 Sep 2026 18:03:01 +0200 Subject: [PATCH 02/10] chore(deps): update Rust dependencies to latest semver-compatible --- Cargo.lock | 867 ++++++++++++++++++++++------------------------------- 1 file changed, 363 insertions(+), 504 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b92ccec..62812e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -69,13 +69,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -86,15 +86,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -102,14 +102,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -118,17 +119,23 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytecount" @@ -138,9 +145,9 @@ checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cast" @@ -150,9 +157,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.60" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" dependencies = [ "find-msvc-tools", "jobserver", @@ -160,12 +167,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.4" @@ -174,9 +175,20 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] [[package]] name = "ciborium" @@ -207,9 +219,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -217,9 +229,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -229,21 +241,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "cmake" @@ -262,9 +274,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -286,6 +298,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "criterion" version = "0.5.1" @@ -324,9 +345,9 @@ dependencies = [ [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -334,18 +355,18 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crunchy" @@ -358,9 +379,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "dirs" @@ -385,13 +403,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -402,9 +420,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "either" -version = "1.15.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "equivalent" @@ -424,21 +442,21 @@ dependencies = [ [[package]] name = "exn" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca6badeb8a07b76663c7d7cb0903c1cc40c38d6fc06d3a2206780c63e8b9496" +checksum = "84c8eaf8836c22a841ee5362e227dd09e5908b52fa2a9bc786cf153c0f344b99" [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "fnv" @@ -463,30 +481,30 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-task", @@ -509,15 +527,15 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.4" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi", - "wasip2", + "rand_core", "wasm-bindgen", ] @@ -539,7 +557,7 @@ dependencies = [ "serde_json", "tabled", "tempfile", - "thiserror 2.0.18", + "thiserror", "time", "tokio", "toml", @@ -561,9 +579,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -573,15 +591,15 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "http" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -589,9 +607,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -599,9 +617,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -618,15 +636,15 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "humantime" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] name = "hyper" -version = "1.9.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -663,7 +681,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -682,9 +700,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -696,9 +714,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -709,9 +727,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -723,16 +741,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -743,15 +762,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -775,9 +794,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -785,9 +804,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown", @@ -795,19 +814,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "is-terminal" @@ -843,27 +852,32 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jni" -version = "0.21.1" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cesu8", "cfg-if", "combine", - "jni-sys 0.3.1", + "jni-macros", + "jni-sys", "log", - "thiserror 1.0.69", + "simd_cesu8", + "thiserror", "walkdir", - "windows-sys 0.45.0", + "windows-link", ] [[package]] -name = "jni-sys" -version = "0.3.1" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "jni-sys 0.4.1", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", ] [[package]] @@ -882,28 +896,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -915,15 +928,15 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.185" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" dependencies = [ "libc", ] @@ -936,21 +949,21 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "log" -version = "0.4.29" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru-slab" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "matchers" @@ -963,15 +976,15 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mio" -version = "1.2.0" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", @@ -989,9 +1002,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-traits" @@ -1055,6 +1068,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "plotters" version = "0.3.7" @@ -1085,9 +1104,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -1098,15 +1117,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -1126,23 +1136,23 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", "cfg_aliases", @@ -1152,7 +1162,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", "web-time", @@ -1160,21 +1170,22 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", "rand", + "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror", "tinyvec", "tracing", "web-time", @@ -1182,60 +1193,57 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.3.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.4" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "rand_chacha", + "chacha20", + "getrandom 0.4.3", "rand_core", ] [[package]] -name = "rand_chacha" -version = "0.9.0" +name = "rand_core" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] -name = "rand_core" -version = "0.9.5" +name = "rand_pcg" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "getrandom 0.3.4", + "rand_core", ] [[package]] @@ -1266,14 +1274,14 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -1283,9 +1291,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1294,17 +1302,17 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "futures-core", "http", @@ -1351,9 +1359,18 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] [[package]] name = "rustix" @@ -1370,9 +1387,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.38" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "once_cell", @@ -1384,9 +1401,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -1396,9 +1413,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -1406,9 +1423,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation", "core-foundation-sys", @@ -1433,9 +1450,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -1445,9 +1462,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "same-file" @@ -1519,9 +1536,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1529,29 +1546,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -1580,9 +1597,25 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "slab" @@ -1592,15 +1625,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -1626,9 +1659,20 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.117" +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.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" dependencies = [ "proc-macro2", "quote", @@ -1652,7 +1696,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1676,7 +1720,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1686,7 +1730,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1703,61 +1747,40 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.69" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.69" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "syn 3.0.5", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -1767,15 +1790,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -1783,9 +1806,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -1803,24 +1826,15 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.52.1" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -1833,20 +1847,20 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ "rustls", "tokio", @@ -1854,9 +1868,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ "indexmap", "serde_core", @@ -1878,18 +1892,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -1908,20 +1922,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -1955,7 +1969,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2077,20 +2091,11 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -2101,9 +2106,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", "wasm-bindgen", @@ -2111,9 +2116,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2121,31 +2126,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 3.0.5", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -2163,9 +2168,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -2185,31 +2190,13 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -2221,215 +2208,87 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "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-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[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_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[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_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[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_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[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_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[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_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" - -[[package]] -name = "wit-bindgen" -version = "0.57.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2444,35 +2303,35 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -2485,21 +2344,21 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -2508,9 +2367,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -2519,17 +2378,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" From 884f89810e64e8cbfce29cabc4fb2038e07364a1 Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Tue, 15 Sep 2026 18:03:01 +0200 Subject: [PATCH 03/10] chore(nix): crane + single fenix toolchain, split deps, lean ci shell - nixpkgs stays nixos-unstable, add crane, one stable toolchain everywhere - buildDepsOnly -> cargoArtifacts split, cleanCargoSource, strictDeps - devShell default (bacon/cargo-deny/cargo-edit, drop cargo-watch/wizard/nextest/udeps) + lean ci shell - clippy/test/deny CI jobs use .#ci; treefmt adds prettier --- .github/workflows/ci.yml | 6 +- flake.lock | 36 ++++++----- flake.nix | 136 +++++++++++++++++++++++---------------- 3 files changed, 101 insertions(+), 77 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index babd08c..571b72c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@v6 - uses: DeterminateSystems/nix-installer-action@v22 - run: nix flake lock --update-input fenix - - run: nix develop --command cargo clippy --all-targets --locked -- -D warnings + - run: nix develop .#ci --command cargo clippy --all-targets --locked -- -D warnings test: name: test @@ -38,7 +38,7 @@ jobs: - uses: actions/checkout@v6 - uses: DeterminateSystems/nix-installer-action@v22 - run: nix flake lock --update-input fenix - - run: nix develop --command cargo test --all --locked + - run: nix develop .#ci --command cargo test --all --locked deny: name: cargo deny @@ -47,7 +47,7 @@ jobs: - uses: actions/checkout@v6 - uses: DeterminateSystems/nix-installer-action@v22 - run: nix flake lock --update-input fenix - - run: nix develop --command cargo deny --all-features check + - run: nix develop .#ci --command cargo deny --all-features check build-linux: name: build (linux-x86_64) diff --git a/flake.lock b/flake.lock index 16df389..8c3dfeb 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,20 @@ { "nodes": { + "crane": { + "locked": { + "lastModified": 1788465171, + "narHash": "sha256-Y1/TTVXjYXGF068IThQH9fPSZ0SIE74PABlUxnWTUH0=", + "owner": "ipetkov", + "repo": "crane", + "rev": "eb35abda9f232cc6610b1d1e3200d15c49b7ac54", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, "fenix": { "inputs": { "nixpkgs": [ @@ -129,24 +144,9 @@ "type": "github" } }, - "nixpkgs_2": { - "locked": { - "lastModified": 1770107345, - "narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "4533d9293756b63904b7238acb84ac8fe4c8c2c4", - "type": "github" - }, - "original": { - "owner": "nixos", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, "root": { "inputs": { + "crane": "crane", "fenix": "fenix", "flake-parts": "flake-parts", "git-hooks": "git-hooks", @@ -173,7 +173,9 @@ }, "treefmt-nix": { "inputs": { - "nixpkgs": "nixpkgs_2" + "nixpkgs": [ + "nixpkgs" + ] }, "locked": { "lastModified": 1775636079, diff --git a/flake.nix b/flake.nix index 3bd6349..c904578 100644 --- a/flake.nix +++ b/flake.nix @@ -1,16 +1,24 @@ { - description = "Rust development environment"; + description = "graylog-cli - Rust CLI for Graylog"; inputs = { - flake-parts.url = "github:hercules-ci/flake-parts"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - git-hooks.url = "github:cachix/git-hooks.nix"; - git-hooks.inputs.nixpkgs.follows = "nixpkgs"; - treefmt-nix.url = "github:numtide/treefmt-nix"; + flake-parts.url = "github:hercules-ci/flake-parts"; + git-hooks = { + url = "github:cachix/git-hooks.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + treefmt-nix = { + url = "github:numtide/treefmt-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; fenix = { url = "github:nix-community/fenix"; inputs.nixpkgs.follows = "nixpkgs"; }; + crane = { + url = "github:ipetkov/crane"; + }; }; outputs = @@ -37,14 +45,33 @@ let pname = "graylog-cli"; version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).package.version; + + windowsTarget = "x86_64-pc-windows-gnu"; + + # One toolchain for every build and dev shell: the complete stable + # profile (rustc, cargo, clippy, rustfmt, rust-src) plus the Windows + # std so the cross build reuses the exact same compiler. + toolchain = pkgs.fenix.combine [ + pkgs.fenix.stable.completeToolchain + pkgs.fenix.targets.${windowsTarget}.stable.rust-std + ]; + + craneLib = (inputs.crane.mkLib pkgs).overrideToolchain toolchain; + commonArgs = { inherit pname version; - src = self; - cargoLock.lockFile = ./Cargo.lock; + src = craneLib.cleanCargoSource self; + strictDeps = true; }; - nativePackage = pkgs.rustPlatform.buildRustPackage ( + + # Dependencies are built once and reused by the package build, so a + # source-only change never recompiles the dependency tree. + cargoArtifacts = craneLib.buildDepsOnly commonArgs; + + nativePackage = craneLib.buildPackage ( commonArgs // { + inherit cargoArtifacts; # On Darwin, Nix embeds its own store path for libiconv into the # binary. Rewrite it to the system path so the binary runs on # machines without Nix installed. @@ -57,81 +84,76 @@ ''; } ); - windowsTarget = "x86_64-pc-windows-gnu"; + + # The mingw cross build keeps nixpkgs' rustPlatform (crane has no + # equivalent of pkgsCross' cross stdenv wiring here), but it is fed + # the same fenix toolchain as everything else. windowsPkgs = pkgs.pkgsCross.mingwW64; - windowsToolchain = - with inputs.fenix.packages.${system}; - combine [ - stable.cargo - stable.rustc - targets.${windowsTarget}.stable.rust-std - ]; windowsRustPlatform = windowsPkgs.makeRustPlatform { - cargo = windowsToolchain; - rustc = windowsToolchain; + cargo = toolchain; + rustc = toolchain; + }; + windowsPackage = windowsRustPlatform.buildRustPackage { + inherit pname version; + src = self; + cargoLock.lockFile = ./Cargo.lock; + cargoBuildTarget = windowsTarget; + depsBuildBuild = lib.optionals pkgs.stdenv.isDarwin [ + pkgs.libiconv + ]; + NIX_LDFLAGS = lib.optionalString pkgs.stdenv.isDarwin "-L${pkgs.libiconv}/lib"; + stdenv = windowsPkgs.stdenv; }; - windowsPackage = windowsRustPlatform.buildRustPackage ( - commonArgs - // { - cargoBuildTarget = windowsTarget; - depsBuildBuild = lib.optionals pkgs.stdenv.isDarwin [ - pkgs.libiconv - ]; - NIX_LDFLAGS = lib.optionalString pkgs.stdenv.isDarwin "-L${pkgs.libiconv}/lib"; - stdenv = windowsPkgs.stdenv; - } - ); in { packages = { default = nativePackage; graylog-cli-windows = windowsPackage; }; + treefmt = { programs.nixfmt.enable = true; programs.nixfmt.package = pkgs.nixfmt; programs.rustfmt.enable = true; + programs.rustfmt.package = toolchain; + programs.prettier.enable = true; }; + pre-commit.settings.hooks = { treefmt.enable = true; }; - devShells.default = pkgs.mkShell { - inherit (config.pre-commit) shellHook; - packages = - with pkgs; - [ - rustToolchain - cargo-deny - cargo-edit - cargo-watch - cargo-wizard - cargo-nextest - rust-analyzer + + devShells = { + default = pkgs.mkShell { + inherit (config.pre-commit) shellHook; + # rust-analyzer ships inside the complete toolchain above. + packages = [ + toolchain + pkgs.bacon + pkgs.cargo-deny + pkgs.cargo-edit ] ++ config.pre-commit.settings.enabledPackages; + env = { + RUST_SRC_PATH = "${toolchain}/lib/rustlib/src/rust/library"; + }; + }; - env = { - RUST_SRC_PATH = "${pkgs.rustToolchain}/lib/rustlib/src/rust/library"; + # Lean shell for CI jobs that only need cargo + cargo-deny. + ci = pkgs.mkShell { + packages = [ + toolchain + pkgs.cargo-deny + ]; }; }; + _module.args.pkgs = import inputs.nixpkgs { inherit system; overlays = lib.attrValues self.overlays; }; }; - flake.overlays.default = final: prev: { - rustToolchain = - with inputs.fenix.packages.${prev.stdenv.hostPlatform.system}; - combine ( - with stable; - [ - clippy - rustc - cargo - rustfmt - rust-src - ] - ); - }; + + flake.overlays.fenix = inputs.fenix.overlays.default; }; } From 0ce256efd9dc9b2e14e31352bca1a07530a8c662 Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Tue, 15 Sep 2026 18:03:01 +0200 Subject: [PATCH 04/10] chore(nix): use nightly rust toolchain like example --- flake.nix | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/flake.nix b/flake.nix index c904578..bb3c2ba 100644 --- a/flake.nix +++ b/flake.nix @@ -48,12 +48,13 @@ windowsTarget = "x86_64-pc-windows-gnu"; - # One toolchain for every build and dev shell: the complete stable - # profile (rustc, cargo, clippy, rustfmt, rust-src) plus the Windows - # std so the cross build reuses the exact same compiler. - toolchain = pkgs.fenix.combine [ - pkgs.fenix.stable.completeToolchain - pkgs.fenix.targets.${windowsTarget}.stable.rust-std + # One toolchain everywhere, like the example: fenix nightly + # `complete.toolchain` (rustc, cargo, clippy, rustfmt, rust-src) + # plus the Windows std so the cross build reuses the exact same + # compiler. Every build and both dev shells share this. + toolchain = inputs.fenix.packages.${system}.combine [ + inputs.fenix.packages.${system}.complete.toolchain + inputs.fenix.packages.${system}.targets.${windowsTarget}.latest.rust-std ]; craneLib = (inputs.crane.mkLib pkgs).overrideToolchain toolchain; @@ -126,12 +127,12 @@ devShells = { default = pkgs.mkShell { inherit (config.pre-commit) shellHook; - # rust-analyzer ships inside the complete toolchain above. packages = [ toolchain pkgs.bacon pkgs.cargo-deny pkgs.cargo-edit + pkgs.cargo-udeps ] ++ config.pre-commit.settings.enabledPackages; env = { From 5b832677fc336e6136b7a69aec28c24b35830562 Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Tue, 15 Sep 2026 18:03:01 +0200 Subject: [PATCH 05/10] feat: named profiles for multiple Graylog instances - Config is now {profiles, active_profile, updater}; legacy [graylog] migrates in-memory to profiles.default without re-auth - Global --profile (GRAYLOG_PROFILE), profiles list/use/show/delete, ping and auth report profile + available_profiles - ValidationError for profile misuse, token-free summaries, per-profile fields-{profile} cache keys --- README.md | 43 +- src/application/service.rs | 868 +++++++++++++++++++++++++++-- src/domain/config.rs | 179 +++++- src/domain/models.rs | 43 +- src/infrastructure/config_store.rs | 174 +++++- src/main.rs | 19 +- src/presentation/cli.rs | 182 ++++++ tests/cli_integration.rs | 394 +++++++++++++ 8 files changed, 1817 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index 2c3dead..75d327a 100644 --- a/README.md +++ b/README.md @@ -191,16 +191,55 @@ Or opt out for a single invocation with `GRAYLOG_CLI_AUTO_UPDATE=0` (the env var ## Configuration -Credentials are written to a `config.toml` file in the platform config directory on first `auth`. Additional settings can be added manually: +Credentials are written to a `config.toml` file in the platform config directory on first `auth`. The file stores one or more named profiles and marks one of them active: ```toml -[graylog] +active_profile = "prod" + +[profiles.prod] url = "https://graylog.example.com" token = "your-access-token" timeout_seconds = 60 # default: 60 verify_tls = true # default: true fields_cache_ttl_seconds = 300 # default: 300 +[profiles.staging] +url = "https://staging.graylog.example.com" +token = "staging-access-token" + [updater] disable_auto_update = false # default: false ``` + +### Profiles + +A profile is a complete set of Graylog credentials. `graylog-cli auth` writes the profile selected with the global `--profile` flag (default: `default`) and makes it the active profile: + +```sh +graylog-cli auth --url https://graylog.example.com --token TOKEN # writes profile "default" +graylog-cli --profile staging auth --url https://staging.example.com --token TOKEN +``` + +Every command runs against the active profile. Use `--profile` (or the `GRAYLOG_PROFILE` environment variable) to target another profile for a single invocation without switching: + +```sh +graylog-cli --profile staging search 'level:ERROR' +GRAYLOG_PROFILE=staging graylog-cli ping +``` + +Profile management commands: + +```sh +graylog-cli profiles list # list profiles (tokens are never shown) +graylog-cli profiles show [name] # show a profile; defaults to the active one +graylog-cli profiles use staging # switch the active profile +graylog-cli profiles delete staging # remove a profile +``` + +Deleting the active profile clears the active selection; the CLI then falls back to the first remaining profile. Deleting the last profile returns the CLI to its not-configured state. Profile names must start with an ASCII letter or digit and may only contain ASCII letters, digits, `.`, `_`, and `-`. + +The fields cache is scoped per profile (`fields-` cache files), so switching profiles never serves stale field lists from another instance. + +### Legacy configuration + +Configurations written by older versions with a single `[graylog]` table keep working: they are migrated in memory to a `default` profile on load and the file is rewritten in the new format only on the next save (for example the next `auth`). No re-authentication is required. diff --git a/src/application/service.rs b/src/application/service.rs index 3a9e75f..d82455d 100644 --- a/src/application/service.rs +++ b/src/application/service.rs @@ -9,12 +9,13 @@ use serde_json::json; use url::Url; use crate::application::ports::{CacheStore, ConfigStore, GraylogGateway, GraylogGatewayFactory}; -use crate::domain::config::{Config, GraylogConfig}; +use crate::domain::config::{Config, DEFAULT_PROFILE_NAME, GraylogConfig, validate_profile_name}; use crate::domain::error::{CliError, HttpError, ValidationError}; use crate::domain::models::{ AggregateCommandInput, AggregateSearchRequest, AggregateStatus, AuthStatus, FieldsStatus, - MessageSearchRequest, MessageSearchStatus, NormalizedRow, PingStatus, SearchCommandInput, - SearchGroup, SortDirection, StreamFindStatus, StreamStatus, StreamsStatus, SystemInfoStatus, + MessageSearchRequest, MessageSearchStatus, NormalizedRow, PingStatus, ProfileDeleteStatus, + ProfileStatus, ProfileSummary, ProfilesStatus, SearchCommandInput, SearchGroup, SortDirection, + StreamFindStatus, StreamStatus, StreamsStatus, SystemInfoStatus, }; const DEFAULT_SEARCH_LIMIT: u64 = 50; @@ -34,6 +35,7 @@ pub struct ApplicationService { config_store: Arc, gateway_factory: Arc, fields_cache_store: Arc, + profile_override: Option, } impl ApplicationService { @@ -46,9 +48,17 @@ impl ApplicationService { config_store, gateway_factory, fields_cache_store, + profile_override: None, } } + /// Pins every command of this service instance to `profile`, overriding + /// the persisted `active_profile` selection. + pub fn with_profile_override(mut self, profile: Option) -> Self { + self.profile_override = profile; + self + } + pub async fn authenticate( &self, base_url: Url, @@ -62,25 +72,152 @@ impl ApplicationService { .into()); } - let existing_updater = self + let profile_name = self + .profile_override + .clone() + .unwrap_or_else(|| DEFAULT_PROFILE_NAME.to_string()); + if let Err(message) = validate_profile_name(&profile_name) { + return Err(CliError::Validation(ValidationError::InvalidValue { + field: "profile", + message, + }) + .into()); + } + + let existing = self .config_store .load() .await - .or_raise(|| CliError::Config("failed to load existing config".to_string()))? - .map(|config| config.updater) + .or_raise(|| CliError::Config("failed to load existing config".to_string()))?; + let (mut profiles, updater) = existing + .map(|config| (config.profiles, config.updater)) .unwrap_or_default(); - let graylog_config = GraylogConfig::new(base_url.clone(), token); + profiles.insert( + profile_name.clone(), + GraylogConfig::new( + base_url.clone(), + secrecy::SecretString::new(trimmed_token.into()), + ), + ); let config = Config { - graylog: graylog_config, - updater: existing_updater, + profiles, + active_profile: Some(profile_name.clone()), + updater, + }; + + self.config_store + .save(config) + .await + .or_raise(|| CliError::Config("failed to persist config".to_string()))?; + + Ok(AuthStatus::ok(base_url.to_string(), profile_name)) + } + + /// Lists all configured profiles without exposing tokens. + pub async fn profiles_list(&self) -> exn::Result { + let config = self + .config_store + .load() + .await + .or_raise(|| CliError::Config("failed to load runtime config".to_string()))? + .unwrap_or_default(); + let active = self.effective_active_profile(&config); + + Ok(ProfilesStatus { + ok: true, + command: "profiles.list", + profiles: config + .profiles + .iter() + .map(|(name, graylog)| profile_summary(name, graylog, active.as_deref())) + .collect(), + active_profile: active, + total: config.profiles.len(), + }) + } + + /// Shows a single profile; `name` defaults to the resolved active profile. + pub async fn profiles_show(&self, name: Option<&str>) -> exn::Result { + let config = self.load_config().await?; + let (name, graylog) = match name { + Some(name) => (name.to_string(), self.require_profile(&config, name)?), + None => self.select_profile(&config)?, }; + let active = self + .effective_active_profile(&config) + .unwrap_or_else(|| name.clone()); + + Ok(ProfileStatus { + ok: true, + command: "profiles.show", + profile: profile_summary(&name, &graylog, Some(active.as_str())), + }) + } + + /// Switches the persisted active profile. + pub async fn profiles_use(&self, name: &str) -> exn::Result { + if let Err(message) = validate_profile_name(name) { + return Err(CliError::Validation(ValidationError::InvalidValue { + field: "profile", + message, + }) + .into()); + } + + let mut config = self.load_config().await?; + let graylog = self.require_profile(&config, name)?; + config.active_profile = Some(name.to_string()); self.config_store .save(config) .await .or_raise(|| CliError::Config("failed to persist config".to_string()))?; - Ok(AuthStatus::ok(base_url.to_string())) + Ok(ProfileStatus { + ok: true, + command: "profiles.use", + profile: profile_summary(name, &graylog, Some(name)), + }) + } + + /// Deletes a profile. Deleting the active profile clears `active_profile`; + /// deleting the last profile leaves an empty map (the not-configured path). + pub async fn profiles_delete(&self, name: &str) -> exn::Result { + if let Err(message) = validate_profile_name(name) { + return Err(CliError::Validation(ValidationError::InvalidValue { + field: "profile", + message, + }) + .into()); + } + + let mut config = self.load_config().await?; + if config.profiles.remove(name).is_none() { + return Err(CliError::Validation(ValidationError::InvalidValue { + field: "profile", + message: unknown_profile_message(name, &config.profiles), + }) + .into()); + } + if config.active_profile.as_deref() == Some(name) { + config.active_profile = None; + } + + let remaining_profiles = config.profiles.len(); + let active_profile = config.active_profile.clone(); + + self.config_store + .save(config) + .await + .or_raise(|| CliError::Config("failed to persist config".to_string()))?; + + Ok(ProfileDeleteStatus { + ok: true, + command: "profiles.delete", + profile: name.to_string(), + active_profile, + remaining_profiles, + }) } pub async fn search( @@ -90,9 +227,9 @@ impl ApplicationService { let mut input = input; if input.all_fields && input.fields.is_empty() { - let config = self.require_config().await?; - let ttl = config.graylog.fields_cache_ttl_seconds; - let cache_key = "fields".to_string(); + let (profile_name, graylog_config) = self.resolve_profile().await?; + let ttl = graylog_config.fields_cache_ttl_seconds; + let cache_key = fields_cache_key(&profile_name); let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() @@ -108,7 +245,7 @@ impl ApplicationService { { Some(cached) if now.saturating_sub(cached.fetched_at) < ttl => cached.fields, _ => { - let client = self.graylog_gateway_with_config(config.graylog)?; + let client = self.graylog_gateway_with_config(graylog_config)?; let result = client.list_fields().await.or_raise(|| { CliError::Http(HttpError::Unavailable { message: "failed to list fields".to_string(), @@ -285,9 +422,9 @@ impl ApplicationService { } pub async fn fields(&self, refresh: bool) -> exn::Result { - let config = self.require_config().await?; - let cache_key = "fields".to_string(); - let ttl = config.graylog.fields_cache_ttl_seconds; + let (profile_name, graylog_config) = self.resolve_profile().await?; + let cache_key = fields_cache_key(&profile_name); + let ttl = graylog_config.fields_cache_ttl_seconds; let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() @@ -317,7 +454,7 @@ impl ApplicationService { let fields = match fetched_fields { Some(fields) => fields, None => { - let client = self.graylog_gateway_with_config(config.graylog)?; + let client = self.graylog_gateway_with_config(graylog_config)?; let result = client.list_fields().await.or_raise(|| { CliError::Http(HttpError::Unavailable { message: "failed to list fields".to_string(), @@ -346,7 +483,10 @@ impl ApplicationService { } pub async fn ping(&self) -> exn::Result { - let client = self.graylog_gateway().await?; + let config = self.load_config().await?; + let available_profiles = config.profiles.keys().cloned().collect::>(); + let (profile_name, graylog_config) = self.select_profile(&config)?; + let client = self.graylog_gateway_with_config(graylog_config)?; let graylog_url = client.base_url().to_string(); client.ping().await.or_raise(|| { @@ -360,6 +500,8 @@ impl ApplicationService { command: "ping", reachable: true, graylog_url, + profile: profile_name, + available_profiles, }) } @@ -419,8 +561,8 @@ impl ApplicationService { command: &'static str, request: MessageSearchRequest, ) -> exn::Result { - let config = self.require_config().await?; - let client = self.graylog_gateway_with_config(config.graylog)?; + let (_, graylog_config) = self.resolve_profile().await?; + let client = self.graylog_gateway_with_config(graylog_config)?; let result = client.search_messages(request.clone()).await.or_raise(|| { CliError::Http(HttpError::Unavailable { message: "message search failed".to_string(), @@ -448,8 +590,8 @@ impl ApplicationService { &self, input: &SearchCommandInput, ) -> exn::Result { - let config = self.require_config().await?; - let client = self.graylog_gateway_with_config(config.graylog)?; + let (_, graylog_config) = self.resolve_profile().await?; + let client = self.graylog_gateway_with_config(graylog_config)?; let mut request = self.build_search_request(input.clone(), DEFAULT_SEARCH_LIMIT); let mut all_messages = Vec::new(); let mut metadata = serde_json::Map::new(); @@ -567,8 +709,8 @@ impl ApplicationService { command: &'static str, request: AggregateSearchRequest, ) -> exn::Result { - let config = self.require_config().await?; - let client = self.graylog_gateway_with_config(config.graylog)?; + let (_, graylog_config) = self.resolve_profile().await?; + let client = self.graylog_gateway_with_config(graylog_config)?; let aggregation_type = request.aggregation_type.as_cli_value(); let result = client.search_aggregate(request).await.or_raise(|| { CliError::Http(HttpError::Unavailable { @@ -585,22 +727,66 @@ impl ApplicationService { }) } - async fn require_config(&self) -> exn::Result { + /// Loads the persisted config, mapping an absent or profile-less config to + /// a `profile` validation error so the message stays visible in output. + async fn load_config(&self) -> exn::Result { self.config_store .load() .await .or_raise(|| CliError::Config("failed to load runtime config".to_string()))? + .filter(|config| !config.profiles.is_empty()) .ok_or_else(|| { - CliError::Config( - "graylog is not configured, run `graylog-cli auth` first".to_string(), - ) + CliError::Validation(ValidationError::InvalidValue { + field: "profile", + message: "graylog is not configured, run `graylog-cli auth` first".to_string(), + }) }) .map_err(Into::into) } + /// Resolves the profile to use for this invocation: the `--profile` + /// override first, then the persisted `active_profile`, then the first + /// stored profile. + async fn resolve_profile(&self) -> exn::Result<(String, GraylogConfig), CliError> { + let config = self.load_config().await?; + self.select_profile(&config).map_err(Into::into) + } + + fn select_profile(&self, config: &Config) -> Result<(String, GraylogConfig), CliError> { + let name = self + .effective_active_profile(config) + .ok_or_else(|| { + CliError::Validation(ValidationError::InvalidValue { + field: "profile", + message: "graylog is not configured, run `graylog-cli auth` first".to_string(), + }) + })? + .to_string(); + let graylog = self.require_profile(config, &name)?; + Ok((name, graylog)) + } + + /// Profile the invocation would use right now, without loading config: + /// override, persisted active, or first stored profile (BTreeMap order). + fn effective_active_profile(&self, config: &Config) -> Option { + self.profile_override + .clone() + .or_else(|| config.active_profile.clone()) + .or_else(|| config.profiles.keys().next().cloned()) + } + + fn require_profile(&self, config: &Config, name: &str) -> Result { + config.profiles.get(name).cloned().ok_or_else(|| { + CliError::Validation(ValidationError::InvalidValue { + field: "profile", + message: unknown_profile_message(name, &config.profiles), + }) + }) + } + async fn graylog_gateway(&self) -> exn::Result, CliError> { - let config = self.require_config().await?; - self.graylog_gateway_with_config(config.graylog) + let (_, graylog_config) = self.resolve_profile().await?; + self.graylog_gateway_with_config(graylog_config) } fn graylog_gateway_with_config( @@ -615,6 +801,32 @@ impl ApplicationService { } } +fn profile_summary(name: &str, graylog: &GraylogConfig, active: Option<&str>) -> ProfileSummary { + ProfileSummary { + name: name.to_string(), + url: graylog.url.to_string(), + timeout_seconds: graylog.timeout_seconds, + verify_tls: graylog.verify_tls, + fields_cache_ttl_seconds: graylog.fields_cache_ttl_seconds, + active: active == Some(name), + } +} + +fn unknown_profile_message(name: &str, profiles: &BTreeMap) -> String { + if profiles.is_empty() { + "graylog is not configured, run `graylog-cli auth` first".to_string() + } else { + let available = profiles.keys().cloned().collect::>().join(", "); + format!("unknown profile `{name}`, available profiles: {available}") + } +} + +/// Per-profile fields cache key. A hyphen (not a colon) keeps the resulting +/// cache file name valid on Windows. +fn fields_cache_key(profile: &str) -> String { + format!("fields-{profile}") +} + fn apply_grouping(mut status: MessageSearchStatus, group_by: &str) -> MessageSearchStatus { status.grouped_by = Some(group_by.to_string()); status.groups = Some(build_search_groups(&status.messages, group_by)); @@ -673,7 +885,8 @@ mod tests { use crate::application::ports::config_store::ConfigError; use crate::application::test_support::fakes::FakeCacheStore; use crate::domain::config::{ - DEFAULT_FIELDS_CACHE_TTL_SECONDS, DEFAULT_TIMEOUT_SECONDS, UpdaterConfig, + DEFAULT_FIELDS_CACHE_TTL_SECONDS, DEFAULT_PROFILE_NAME, DEFAULT_TIMEOUT_SECONDS, + UpdaterConfig, }; use crate::domain::models::{ AggregateSearchResult, AggregationType, FieldsResult, JsonObject, MessageSearchResult, @@ -924,9 +1137,11 @@ mod tests { } } + #[derive(Clone)] struct FakeGraylogGatewayFactory { gateway: Arc, failure: Option, + built_configs: Arc>>, } impl FakeGraylogGatewayFactory { @@ -934,35 +1149,77 @@ mod tests { Self { gateway, failure: None, + built_configs: Arc::new(Mutex::new(Vec::new())), } } + + /// (base_url, token) of each config the factory was asked to build. + fn built_configs(&self) -> Vec<(String, String)> { + self.built_configs + .lock() + .expect("built config mutex should not be poisoned") + .iter() + .map(|config| { + ( + config.url.to_string(), + config.token.expose_secret().to_string(), + ) + }) + .collect() + } } impl GraylogGatewayFactory for FakeGraylogGatewayFactory { fn build_from_config( &self, - _config: GraylogConfig, + config: GraylogConfig, ) -> Result, HttpError> { if let Some(message) = &self.failure { Err(HttpError::RequestBuild { message: message.clone(), }) } else { + self.built_configs + .lock() + .expect("built config mutex should not be poisoned") + .push(config); Ok(Arc::clone(&self.gateway)) } } } fn test_config() -> Config { - Config { - graylog: GraylogConfig::new( - Url::parse("http://localhost:9000").expect("test URL should parse"), - secrecy::SecretString::new("test-token".to_owned().into()), + single_profile_config(DEFAULT_PROFILE_NAME, "http://localhost:9000", "test-token") + } + + fn single_profile_config(name: &str, url: &str, token: &str) -> Config { + let mut profiles = BTreeMap::new(); + profiles.insert( + name.to_string(), + GraylogConfig::new( + Url::parse(url).expect("test URL should parse"), + secrecy::SecretString::new(token.to_owned().into()), ), + ); + Config { + profiles, + active_profile: Some(name.to_string()), updater: UpdaterConfig::default(), } } + fn multi_profile_config() -> Config { + let mut config = single_profile_config("alpha", "http://alpha:9000", "alpha-token"); + config.profiles.insert( + "beta".to_string(), + GraylogConfig::new( + Url::parse("http://beta:9000").expect("test URL should parse"), + secrecy::SecretString::new("beta-token".to_owned().into()), + ), + ); + config + } + fn test_service( config_store: Arc, cache_store: Arc, @@ -976,12 +1233,30 @@ mod tests { cache_store: FakeCacheStore, gateway: FakeGraylogGateway, ) -> (ApplicationService, FakeGraylogGateway, FakeCacheStore) { + let (service, gateway, cache_store, _) = + service_with_gateway_and_profile(config_store, cache_store, gateway, None); + (service, gateway, cache_store) + } + + fn service_with_gateway_and_profile( + config_store: FakeConfigStore, + cache_store: FakeCacheStore, + gateway: FakeGraylogGateway, + profile_override: Option<&str>, + ) -> ( + ApplicationService, + FakeGraylogGateway, + FakeCacheStore, + FakeGraylogGatewayFactory, + ) { + let factory = FakeGraylogGatewayFactory::new(Arc::new(gateway.clone())); let service = test_service( Arc::new(config_store), Arc::new(cache_store.clone()), - Arc::new(FakeGraylogGatewayFactory::new(Arc::new(gateway.clone()))), - ); - (service, gateway, cache_store) + Arc::new(factory.clone()), + ) + .with_profile_override(profile_override.map(str::to_string)); + (service, gateway, cache_store, factory) } fn make_search_messages_result( @@ -1044,10 +1319,14 @@ mod tests { ); } - fn assert_config_error_contains(error: exn::Exn, expected: &str) { + fn assert_profile_validation_error(error: exn::Exn, expected: &str) { assert!( - matches!(&*error, CliError::Config(message) if message.contains(expected)), - "expected config error containing {expected}, got {error:?}" + matches!( + &*error, + CliError::Validation(ValidationError::InvalidValue { field: "profile", message }) + if message.contains(expected) + ), + "expected InvalidValue profile error containing {expected}, got {error:?}" ); } @@ -1104,14 +1383,16 @@ mod tests { let saved = config_store .saved_config() .expect("config should be saved after authentication"); - assert_eq!(saved.graylog.url, url); - assert_eq!(saved.graylog.token.expose_secret(), "test-token"); - assert_eq!(saved.graylog.timeout_seconds, DEFAULT_TIMEOUT_SECONDS); - assert!(saved.graylog.verify_tls); + let profile = &saved.profiles[DEFAULT_PROFILE_NAME]; + assert_eq!(profile.url, url); + assert_eq!(profile.token.expose_secret(), "test-token"); + assert_eq!(profile.timeout_seconds, DEFAULT_TIMEOUT_SECONDS); + assert!(profile.verify_tls); assert_eq!( - saved.graylog.fields_cache_ttl_seconds, + profile.fields_cache_ttl_seconds, DEFAULT_FIELDS_CACHE_TTL_SECONDS ); + assert_eq!(saved.active_profile.as_deref(), Some(DEFAULT_PROFILE_NAME)); } #[tokio::test] @@ -1135,7 +1416,59 @@ mod tests { .saved_config() .expect("config should be saved after authentication"); assert!(saved.updater.disable_auto_update); - assert_eq!(saved.graylog.token.expose_secret(), "new-token"); + assert_eq!( + saved.profiles[DEFAULT_PROFILE_NAME].token.expose_secret(), + "new-token" + ); + } + + #[tokio::test] + async fn authenticate_writes_named_profile_and_preserves_other_profiles() { + let config_store = FakeConfigStore::new(multi_profile_config()); + let (service, _, _, _) = service_with_gateway_and_profile( + config_store.clone(), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + Some("staging"), + ); + let status = service + .authenticate( + Url::parse("http://staging:9000").expect("test URL should parse"), + secrecy::SecretString::new("staging-token".to_owned().into()), + ) + .await + .expect("authentication should succeed"); + assert_eq!(status.profile, "staging"); + assert_eq!(status.graylog_url, "http://staging:9000/"); + let saved = config_store + .saved_config() + .expect("config should be saved after authentication"); + assert_eq!(saved.profiles.len(), 3); + assert_eq!( + saved.profiles["staging"].token.expose_secret(), + "staging-token" + ); + assert!(saved.profiles.contains_key("alpha")); + assert!(saved.profiles.contains_key("beta")); + assert_eq!(saved.active_profile.as_deref(), Some("staging")); + } + + #[tokio::test] + async fn authenticate_rejects_invalid_profile_name() { + let (service, _, _, _) = service_with_gateway_and_profile( + FakeConfigStore::empty(), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + Some("-leading-dash"), + ); + let error = service + .authenticate( + Url::parse("http://localhost:9000").expect("test URL should parse"), + secrecy::SecretString::new("token".to_owned().into()), + ) + .await + .expect_err("invalid profile name should be rejected"); + assert_profile_validation_error(error, "profile names must"); } #[tokio::test] @@ -1155,6 +1488,7 @@ mod tests { .expect("authentication should succeed"); assert!(status.ok); assert_eq!(status.graylog_url, url.to_string()); + assert_eq!(status.profile, DEFAULT_PROFILE_NAME); } #[tokio::test] @@ -1168,7 +1502,60 @@ mod tests { .search(make_search_input()) .await .expect_err("search should require config"); - assert_config_error_contains(error, "graylog is not configured"); + assert_profile_validation_error(error, "graylog is not configured"); + } + + #[tokio::test] + async fn search_rejects_unknown_profile_override() { + let (service, _, _, _) = service_with_gateway_and_profile( + FakeConfigStore::new(multi_profile_config()), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + Some("gamma"), + ); + let error = service + .search(make_search_input()) + .await + .expect_err("unknown profile override should fail"); + assert_profile_validation_error(error, "unknown profile `gamma`"); + } + + #[tokio::test] + async fn profile_override_takes_precedence_over_active_profile() { + let (service, _, _, factory) = service_with_gateway_and_profile( + FakeConfigStore::new(multi_profile_config()), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + Some("beta"), + ); + service + .search(make_search_input()) + .await + .expect("search should succeed with override"); + let built = factory.built_configs(); + assert_eq!( + built, + vec![("http://beta:9000/".to_string(), "beta-token".to_string())] + ); + } + + #[tokio::test] + async fn without_override_the_active_profile_is_used() { + let (service, _, _, factory) = service_with_gateway_and_profile( + FakeConfigStore::new(multi_profile_config()), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + None, + ); + service + .search(make_search_input()) + .await + .expect("search should succeed with active profile"); + let built = factory.built_configs(); + assert_eq!( + built, + vec![("http://alpha:9000/".to_string(), "alpha-token".to_string())] + ); } #[tokio::test] @@ -1523,7 +1910,7 @@ mod tests { .expect("system time should be after epoch") .as_secs(); cache_store.insert( - "fields", + "fields-default", serde_json::to_string(&CachedFields { fields: vec!["message".to_string(), "source".to_string()], fetched_at: now, @@ -1557,7 +1944,7 @@ mod tests { input.all_fields = true; service.search(input).await.expect("search should succeed"); assert_eq!(gateway.list_fields_call_count(), 1); - assert!(cache_store.get("fields").is_some()); + assert!(cache_store.get("fields-default").is_some()); assert_eq!( gateway .search_requests() @@ -1578,7 +1965,7 @@ mod tests { .expect("system time should be after epoch") .as_secs(); cache_store.insert( - "fields", + "fields-default", serde_json::to_string(&CachedFields { fields: vec!["stale".to_string()], fetched_at: now - DEFAULT_FIELDS_CACHE_TTL_SECONDS - 1, @@ -1682,7 +2069,7 @@ mod tests { .aggregate(make_aggregate_input()) .await .expect_err("aggregate should require config"); - assert_config_error_contains(error, "graylog is not configured"); + assert_profile_validation_error(error, "graylog is not configured"); } #[tokio::test] @@ -1954,6 +2341,26 @@ mod tests { assert_eq!(status.total, 3); } + #[tokio::test] + async fn fields_cache_is_scoped_per_profile() { + let gateway = FakeGraylogGateway::new(); + gateway.set_fields(vec!["beta-field".to_string()]); + let cache_store = FakeCacheStore::default(); + let (service, _, _, _) = service_with_gateway_and_profile( + FakeConfigStore::new(multi_profile_config()), + cache_store.clone(), + gateway, + Some("beta"), + ); + service + .fields(false) + .await + .expect("fields should succeed for beta"); + assert!(cache_store.get("fields-beta").is_some()); + assert!(cache_store.get("fields-alpha").is_none()); + assert!(cache_store.get("fields").is_none()); + } + #[tokio::test] async fn ping_returns_reachable_status() { let (service, _, _) = service_with_gateway( @@ -1965,5 +2372,358 @@ mod tests { assert!(status.ok); assert!(status.reachable); assert_eq!(status.graylog_url, "http://localhost:9000"); + assert_eq!(status.profile, DEFAULT_PROFILE_NAME); + assert_eq!(status.available_profiles, vec![DEFAULT_PROFILE_NAME]); + } + + #[tokio::test] + async fn ping_reports_override_and_available_profiles() { + let (service, _, _, _) = service_with_gateway_and_profile( + FakeConfigStore::new(multi_profile_config()), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + Some("beta"), + ); + let status = service.ping().await.expect("ping should succeed"); + assert_eq!(status.profile, "beta"); + assert_eq!(status.available_profiles, vec!["alpha", "beta"]); + } + + // --- Profile management tests --- + + #[tokio::test] + async fn profiles_list_returns_summaries_without_tokens() { + let config_store = FakeConfigStore::new(multi_profile_config()); + let (service, _, _) = service_with_gateway( + config_store, + FakeCacheStore::default(), + FakeGraylogGateway::new(), + ); + let status = service + .profiles_list() + .await + .expect("profiles list should succeed"); + assert!(status.ok); + assert_eq!(status.command, "profiles.list"); + assert_eq!(status.total, 2); + assert_eq!(status.active_profile.as_deref(), Some("alpha")); + assert_eq!(status.profiles.len(), 2); + assert_eq!(status.profiles[0].name, "alpha"); + assert!(status.profiles[0].active); + assert_eq!(status.profiles[0].url, "http://alpha:9000/"); + assert_eq!(status.profiles[1].name, "beta"); + assert!(!status.profiles[1].active); + + let serialized = serde_json::to_string(&status).expect("status should serialize"); + assert!(!serialized.contains("alpha-token")); + assert!(!serialized.contains("beta-token")); + } + + #[test] + fn profile_summary_serialization_contains_no_token_field() { + let summary = ProfileSummary { + name: "prod".to_string(), + url: "https://graylog.example.com/".to_string(), + timeout_seconds: 60, + verify_tls: true, + fields_cache_ttl_seconds: 300, + active: true, + }; + + let serialized = serde_json::to_string(&summary).expect("summary should serialize"); + + assert!(!serialized.contains("token")); + let mut keys = serde_json::from_str::(&serialized) + .expect("summary should parse") + .as_object() + .expect("summary should be an object") + .keys() + .cloned() + .collect::>(); + keys.sort_unstable(); + assert_eq!( + keys, + vec![ + "active", + "fields_cache_ttl_seconds", + "name", + "timeout_seconds", + "url", + "verify_tls" + ] + ); + } + + #[tokio::test] + async fn profiles_list_with_empty_store_returns_empty_status() { + let (service, _, _) = service_with_gateway( + FakeConfigStore::empty(), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + ); + let status = service + .profiles_list() + .await + .expect("profiles list should succeed without config"); + assert_eq!(status.total, 0); + assert!(status.profiles.is_empty()); + assert_eq!(status.active_profile, None); + } + + #[tokio::test] + async fn profiles_show_defaults_to_resolved_active_profile() { + let (service, _, _) = service_with_gateway( + FakeConfigStore::new(multi_profile_config()), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + ); + let status = service + .profiles_show(None) + .await + .expect("profiles show should succeed"); + assert_eq!(status.command, "profiles.show"); + assert_eq!(status.profile.name, "alpha"); + assert!(status.profile.active); + let serialized = serde_json::to_string(&status).expect("status should serialize"); + assert!(!serialized.contains("alpha-token")); + } + + #[tokio::test] + async fn profiles_show_with_override_reports_override_as_active() { + let (service, _, _, _) = service_with_gateway_and_profile( + FakeConfigStore::new(multi_profile_config()), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + Some("beta"), + ); + let status = service + .profiles_show(None) + .await + .expect("profiles show should succeed"); + assert_eq!(status.profile.name, "beta"); + assert!(status.profile.active); + } + + #[tokio::test] + async fn profiles_show_named_profile() { + let (service, _, _) = service_with_gateway( + FakeConfigStore::new(multi_profile_config()), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + ); + let status = service + .profiles_show(Some("beta")) + .await + .expect("profiles show should succeed"); + assert_eq!(status.profile.name, "beta"); + assert!(!status.profile.active); + } + + #[tokio::test] + async fn profiles_show_rejects_unknown_profile() { + let (service, _, _) = service_with_gateway( + FakeConfigStore::new(multi_profile_config()), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + ); + let error = service + .profiles_show(Some("gamma")) + .await + .expect_err("unknown profile should fail"); + assert_profile_validation_error(error, "unknown profile `gamma`"); + } + + #[tokio::test] + async fn profiles_use_switches_active_profile() { + let config_store = FakeConfigStore::new(multi_profile_config()); + let (service, _, _) = service_with_gateway( + config_store.clone(), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + ); + let status = service + .profiles_use("beta") + .await + .expect("profiles use should succeed"); + assert_eq!(status.command, "profiles.use"); + assert_eq!(status.profile.name, "beta"); + assert!(status.profile.active); + let saved = config_store.saved_config().expect("config should be saved"); + assert_eq!(saved.active_profile.as_deref(), Some("beta")); + assert_eq!(saved.profiles.len(), 2); + } + + #[tokio::test] + async fn profiles_use_rejects_unknown_profile() { + let (service, _, _) = service_with_gateway( + FakeConfigStore::new(multi_profile_config()), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + ); + let error = service + .profiles_use("gamma") + .await + .expect_err("unknown profile should fail"); + let message = match (&*error, "capture") { + ( + CliError::Validation(ValidationError::InvalidValue { + field: "profile", + message, + }), + _, + ) => message.clone(), + _ => panic!("expected InvalidValue profile error, got {error:?}"), + }; + assert!(message.contains("unknown profile `gamma`"), "got {message}"); + assert!( + message.contains("available profiles: alpha, beta"), + "got {message}" + ); + } + + #[tokio::test] + async fn profiles_use_rejects_invalid_profile_name() { + let (service, _, _) = service_with_gateway( + FakeConfigStore::new(multi_profile_config()), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + ); + let error = service + .profiles_use("no spaces") + .await + .expect_err("invalid profile name should fail"); + assert_profile_validation_error(error, "profile names must"); + } + + #[tokio::test] + async fn profiles_delete_rejects_invalid_profile_name() { + let (service, _, _) = service_with_gateway( + FakeConfigStore::new(multi_profile_config()), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + ); + let error = service + .profiles_delete("no spaces") + .await + .expect_err("invalid profile name should fail"); + assert_profile_validation_error(error, "profile names must"); + } + + #[tokio::test] + async fn profiles_delete_inactive_profile_keeps_active() { + let config_store = FakeConfigStore::new(multi_profile_config()); + let (service, _, _) = service_with_gateway( + config_store.clone(), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + ); + let status = service + .profiles_delete("beta") + .await + .expect("profiles delete should succeed"); + assert_eq!(status.command, "profiles.delete"); + assert_eq!(status.profile, "beta"); + assert_eq!(status.remaining_profiles, 1); + assert_eq!(status.active_profile.as_deref(), Some("alpha")); + let saved = config_store.saved_config().expect("config should be saved"); + assert!(!saved.profiles.contains_key("beta")); + assert_eq!(saved.active_profile.as_deref(), Some("alpha")); + } + + #[tokio::test] + async fn profiles_delete_active_profile_clears_active_profile() { + let config_store = FakeConfigStore::new(multi_profile_config()); + let (service, _, _) = service_with_gateway( + config_store.clone(), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + ); + let status = service + .profiles_delete("alpha") + .await + .expect("profiles delete should succeed"); + assert_eq!(status.active_profile, None); + assert_eq!(status.remaining_profiles, 1); + let saved = config_store.saved_config().expect("config should be saved"); + assert_eq!(saved.active_profile, None); + assert!(saved.profiles.contains_key("beta")); + } + + #[tokio::test] + async fn deleting_active_profile_falls_back_to_remaining_profile() { + let config_store = FakeConfigStore::new(multi_profile_config()); + let (service, _, _, factory) = service_with_gateway_and_profile( + config_store, + FakeCacheStore::default(), + FakeGraylogGateway::new(), + None, + ); + service + .profiles_delete("alpha") + .await + .expect("profiles delete should succeed"); + service + .search(make_search_input()) + .await + .expect("search should fall back to the remaining profile"); + let built = factory.built_configs(); + assert_eq!( + built, + vec![("http://beta:9000/".to_string(), "beta-token".to_string())] + ); + } + + #[tokio::test] + async fn profiles_delete_last_profile_leaves_empty_not_configured() { + let config_store = FakeConfigStore::new(test_config()); + let (service, _, _) = service_with_gateway( + config_store.clone(), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + ); + let status = service + .profiles_delete(DEFAULT_PROFILE_NAME) + .await + .expect("profiles delete should succeed"); + assert_eq!(status.remaining_profiles, 0); + assert_eq!(status.active_profile, None); + let saved = config_store.saved_config().expect("config should be saved"); + assert!(saved.profiles.is_empty()); + + let error = service + .search(make_search_input()) + .await + .expect_err("commands after deleting the last profile are not configured"); + assert_profile_validation_error(error, "graylog is not configured"); + } + + #[tokio::test] + async fn profiles_delete_rejects_unknown_profile() { + let (service, _, _) = service_with_gateway( + FakeConfigStore::new(multi_profile_config()), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + ); + let error = service + .profiles_delete("gamma") + .await + .expect_err("unknown profile should fail"); + assert_profile_validation_error(error, "unknown profile `gamma`"); + } + + #[tokio::test] + async fn commands_fail_when_active_profile_is_dangling() { + let mut config = multi_profile_config(); + config.active_profile = Some("gamma".to_string()); + let (service, _, _) = service_with_gateway( + FakeConfigStore::new(config), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + ); + let error = service + .search(make_search_input()) + .await + .expect_err("dangling active profile should fail"); + assert_profile_validation_error(error, "unknown profile `gamma`"); } } diff --git a/src/domain/config.rs b/src/domain/config.rs index 7cc9778..dcf73a1 100644 --- a/src/domain/config.rs +++ b/src/domain/config.rs @@ -1,9 +1,13 @@ +use std::collections::BTreeMap; + use secrecy::{ExposeSecret, SecretString}; use serde::{Deserialize, Serialize}; use url::Url; pub const DEFAULT_TIMEOUT_SECONDS: u64 = 60; pub const DEFAULT_FIELDS_CACHE_TTL_SECONDS: u64 = 300; +/// Profile name that legacy `[graylog]` configurations migrate into. +pub const DEFAULT_PROFILE_NAME: &str = "default"; fn default_timeout_seconds() -> u64 { DEFAULT_TIMEOUT_SECONDS @@ -15,13 +19,45 @@ fn default_verify_tls() -> bool { true } -#[derive(Debug, Clone, Serialize, Deserialize)] +/// Root configuration file format: named Graylog profiles plus updater settings. +/// +/// Legacy files with a single `[graylog]` table are migrated in memory by the +/// infrastructure config store; the domain type only models the new shape. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Config { - pub graylog: GraylogConfig, + #[serde(default)] + pub profiles: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_profile: Option, #[serde(default)] pub updater: UpdaterConfig, } +/// Returns true when `name` matches `^[A-Za-z0-9][A-Za-z0-9._-]*$`. +/// +/// The rule keeps profile names usable as file-name fragments (the fields +/// cache key embeds the profile name) and as TOML table keys. +pub fn is_valid_profile_name(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(first) if first.is_ascii_alphanumeric() => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) +} + +/// Validates a profile name, returning a message describing the rule on failure. +pub fn validate_profile_name(name: &str) -> Result<(), String> { + if is_valid_profile_name(name) { + Ok(()) + } else { + Err(format!( + "profile names must start with an ASCII letter or digit and may only contain \ + ASCII letters, digits, `.`, `_`, and `-` (got `{name}`)" + )) + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct UpdaterConfig { #[serde(default)] @@ -85,23 +121,32 @@ where #[cfg(test)] mod tests { + use std::collections::BTreeMap; + use secrecy::{ExposeSecret, SecretString}; use url::Url; use super::{ - Config, DEFAULT_FIELDS_CACHE_TTL_SECONDS, DEFAULT_TIMEOUT_SECONDS, GraylogConfig, - UpdaterConfig, + Config, DEFAULT_FIELDS_CACHE_TTL_SECONDS, DEFAULT_PROFILE_NAME, DEFAULT_TIMEOUT_SECONDS, + GraylogConfig, UpdaterConfig, is_valid_profile_name, }; + fn test_profile() -> GraylogConfig { + GraylogConfig { + url: Url::parse("https://graylog.example.com").expect("test URL should parse"), + token: SecretString::new("test-token".to_owned().into()), + timeout_seconds: 42, + verify_tls: false, + fields_cache_ttl_seconds: 123, + } + } + fn test_config() -> Config { + let mut profiles = BTreeMap::new(); + profiles.insert(DEFAULT_PROFILE_NAME.to_string(), test_profile()); Config { - graylog: GraylogConfig { - url: Url::parse("https://graylog.example.com").expect("test URL should parse"), - token: SecretString::new("test-token".to_owned().into()), - timeout_seconds: 42, - verify_tls: false, - fields_cache_ttl_seconds: 123, - }, + profiles, + active_profile: Some(DEFAULT_PROFILE_NAME.to_string()), updater: UpdaterConfig::default(), } } @@ -110,8 +155,10 @@ mod tests { fn config_serializes_to_toml() { let toml = toml::to_string(&test_config()).expect("config should serialize"); + assert!(toml.contains("[profiles.default]")); assert!(toml.contains("url = \"https://graylog.example.com/\"")); assert!(toml.contains("token = \"test-token\"")); + assert!(!toml.contains("graylog =")); } #[test] @@ -121,13 +168,48 @@ mod tests { let deserialized: Config = toml::from_str(&toml).expect("config should deserialize"); - assert_eq!(deserialized.graylog.url, config.graylog.url); assert_eq!( - deserialized.graylog.timeout_seconds, - config.graylog.timeout_seconds + deserialized.profiles[DEFAULT_PROFILE_NAME].url, + config.profiles[DEFAULT_PROFILE_NAME].url + ); + assert_eq!( + deserialized.profiles[DEFAULT_PROFILE_NAME].timeout_seconds, + config.profiles[DEFAULT_PROFILE_NAME].timeout_seconds + ); + assert_eq!( + deserialized.active_profile.as_deref(), + Some(DEFAULT_PROFILE_NAME) ); } + #[test] + fn config_without_active_profile_serializes_without_the_field() { + let config = Config { + profiles: BTreeMap::new(), + active_profile: None, + updater: UpdaterConfig::default(), + }; + + let toml = toml::to_string(&config).expect("config should serialize"); + + assert!(!toml.contains("active_profile")); + } + + #[test] + fn config_with_empty_profiles_round_trips() { + let config = Config { + profiles: BTreeMap::new(), + active_profile: None, + updater: UpdaterConfig::default(), + }; + let toml = toml::to_string(&config).expect("config should serialize"); + + let deserialized: Config = toml::from_str(&toml).expect("config should deserialize"); + + assert!(deserialized.profiles.is_empty()); + assert_eq!(deserialized.active_profile, None); + } + #[test] fn graylog_config_new_sets_defaults() { let config = GraylogConfig::new( @@ -158,7 +240,7 @@ mod tests { #[test] fn config_deserialization_uses_default_timeout() { let toml = r#" - [graylog] + [profiles.default] url = "https://graylog.example.com" token = "test-token" verify_tls = false @@ -167,13 +249,16 @@ mod tests { let config: Config = toml::from_str(toml).expect("config should deserialize"); - assert_eq!(config.graylog.timeout_seconds, DEFAULT_TIMEOUT_SECONDS); + assert_eq!( + config.profiles[DEFAULT_PROFILE_NAME].timeout_seconds, + DEFAULT_TIMEOUT_SECONDS + ); } #[test] fn config_deserialization_uses_default_verify_tls() { let toml = r#" - [graylog] + [profiles.default] url = "https://graylog.example.com" token = "test-token" timeout_seconds = 42 @@ -182,13 +267,13 @@ mod tests { let config: Config = toml::from_str(toml).expect("config should deserialize"); - assert!(config.graylog.verify_tls); + assert!(config.profiles[DEFAULT_PROFILE_NAME].verify_tls); } #[test] fn config_deserialization_uses_default_cache_ttl() { let toml = r#" - [graylog] + [profiles.default] url = "https://graylog.example.com" token = "test-token" timeout_seconds = 42 @@ -198,15 +283,29 @@ mod tests { let config: Config = toml::from_str(toml).expect("config should deserialize"); assert_eq!( - config.graylog.fields_cache_ttl_seconds, + config.profiles[DEFAULT_PROFILE_NAME].fields_cache_ttl_seconds, DEFAULT_FIELDS_CACHE_TTL_SECONDS ); } + #[test] + fn config_deserialization_defaults_active_profile_to_none() { + let toml = r#" + [profiles.default] + url = "https://graylog.example.com" + token = "test-token" + "#; + + let config: Config = toml::from_str(toml).expect("config should deserialize"); + + assert_eq!(config.active_profile, None); + assert!(config.profiles.contains_key(DEFAULT_PROFILE_NAME)); + } + #[test] fn config_deserialization_defaults_disable_auto_update_to_false() { let toml = r#" - [graylog] + [profiles.default] url = "https://graylog.example.com" token = "test-token" "#; @@ -219,7 +318,9 @@ mod tests { #[test] fn config_deserialization_reads_disable_auto_update_override() { let toml = r#" - [graylog] + active_profile = "default" + + [profiles.default] url = "https://graylog.example.com" token = "test-token" @@ -231,4 +332,38 @@ mod tests { assert!(config.updater.disable_auto_update); } + + #[test] + fn valid_profile_names_are_accepted() { + for name in [ + "a", + "A", + "7", + "default", + "prod-eu-1", + "staging.v2", + "team_a", + "Prod.EU-1_2", + ] { + assert!(is_valid_profile_name(name), "`{name}` should be valid"); + } + } + + #[test] + fn invalid_profile_names_are_rejected() { + for name in [ + "", + ".hidden", + "-leading", + "_leading", + "trailing space ", + "with space", + "with/slash", + "with:colon", + "with\\backslash", + "ünïcode", + ] { + assert!(!is_valid_profile_name(name), "`{name}` should be invalid"); + } + } } diff --git a/src/domain/models.rs b/src/domain/models.rs index a4bba04..2be859b 100644 --- a/src/domain/models.rs +++ b/src/domain/models.rs @@ -170,6 +170,8 @@ pub struct PingStatus { pub command: &'static str, pub reachable: bool, pub graylog_url: String, + pub profile: String, + pub available_profiles: Vec, } impl AggregationType { @@ -244,18 +246,57 @@ pub struct AuthStatus { pub ok: bool, pub command: &'static str, pub graylog_url: String, + pub profile: String, } impl AuthStatus { - pub fn ok(graylog_url: String) -> Self { + pub fn ok(graylog_url: String, profile: String) -> Self { Self { ok: true, command: "auth", graylog_url, + profile, } } } +/// Token-free view of a stored Graylog profile, safe to serialize to stdout. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ProfileSummary { + pub name: String, + pub url: String, + pub timeout_seconds: u64, + pub verify_tls: bool, + pub fields_cache_ttl_seconds: u64, + /// Effective for this invocation: profile override first, then persisted active_profile. + pub active: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ProfilesStatus { + pub ok: bool, + pub command: &'static str, + pub active_profile: Option, + pub profiles: Vec, + pub total: usize, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ProfileStatus { + pub ok: bool, + pub command: &'static str, + pub profile: ProfileSummary, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ProfileDeleteStatus { + pub ok: bool, + pub command: &'static str, + pub profile: String, + pub active_profile: Option, + pub remaining_profiles: usize, +} + #[derive(Debug, Clone, Serialize)] pub struct FieldsResult { pub fields: Vec, diff --git a/src/infrastructure/config_store.rs b/src/infrastructure/config_store.rs index f10b97b..40c0a10 100644 --- a/src/infrastructure/config_store.rs +++ b/src/infrastructure/config_store.rs @@ -1,14 +1,64 @@ +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use async_trait::async_trait; +use serde::Deserialize; use tokio::task; use crate::application::ports::cache_store::{CacheError, CacheStore}; use crate::application::ports::config_store::{ConfigError, ConfigStore}; -use crate::domain::config::Config; +use crate::domain::config::{Config, DEFAULT_PROFILE_NAME, GraylogConfig, UpdaterConfig}; + +/// On-disk shape of `config.toml`, supporting both the current profile-based +/// format and the legacy single-instance `[graylog]` format. +/// +/// This type is private to the infrastructure layer: legacy files are migrated +/// in memory while loading, and `ConfigStore::save` only ever writes the new +/// format. Loading a legacy file never rewrites it. +#[derive(Debug, Deserialize)] +struct RawConfigFile { + #[serde(default)] + graylog: Option, + #[serde(default)] + profiles: BTreeMap, + #[serde(default)] + active_profile: Option, + #[serde(default)] + updater: UpdaterConfig, +} + +/// Parses raw file contents into a domain `Config`, migrating a legacy +/// `[graylog]` table into `profiles.default` and selecting an active profile +/// when the file does not name one. +fn parse_config_file(contents: &str) -> Result { + let raw: RawConfigFile = toml::from_str(contents) + .map_err(|error| ConfigError::InvalidFormat(format!("failed to parse config: {error}")))?; + + let mut profiles = raw.profiles; + if let Some(legacy) = raw.graylog { + if !profiles.contains_key(DEFAULT_PROFILE_NAME) { + profiles.insert(DEFAULT_PROFILE_NAME.to_string(), legacy); + } else { + tracing::warn!("legacy [graylog] table ignored: profiles.default already exists"); + } + } + + let active_profile = raw.active_profile.or_else(|| { + profiles + .contains_key(DEFAULT_PROFILE_NAME) + .then(|| DEFAULT_PROFILE_NAME.to_string()) + .or_else(|| profiles.keys().next().cloned()) + }); + + Ok(Config { + profiles, + active_profile, + updater: raw.updater, + }) +} #[derive(Debug, Default, Clone, Copy)] pub struct FileConfigStore; @@ -51,9 +101,7 @@ impl ConfigStore for FileConfigStore { ConfigError::OperationFailure(format!("failed to read config: {error}")) })?; - toml::from_str::(&contents).map_err(|error| { - ConfigError::InvalidFormat(format!("failed to parse config: {error}")) - }) + parse_config_file(&contents) }) .await .map_err(|error| { @@ -160,3 +208,121 @@ fn set_directory_permissions(config_dir: &Path) -> Result<(), ConfigError> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::parse_config_file; + use crate::domain::config::DEFAULT_PROFILE_NAME; + + #[test] + fn legacy_graylog_table_migrates_into_default_profile() { + let contents = r#" + [graylog] + url = "https://graylog.example.com" + token = "legacy-token" + timeout_seconds = 42 + "#; + + let config = parse_config_file(contents).expect("legacy config should parse"); + + assert_eq!(config.profiles.len(), 1); + let profile = &config.profiles[DEFAULT_PROFILE_NAME]; + assert_eq!(profile.url.as_str(), "https://graylog.example.com/"); + assert_eq!(profile.timeout_seconds, 42); + assert_eq!(config.active_profile.as_deref(), Some(DEFAULT_PROFILE_NAME)); + } + + #[test] + fn legacy_graylog_preserves_updater_settings() { + let contents = r#" + [graylog] + url = "https://graylog.example.com" + token = "legacy-token" + + [updater] + disable_auto_update = true + "#; + + let config = parse_config_file(contents).expect("legacy config should parse"); + + assert!(config.updater.disable_auto_update); + } + + #[test] + fn profile_format_passes_through_unchanged() { + let contents = r#" + active_profile = "prod" + + [profiles.prod] + url = "https://prod.example.com" + token = "prod-token" + + [profiles.staging] + url = "https://staging.example.com" + token = "staging-token" + "#; + + let config = parse_config_file(contents).expect("profile config should parse"); + + assert_eq!(config.profiles.len(), 2); + assert_eq!(config.active_profile.as_deref(), Some("prod")); + assert!(config.profiles.contains_key("staging")); + } + + #[test] + fn profile_format_without_active_profile_selects_default_then_first() { + let contents = r#" + [profiles.beta] + url = "https://beta.example.com" + token = "beta-token" + + [profiles.default] + url = "https://graylog.example.com" + token = "default-token" + "#; + + let config = parse_config_file(contents).expect("profile config should parse"); + + assert_eq!(config.active_profile.as_deref(), Some(DEFAULT_PROFILE_NAME)); + } + + #[test] + fn migration_keeps_existing_default_profile_over_legacy_graylog() { + let contents = r#" + [graylog] + url = "https://legacy.example.com" + token = "legacy-token" + + [profiles.default] + url = "https://current.example.com" + token = "current-token" + "#; + + let config = parse_config_file(contents).expect("mixed config should parse"); + + assert_eq!(config.profiles.len(), 1); + assert_eq!( + config.profiles[DEFAULT_PROFILE_NAME].url.as_str(), + "https://current.example.com/" + ); + } + + #[test] + fn empty_profiles_migrate_to_not_configured_state() { + let contents = ""; + + let config = parse_config_file(contents).expect("empty config should parse"); + + assert!(config.profiles.is_empty()); + assert_eq!(config.active_profile, None); + } + + #[test] + fn malformed_config_is_rejected() { + let contents = "not [ valid toml"; + + let error = parse_config_file(contents).expect_err("malformed config should fail"); + + assert!(error.to_string().contains("failed to parse config")); + } +} diff --git a/src/main.rs b/src/main.rs index 872113d..9f1e572 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ use graylog_cli::infrastructure::config_store::FileConfigStore; use graylog_cli::infrastructure::graylog_client::ReqwestGraylogGatewayFactory; use graylog_cli::infrastructure::updater::GitHubUpdaterGateway; use graylog_cli::presentation::cli::{ - Cli, Commands, FieldsArgs, OutputFormat, StreamsCommands, SystemCommands, + Cli, Commands, FieldsArgs, OutputFormat, ProfilesCommands, StreamsCommands, SystemCommands, }; use graylog_cli::presentation::output::{ ErrorEnvelope, exit_code_for_cli_error, print_error_json, print_json, print_table, @@ -49,7 +49,8 @@ async fn main() { config_store.clone(), Arc::new(ReqwestGraylogGatewayFactory), config_store.clone(), - ); + ) + .with_profile_override(cli.profile.clone()); let updater = build_updater_service(config_store.clone()); @@ -170,6 +171,20 @@ async fn run( emit_json_success(&service.system_info().await?); } }, + Commands::Profiles { command } => match command { + ProfilesCommands::List => { + emit_json_success(&service.profiles_list().await?); + } + ProfilesCommands::Use(args) => { + emit_json_success(&service.profiles_use(&args.name).await?); + } + ProfilesCommands::Show(args) => { + emit_json_success(&service.profiles_show(args.name.as_deref()).await?); + } + ProfilesCommands::Delete(args) => { + emit_json_success(&service.profiles_delete(&args.name).await?); + } + }, Commands::Fields(FieldsArgs { refresh }) => { emit_json_success(&service.fields(refresh).await?); } diff --git a/src/presentation/cli.rs b/src/presentation/cli.rs index 664bf75..1dbddbb 100644 --- a/src/presentation/cli.rs +++ b/src/presentation/cli.rs @@ -4,6 +4,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum}; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; +use crate::domain::config::validate_profile_name; use crate::domain::error::{CliError, ValidationError}; use crate::domain::models::{ AggregateCommandInput, AggregationType, SearchCommandInput, SortDirection, @@ -18,10 +19,25 @@ use crate::domain::timerange::{CommandTimerange, TimerangeInput}; )] #[command(arg_required_else_help = true)] pub struct Cli { + /// Graylog profile to use for this invocation (default: the active profile). + #[arg( + long = "profile", + global = true, + env = "GRAYLOG_PROFILE", + value_parser = parse_profile_value, + )] + pub profile: Option, + #[command(subcommand)] pub command: Commands, } +fn parse_profile_value(value: &str) -> Result { + validate_profile_name(value) + .map(|()| value.to_string()) + .map_err(|message| format!("invalid value '{value}' for '--profile': {message}")) +} + impl Cli { pub fn validate(&self) -> Result<(), CliError> { self.command.validate().map_err(CliError::from) @@ -48,6 +64,11 @@ pub enum Commands { #[command(subcommand)] command: SystemCommands, }, + /// Manage named Graylog instance profiles. + Profiles { + #[command(subcommand)] + command: ProfilesCommands, + }, /// Check that Graylog is reachable. Ping, /// List all indexed fields. @@ -74,6 +95,7 @@ impl Commands { } Self::Streams { command } => command.validate(), Self::System { .. } => Ok(()), + Self::Profiles { .. } => Ok(()), Self::Fields(_) => Ok(()), Self::Upgrade | Self::SelfUpdateWorker => Ok(()), } @@ -311,6 +333,32 @@ pub enum SystemCommands { Info, } +#[derive(Debug, Subcommand)] +pub enum ProfilesCommands { + /// List configured profiles (tokens are never shown). + List, + /// Make a profile the active one. + Use(ProfileNameArgs), + /// Show details for a profile (defaults to the active profile). + Show(ProfileShowArgs), + /// Delete a profile. + Delete(ProfileNameArgs), +} + +#[derive(Debug, Args)] +pub struct ProfileNameArgs { + /// Profile name. + #[arg(value_parser = parse_profile_value)] + pub name: String, +} + +#[derive(Debug, Args)] +pub struct ProfileShowArgs { + /// Profile name (defaults to the active profile). + #[arg(value_parser = parse_profile_value)] + pub name: Option, +} + #[derive(Debug, Clone, Default, Args)] pub struct TimerangeArgs { #[arg(long = "time-range")] @@ -849,4 +897,138 @@ mod tests { "expected absolute timerange from --since" ); } + + // --- --profile tests --- + + #[test] + fn global_profile_flag_parses_before_subcommand() { + let cli = parse(&["graylog-cli", "--profile", "prod", "ping"]) + .expect("--profile before subcommand should parse"); + + assert_eq!(cli.profile.as_deref(), Some("prod")); + assert!(matches!(cli.command, Commands::Ping)); + } + + #[test] + fn global_profile_flag_parses_after_subcommand() { + let cli = parse(&["graylog-cli", "ping", "--profile", "prod"]) + .expect("--profile after subcommand should parse"); + + assert_eq!(cli.profile.as_deref(), Some("prod")); + } + + #[test] + fn profile_defaults_to_none() { + let cli = parse(&["graylog-cli", "ping"]).expect("ping should parse"); + + assert_eq!(cli.profile, None); + } + + #[test] + fn invalid_profile_names_are_rejected() { + for value in ["", "-lead", ".dot", "with space", "pro/file"] { + assert!( + parse(&["graylog-cli", "--profile", value, "ping"]).is_err(), + "profile `{value}` should be rejected" + ); + } + } + + #[test] + fn auth_accepts_global_profile_flag() { + let result = parse(&[ + "graylog-cli", + "auth", + "--url", + "http://localhost:9000", + "--token", + "secret", + "--profile", + "prod", + ]); + // clap routes the global flag to the root, so parsing succeeds and the + // value lands on `cli.profile` rather than on AuthArgs. + let cli = result.expect("--profile is a global flag even after auth"); + assert_eq!(cli.profile.as_deref(), Some("prod")); + } + + // --- Profiles subcommand tests --- + + #[test] + fn profiles_list_parses() { + let cli = parse(&["graylog-cli", "profiles", "list"]).expect("profiles list should parse"); + + match cli.command { + Commands::Profiles { command } => { + assert!(matches!(command, ProfilesCommands::List)); + } + _ => panic!("expected Profiles command"), + } + } + + #[test] + fn profiles_use_requires_valid_name() { + let cli = + parse(&["graylog-cli", "profiles", "use", "prod"]).expect("profiles use should parse"); + + match cli.command { + Commands::Profiles { command } => match command { + ProfilesCommands::Use(args) => assert_eq!(args.name, "prod"), + _ => panic!("expected Use subcommand"), + }, + _ => panic!("expected Profiles command"), + } + + assert!( + parse(&["graylog-cli", "profiles", "use", "not valid"]).is_err(), + "profiles use with invalid name should fail" + ); + assert!( + parse(&["graylog-cli", "profiles", "use"]).is_err(), + "profiles use without name should fail" + ); + } + + #[test] + fn profiles_show_name_is_optional() { + let cli = parse(&["graylog-cli", "profiles", "show"]) + .expect("profiles show without name should parse"); + + match cli.command { + Commands::Profiles { command } => match command { + ProfilesCommands::Show(args) => assert_eq!(args.name, None), + _ => panic!("expected Show subcommand"), + }, + _ => panic!("expected Profiles command"), + } + + let cli = parse(&["graylog-cli", "profiles", "show", "prod"]) + .expect("profiles show with name should parse"); + match cli.command { + Commands::Profiles { command } => match command { + ProfilesCommands::Show(args) => assert_eq!(args.name.as_deref(), Some("prod")), + _ => panic!("expected Show subcommand"), + }, + _ => panic!("expected Profiles command"), + } + } + + #[test] + fn profiles_delete_requires_name() { + let cli = parse(&["graylog-cli", "profiles", "delete", "prod"]) + .expect("profiles delete should parse"); + + match cli.command { + Commands::Profiles { command } => match command { + ProfilesCommands::Delete(args) => assert_eq!(args.name, "prod"), + _ => panic!("expected Delete subcommand"), + }, + _ => panic!("expected Profiles command"), + } + + assert!( + parse(&["graylog-cli", "profiles", "delete"]).is_err(), + "profiles delete without name should fail" + ); + } } diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index f8e8173..e832140 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -1,5 +1,10 @@ +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; use std::process::Command; +use tempfile::TempDir; + fn graylog_cli() -> Command { Command::new(env!("CARGO_BIN_EXE_graylog-cli")) } @@ -10,6 +15,84 @@ fn run(args: &[&str]) -> std::process::Output { cmd.output().expect("failed to run graylog-cli") } +struct TestEnv { + home: TempDir, + config_path: PathBuf, +} + +impl TestEnv { + fn new() -> Self { + let home = tempfile::tempdir().expect("temp dir should create"); + let config_path = config_file_path(home.path()); + std::fs::create_dir_all( + config_path + .parent() + .expect("config path should have a parent"), + ) + .expect("config dir should create"); + Self { home, config_path } + } + + fn write_config(&self, contents: &str) { + std::fs::write(&self.config_path, contents).expect("config should write"); + } + + fn read_config(&self) -> String { + std::fs::read_to_string(&self.config_path).expect("config should read") + } + + fn command(&self, args: &[&str]) -> Command { + let mut cmd = graylog_cli(); + cmd.args(args) + .env("HOME", self.config_home()) + .env("XDG_CONFIG_HOME", self.config_home()) + .env("GRAYLOG_CLI_AUTO_UPDATE", "0") + .env_remove("GRAYLOG_PROFILE") + .env_remove("GRAYLOG_TOKEN"); + cmd + } + + fn run(&self, args: &[&str]) -> std::process::Output { + self.command(args) + .output() + .expect("failed to run graylog-cli") + } + + fn config_home(&self) -> &Path { + self.home.path() + } +} + +fn config_file_path(config_home: &Path) -> PathBuf { + if cfg!(target_os = "macos") { + config_home.join("Library/Application Support/graylog-cli/config.toml") + } else { + config_home.join("graylog-cli/config.toml") + } +} + +const LEGACY_CONFIG: &str = r#" +[graylog] +url = "https://legacy.example.com" +token = "legacy-secret-token" +timeout_seconds = 42 + +[updater] +disable_auto_update = true +"#; + +const PROFILES_CONFIG: &str = r#" +active_profile = "alpha" + +[profiles.alpha] +url = "https://alpha.example.com" +token = "alpha-secret-token" + +[profiles.beta] +url = "https://beta.example.com" +token = "beta-secret-token" +"#; + #[test] fn help_flag_exits_zero() { let output = run(&["--help"]); @@ -18,6 +101,7 @@ fn help_flag_exits_zero() { assert!(stdout.contains("graylog-cli")); assert!(stdout.contains("search")); assert!(stdout.contains("auth")); + assert!(stdout.contains("--profile")); } #[test] @@ -37,3 +121,313 @@ fn unknown_command_fails() { let output = run(&["nonexistent"]); assert!(!output.status.success()); } + +#[test] +fn profiles_help_lists_subcommands() { + let output = run(&["profiles", "--help"]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + for subcommand in ["list", "use", "show", "delete"] { + assert!( + stdout.contains(subcommand), + "expected `{subcommand}` in help" + ); + } +} + +#[test] +fn legacy_config_migrates_without_reauth_and_without_token_leak() { + let env = TestEnv::new(); + env.write_config(LEGACY_CONFIG); + + let output = env.run(&["profiles", "list"]); + assert!(output.status.success(), "profiles list should succeed"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("default"), + "legacy profile migrates to `default`" + ); + assert!( + stdout.contains("https://legacy.example.com/"), + "legacy URL is preserved: {stdout}" + ); + assert!( + !stdout.contains("legacy-secret-token"), + "token must never appear in output" + ); + assert!(stdout.contains("\"active_profile\": \"default\"")); + + // Loading never rewrites a legacy file. + let on_disk = env.read_config(); + assert!( + on_disk.contains("[graylog]"), + "legacy config must not be rewritten by load" + ); + assert!(!on_disk.contains("[profiles")); +} + +#[test] +fn legacy_config_profiles_show_defaults_to_migrated_profile() { + let env = TestEnv::new(); + env.write_config(LEGACY_CONFIG); + + let output = env.run(&["profiles", "show"]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("\"name\": \"default\"")); + assert!(stdout.contains("\"active\": true")); + assert!(stdout.contains("\"timeout_seconds\": 42")); + assert!(!stdout.contains("legacy-secret-token")); +} + +#[test] +fn global_profile_flag_selects_profile_for_show() { + let env = TestEnv::new(); + env.write_config(PROFILES_CONFIG); + + let output = env.run(&["--profile", "beta", "profiles", "show"]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("https://beta.example.com/")); + assert!(stdout.contains("\"active\": true")); + assert!(!stdout.contains("beta-secret-token")); +} + +#[test] +fn profile_flag_works_after_subcommand() { + let env = TestEnv::new(); + env.write_config(PROFILES_CONFIG); + + let output = env.run(&["profiles", "show", "--profile", "beta"]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("https://beta.example.com/")); +} + +#[test] +fn graylog_profile_env_var_selects_profile() { + let env = TestEnv::new(); + env.write_config(PROFILES_CONFIG); + + let mut cmd = env.command(&["profiles", "show"]); + cmd.env("GRAYLOG_PROFILE", "beta"); + let output = cmd.output().expect("failed to run graylog-cli"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("https://beta.example.com/")); +} + +#[test] +fn profiles_use_persists_active_profile() { + let env = TestEnv::new(); + env.write_config(PROFILES_CONFIG); + + let output = env.run(&["profiles", "use", "beta"]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("\"command\": \"profiles.use\"")); + assert!(stdout.contains("\"name\": \"beta\"")); + + let on_disk = env.read_config(); + assert!(on_disk.contains("active_profile = \"beta\"")); + + let follow_up = env.run(&["profiles", "show"]); + assert!(follow_up.status.success()); + let stdout = String::from_utf8_lossy(&follow_up.stdout); + assert!(stdout.contains("https://beta.example.com/")); +} + +#[test] +fn profiles_delete_active_clears_active_and_leaves_file_valid() { + let env = TestEnv::new(); + env.write_config(PROFILES_CONFIG); + + let output = env.run(&["profiles", "delete", "alpha"]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("\"remaining_profiles\": 1")); + + let on_disk = env.read_config(); + assert!(!on_disk.contains("[profiles.alpha]")); + assert!(on_disk.contains("[profiles.beta]")); + assert!(!on_disk.contains("active_profile = \"alpha\"")); + + // Remaining profile is still usable without re-selecting it. + let follow_up = env.run(&["profiles", "show"]); + assert!(follow_up.status.success()); + let stdout = String::from_utf8_lossy(&follow_up.stdout); + assert!(stdout.contains("https://beta.example.com/")); +} + +#[test] +fn profiles_delete_last_profile_leads_to_not_configured() { + let env = TestEnv::new(); + env.write_config(LEGACY_CONFIG); + + let deleted = env.run(&["profiles", "delete", "default"]); + assert!(deleted.status.success()); + + let output = env.run(&["profiles", "show"]); + assert_eq!(output.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("validation_error")); + assert!(stderr.contains("graylog is not configured")); +} + +#[test] +fn unknown_profile_reports_validation_error() { + let env = TestEnv::new(); + env.write_config(PROFILES_CONFIG); + + let output = env.run(&["--profile", "gamma", "profiles", "show"]); + assert_eq!(output.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("validation_error"), "got: {stderr}"); + assert!(stderr.contains("unknown profile `gamma`"), "got: {stderr}"); + assert!(stderr.contains("alpha"), "got: {stderr}"); +} + +#[test] +fn invalid_profile_name_is_rejected_by_the_parser() { + let output = run(&["--profile", "not valid", "ping"]); + assert_eq!(output.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("--profile"), "got: {stderr}"); +} + +#[test] +fn auth_without_profile_writes_default_profile() { + let env = TestEnv::new(); + + let output = env.run(&[ + "auth", + "--url", + "http://localhost:9000", + "--token", + "fresh-token", + ]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("\"profile\": \"default\"")); + assert!(!stdout.contains("fresh-token")); + + let on_disk = env.read_config(); + assert!(on_disk.contains("[profiles.default]")); + assert!(on_disk.contains("active_profile = \"default\"")); + assert!(!on_disk.contains("[graylog]")); +} + +#[test] +fn auth_with_global_profile_writes_named_profile() { + let env = TestEnv::new(); + env.write_config(LEGACY_CONFIG); + + let output = env.run(&[ + "--profile", + "prod", + "auth", + "--url", + "http://prod:9000", + "--token", + "prod-token", + ]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("\"profile\": \"prod\"")); + + let on_disk = env.read_config(); + assert!( + on_disk.contains("[profiles.default]"), + "legacy profile is kept" + ); + assert!(on_disk.contains("[profiles.prod]")); + assert!(on_disk.contains("active_profile = \"prod\"")); + assert!( + on_disk.contains("disable_auto_update = true"), + "updater settings survive" + ); + assert!( + !on_disk.contains("[graylog]"), + "save always writes the new format" + ); +} + +#[test] +fn ping_reports_profile_and_available_profiles() { + use std::time::Duration; + + // One-shot HTTP server: a single accept with a read timeout, one fixed + // 200 JSON response, then exit. No keep-alive, no second connection. + let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let addr = listener.local_addr().expect("local addr"); + let (served_tx, served_rx) = std::sync::mpsc::channel::(); + let server = std::thread::spawn(move || { + let served = (|| -> Option<()> { + let (mut stream, _) = listener.accept().ok()?; + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .ok()?; + let mut seen = Vec::new(); + let mut buffer = [0u8; 4096]; + loop { + let n = stream.read(&mut buffer).ok()?; + if n == 0 { + return None; + } + seen.extend_from_slice(&buffer[..n]); + if String::from_utf8_lossy(&seen).contains("\r\n\r\n") { + break; + } + } + let body = "{}"; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(response.as_bytes()).ok()?; + stream.flush().ok()?; + Some(()) + })() + .is_some(); + let _ = served_tx.send(served); + }); + + let env = TestEnv::new(); + env.write_config(&format!( + r#" +active_profile = "alpha" + +[profiles.alpha] +url = "http://{addr}" +token = "alpha-secret-token" + +[profiles.beta] +url = "http://beta.example.com" +token = "beta-secret-token" +"# + )); + + let output = env.run(&["ping"]); + assert!( + served_rx + .recv_timeout(Duration::from_secs(10)) + .expect("server should serve exactly one request"), + "server should have served the ping request" + ); + // The server thread has sent its result and returns immediately, so this + // join cannot block. + server.join().expect("server thread should finish"); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("\"profile\": \"alpha\""), "got: {stdout}"); + assert!(stdout.contains("\"available_profiles\"")); + assert!(stdout.contains("alpha")); + assert!(stdout.contains("beta")); + assert!(!stdout.contains("alpha-secret-token")); +} From 91de51ac3d7daf1a5400a7fa6a143ff62b75c884 Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Tue, 15 Sep 2026 18:03:01 +0200 Subject: [PATCH 06/10] style: run nix fmt (prettier markdown) --- .claude/skills/release/SKILL.md | 8 +++--- AGENTS.md | 45 ++++++++++++++++++++------------- README.md | 10 ++++---- skills/graylog-cli/SKILL.md | 34 ++++++++++++------------- 4 files changed, 53 insertions(+), 44 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index e469557..caca69a 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -2,7 +2,7 @@ name: release description: Create a graylog-cli release by determining the next git tag, synchronizing Cargo.toml and flake.nix versions, verifying the Nix build, tagging, and pushing the release trigger. disable-model-invocation: true -argument-hint: '[patch|minor|major|]' +argument-hint: "[patch|minor|major|]" allowed-tools: Bash, Read, Edit, Write, Grep, Glob --- @@ -23,10 +23,10 @@ Use this skill to prepare and publish a `graylog-cli` release. Releases are trig The release version is duplicated and must be updated in both places: -| File | Field | -| --- | --- | +| File | Field | +| ------------ | --------------------------------- | | `Cargo.toml` | `[package] version = ""` | -| `flake.nix` | `version = "";` | +| `flake.nix` | `version = "";` | The git tag must be `v`, for example `v0.1.0`. diff --git a/AGENTS.md b/AGENTS.md index 268de7c..55b54df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,9 +5,11 @@ **Branch:** main ## OVERVIEW + Rust CLI for Graylog (search/aggregate/streams/system). Hexagonal single crate, tokio + clap + reqwest/rustls. ## STRUCTURE + ``` graylog-cli/ ├── src/main.rs # composition root, updater worker @@ -21,28 +23,31 @@ graylog-cli/ ``` ## WHERE TO LOOK -| Task | Location | Notes | -|------|----------|-------| -| CLI commands/flags | `src/presentation/cli.rs` | clap derive, `validate()` + `to_input()` | -| Use-cases | `src/application/service.rs` | `ApplicationService`, 1969 lines | -| HTTP + normalization | `src/infrastructure/graylog_client.rs` | 1847 lines, `X-Requested-By` | -| Config/cache files | `src/infrastructure/config_store.rs` | `~/.config/graylog-cli/config.toml`, 0700 | -| Output/exit codes | `src/presentation/output.rs` | JSON envelope, codes 1-6 | -| Self-update | `src/application/updater_service.rs`, `src/main.rs` | `__self-update-worker`, 24h throttle | -| Release | `.github/workflows/release.yml` | tag `v*` must be on main | + +| Task | Location | Notes | +| -------------------- | --------------------------------------------------- | ----------------------------------------- | +| CLI commands/flags | `src/presentation/cli.rs` | clap derive, `validate()` + `to_input()` | +| Use-cases | `src/application/service.rs` | `ApplicationService`, 1969 lines | +| HTTP + normalization | `src/infrastructure/graylog_client.rs` | 1847 lines, `X-Requested-By` | +| Config/cache files | `src/infrastructure/config_store.rs` | `~/.config/graylog-cli/config.toml`, 0700 | +| Output/exit codes | `src/presentation/output.rs` | JSON envelope, codes 1-6 | +| Self-update | `src/application/updater_service.rs`, `src/main.rs` | `__self-update-worker`, 24h throttle | +| Release | `.github/workflows/release.yml` | tag `v*` must be on main | ## CODE MAP -| Symbol | Type | Location | Role | -|--------|------|----------|------| -| `ApplicationService` | struct | `application/service.rs` | search/aggregate/auth/ping/streams/system/fields | -| `Config/GraylogConfig/UpdaterConfig` | struct | `domain/config.rs` | TOML config, `SecretString` token | -| `CliError/HttpError/ValidationError` | enum | `domain/error.rs` | layered thiserror + exn | -| `ConfigStore/CacheStore/GraylogGateway/UpdaterGateway` | trait | `application/ports/` | DI seams, glob re-export | -| `FileConfigStore` | struct | `infrastructure/config_store.rs` | atomic write, implements both stores | -| `Cli/Commands` | enum | `presentation/cli.rs` | `auth/search/aggregate/count-by-level/streams/system/ping/fields/upgrade` | -| `print_json/print_table/exit_code_for_cli_error` | fn | `presentation/output.rs` | machine contract | + +| Symbol | Type | Location | Role | +| ------------------------------------------------------ | ------ | -------------------------------- | ------------------------------------------------------------------------- | +| `ApplicationService` | struct | `application/service.rs` | search/aggregate/auth/ping/streams/system/fields | +| `Config/GraylogConfig/UpdaterConfig` | struct | `domain/config.rs` | TOML config, `SecretString` token | +| `CliError/HttpError/ValidationError` | enum | `domain/error.rs` | layered thiserror + exn | +| `ConfigStore/CacheStore/GraylogGateway/UpdaterGateway` | trait | `application/ports/` | DI seams, glob re-export | +| `FileConfigStore` | struct | `infrastructure/config_store.rs` | atomic write, implements both stores | +| `Cli/Commands` | enum | `presentation/cli.rs` | `auth/search/aggregate/count-by-level/streams/system/ping/fields/upgrade` | +| `print_json/print_table/exit_code_for_cli_error` | fn | `presentation/output.rs` | machine contract | ## CONVENTIONS + - Hexagonal: `application` never does I/O directly, only via `ports` traits; adapters in `infrastructure`. - Errors: `thiserror` layer enums + `exn::Result`; `main()` returns `()`, single `emit_cli_error` prints JSON to stderr. - Success = JSON on stdout; `--format table` only exception. No prompts, non-interactive. @@ -50,6 +55,7 @@ graylog-cli/ - Edition 2024 (let-chains), `max_width=100`, treefmt via pre-commit. Conventional Commits. ## ANTI-PATTERNS (THIS PROJECT) + - Don't return `Result` from `main` or add `anyhow`; use `exn` + JSON envelope + semantic exit codes. - Don't I/O in `ApplicationService`; go through `ports`. - Don't set `target-cpu=native` in `.cargo/config.toml` (breaks cross builds). @@ -57,11 +63,13 @@ graylog-cli/ - Never serialize `GraylogConfig` to stdout (token is plaintext in TOML serde). ## UNIQUE STYLES + - `lib.rs` is 4 lines; `main.rs` wires `Arc` twice (config + cache). - Fields cache single global key `"fields"` with TTL 300s. - Release patches `Cargo.toml` version from tag; `publish=false`, ships binaries only. ## COMMANDS + ```bash nix develop --command cargo test --all --locked nix develop --command cargo clippy --all-targets --locked -- -D warnings @@ -72,6 +80,7 @@ nix build .#graylog-cli-windows ``` ## NOTES + - Two hotspots hold ~52% LOC: `service.rs`, `graylog_client.rs`. `cli.rs` (852) holds full command tree. - `publish=false`; tags `v*` must point at `main` (verified in release.yml). - `dirs::config_dir()/graylog-cli/` holds `config.toml` + `.json` caches. diff --git a/README.md b/README.md index 75d327a..be12078 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,11 @@ graylog-cli --help Most commands accept a time range. Three forms are supported: -| Flag | Description | Example | -|------|-------------|---------| -| `--since ` | Relative to now, shorthand | `--since 1h`, `--since 30m`, `--since 7d` | -| `--time-range ` | Relative to now, Graylog-style | `--time-range 1h` | -| `--from --to ` | Absolute range (RFC 3339) | `--from 2024-01-01T00:00:00Z --to 2024-01-02T00:00:00Z` | +| Flag | Description | Example | +| ------------------------- | ------------------------------ | ------------------------------------------------------- | +| `--since ` | Relative to now, shorthand | `--since 1h`, `--since 30m`, `--since 7d` | +| `--time-range ` | Relative to now, Graylog-style | `--time-range 1h` | +| `--from --to ` | Absolute range (RFC 3339) | `--from 2024-01-01T00:00:00Z --to 2024-01-02T00:00:00Z` | `--since` and `--time-range` are mutually exclusive with `--from`/`--to`. diff --git a/skills/graylog-cli/SKILL.md b/skills/graylog-cli/SKILL.md index e45fee7..b6e6ce2 100644 --- a/skills/graylog-cli/SKILL.md +++ b/skills/graylog-cli/SKILL.md @@ -98,21 +98,21 @@ graylog-cli search [--time-range 15m] [--since 1h] [--field message] [-- [--group-by ] [--all-pages] [--all-fields] [--format json|table] ``` -| Flag | Values | Notes | -| ------------------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--time-range` | `Ns`, `Nm`, `Nh`, `Nd`, `Nw` | Relative range. Mutually exclusive with `--from`/`--to` and `--since` | -| `--from` / `--to` | ISO 8601 timestamps | Absolute range. Both required together. `--from` must be earlier than `--to` | -| `--since` | humantime duration | Shorthand absolute range ending now: `--since 1h` expands to `--from --to `. Mutually exclusive with `--time-range` and `--from`/`--to` | -| `--field` | repeatable | Restrict returned fields | -| `--all-fields` | flag (no value) | Fetch all indexed fields (cached on disk with TTL). Ignored when `--field` is set | -| `--limit` | 1-1000 | Per-page limit (ignored when `--all-pages` is set) | -| `--offset` | non-negative integer | Pagination offset (ignored when `--all-pages` is set) | -| `--sort` | field name | Default: `timestamp` | -| `--sort-direction` | `asc`, `desc` | Default: `desc` | -| `--stream-id` | repeatable | Scope search to specific streams | -| `--group-by` | any indexed field name | Group results by a field. Adds `grouped_by` and `groups` to output | -| `--all-pages` | flag (no value) | Fetch all results beyond the 500-per-page API limit. See caveat below | -| `--format` | `json` (default), `table` | Output format. `table` renders an ASCII table of messages directly to stdout | +| Flag | Values | Notes | +| ------------------ | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--time-range` | `Ns`, `Nm`, `Nh`, `Nd`, `Nw` | Relative range. Mutually exclusive with `--from`/`--to` and `--since` | +| `--from` / `--to` | ISO 8601 timestamps | Absolute range. Both required together. `--from` must be earlier than `--to` | +| `--since` | humantime duration | Shorthand absolute range ending now: `--since 1h` expands to `--from --to `. Mutually exclusive with `--time-range` and `--from`/`--to` | +| `--field` | repeatable | Restrict returned fields | +| `--all-fields` | flag (no value) | Fetch all indexed fields (cached on disk with TTL). Ignored when `--field` is set | +| `--limit` | 1-1000 | Per-page limit (ignored when `--all-pages` is set) | +| `--offset` | non-negative integer | Pagination offset (ignored when `--all-pages` is set) | +| `--sort` | field name | Default: `timestamp` | +| `--sort-direction` | `asc`, `desc` | Default: `desc` | +| `--stream-id` | repeatable | Scope search to specific streams | +| `--group-by` | any indexed field name | Group results by a field. Adds `grouped_by` and `groups` to output | +| `--all-pages` | flag (no value) | Fetch all results beyond the 500-per-page API limit. See caveat below | +| `--format` | `json` (default), `table` | Output format. `table` renders an ASCII table of messages directly to stdout | When `--group-by` is set, the output includes a `groups` array where each group has `key` (field value), `count` (number of messages), and `duration_ms` (time span from first to last message in the group). Use `--sort-direction asc` with `--group-by` for chronological grouping. The `--group-by` field is automatically added to the fetched fields, so you do not need to specify it explicitly with `--field`. @@ -206,8 +206,8 @@ graylog-cli fields [--refresh] Returns every field name that Graylog has indexed across all messages. Use this to discover what fields you can pass to `--field`, use in queries (`field:value`), or aggregate on. -| Flag | Notes | -| ----------- | -------------------------------------------------------------------------------------------------- | +| Flag | Notes | +| ----------- | --------------------------------------------------------------------------------------------------- | | `--refresh` | Bypass the on-disk cache and fetch fresh fields from Graylog, then update the cache with the result | Without `--refresh`, results may be served from an on-disk cache to avoid a round-trip on every query. Use `--refresh` when newly indexed fields are not appearing in results. From 509ff5bd33a2102d38b5e8dfea965b3fa806ce98 Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Tue, 15 Sep 2026 18:03:01 +0200 Subject: [PATCH 07/10] feat: rename profiles and document them in skill - New profiles rename command (validates both names, rejects unknown source and existing target, follows active selection) - skills/graylog-cli: add Profiles section (storage layout, management commands, --profile/GRAYLOG_PROFILE, ping fields) --- README.md | 5 +- skills/graylog-cli/SKILL.md | 45 +++++++++++++ src/application/service.rs | 122 ++++++++++++++++++++++++++++++++++++ src/main.rs | 7 +++ src/presentation/cli.rs | 38 +++++++++++ 5 files changed, 215 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index be12078..6384557 100644 --- a/README.md +++ b/README.md @@ -232,8 +232,9 @@ Profile management commands: ```sh graylog-cli profiles list # list profiles (tokens are never shown) graylog-cli profiles show [name] # show a profile; defaults to the active one -graylog-cli profiles use staging # switch the active profile -graylog-cli profiles delete staging # remove a profile +graylog-cli profiles use staging # switch the active profile +graylog-cli profiles rename staging prod # rename a profile +graylog-cli profiles delete staging # remove a profile ``` Deleting the active profile clears the active selection; the CLI then falls back to the first remaining profile. Deleting the last profile returns the CLI to its not-configured state. Profile names must start with an ASCII letter or digit and may only contain ASCII letters, digits, `.`, `_`, and `-`. diff --git a/skills/graylog-cli/SKILL.md b/skills/graylog-cli/SKILL.md index b6e6ce2..203d12a 100644 --- a/skills/graylog-cli/SKILL.md +++ b/skills/graylog-cli/SKILL.md @@ -42,6 +42,37 @@ Config is stored at: On Unix, directory permissions are `0700` and file permissions are `0600`. On Windows, NTFS ACLs inherit from the parent directory — no explicit permission hardening is applied. +## Profiles (Multiple Graylog Instances) + +Profiles live in the same `config.toml` file above. Each profile is a complete set of credentials under `[profiles.]`, plus an `active_profile` pointer naming the default: + +```toml +active_profile = "prod" + +[profiles.prod] +url = "https://graylog.example.com" +token = "your-access-token" + +[profiles.staging] +url = "https://staging.graylog.example.com" +token = "staging-access-token" +``` + +Per-profile cache files (`fields-.json`) sit next to `config.toml`, so switching profiles never serves stale field lists from another instance. A legacy single-credential file (`[graylog]` table, no profiles) migrates automatically on first load into `profiles.default` without re-auth. + +```bash +graylog-cli auth --url --token # writes profile "default" +graylog-cli --profile staging auth --url --token # writes profile "staging" + +graylog-cli profiles list # list profiles (tokens are never shown) +graylog-cli profiles show [name] # show a profile; defaults to the active one +graylog-cli profiles use staging # switch the active profile +graylog-cli profiles rename old new # rename a profile (follows the active selection) +graylog-cli profiles delete staging # remove a profile +``` + +Every command runs against the active profile. Use the global `--profile` flag (or `GRAYLOG_PROFILE` env var) to target another profile for one invocation without switching: `graylog-cli --profile staging search 'level:ERROR'`. `ping` reports which profile it used (`profile`) and which are available (`available_profiles`). Profile names must start with an ASCII letter or digit and may only contain ASCII letters, digits, `.`, `_`, and `-`. + ## Graylog Query Language The `search`, `aggregate`, and `streams search` commands accept Graylog's Lucene-based query syntax. Understanding the query language is essential for effective use. @@ -220,6 +251,20 @@ Check that Graylog is reachable and credentials are valid. graylog-cli ping ``` +The response includes `profile` (which profile was used) and `available_profiles` (all configured profiles). See [Profiles](#profiles-multiple-graylog-instances). + +### profiles + +Manage named Graylog instance profiles. Tokens are never shown in output. + +```bash +graylog-cli profiles list +graylog-cli profiles show [name] +graylog-cli profiles use +graylog-cli profiles rename +graylog-cli profiles delete +``` + ## Investigation Workflows When investigating an issue, follow these patterns. Output is JSON — pipe through `jq` for filtering. diff --git a/src/application/service.rs b/src/application/service.rs index d82455d..bd36afe 100644 --- a/src/application/service.rs +++ b/src/application/service.rs @@ -220,6 +220,67 @@ impl ApplicationService { }) } + /// Renames a profile, keeping its settings. Follows the active selection: + /// if the renamed profile was active, the new name becomes active. + pub async fn profiles_rename( + &self, + old_name: &str, + new_name: &str, + ) -> exn::Result { + if let Err(message) = validate_profile_name(old_name) { + return Err(CliError::Validation(ValidationError::InvalidValue { + field: "profile", + message, + }) + .into()); + } + if let Err(message) = validate_profile_name(new_name) { + return Err(CliError::Validation(ValidationError::InvalidValue { + field: "profile", + message, + }) + .into()); + } + + let mut config = self.load_config().await?; + let graylog = self.require_profile(&config, old_name)?; + if config.profiles.contains_key(new_name) { + let available = config + .profiles + .keys() + .cloned() + .collect::>() + .join(", "); + return Err(CliError::Validation(ValidationError::InvalidValue { + field: "profile", + message: format!( + "profile `{new_name}` already exists (available profiles: {available})" + ), + }) + .into()); + } + + config.profiles.remove(old_name); + config + .profiles + .insert(new_name.to_string(), graylog.clone()); + if config.active_profile.as_deref() == Some(old_name) { + config.active_profile = Some(new_name.to_string()); + } + let active = self.effective_active_profile(&config); + + self.config_store + .save(config) + .await + .or_raise(|| CliError::Config("failed to persist config".to_string()))?; + + Ok(ProfileStatus { + ok: true, + command: "profiles.rename", + profile: profile_summary(new_name, &graylog, active.as_deref()), + }) + } + pub async fn search( &self, input: SearchCommandInput, @@ -2609,6 +2670,67 @@ mod tests { assert_profile_validation_error(error, "profile names must"); } + #[tokio::test] + async fn profiles_rename_moves_settings_and_keeps_active() { + let config_store = FakeConfigStore::new(multi_profile_config()); + let (service, _, _) = service_with_gateway( + config_store.clone(), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + ); + let status = service + .profiles_rename("beta", "gamma") + .await + .expect("profiles rename should succeed"); + assert_eq!(status.command, "profiles.rename"); + assert_eq!(status.profile.name, "gamma"); + assert_eq!(status.profile.url, "http://beta:9000/"); + let saved = config_store.saved_config().expect("config should be saved"); + assert!(!saved.profiles.contains_key("beta")); + assert!(saved.profiles.contains_key("gamma")); + assert_eq!(saved.active_profile.as_deref(), Some("alpha")); + } + + #[tokio::test] + async fn profiles_rename_follows_active_profile() { + let config_store = FakeConfigStore::new(multi_profile_config()); + let (service, _, _) = service_with_gateway( + config_store.clone(), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + ); + service + .profiles_rename("alpha", "gamma") + .await + .expect("profiles rename should succeed"); + let saved = config_store.saved_config().expect("config should be saved"); + assert_eq!(saved.active_profile.as_deref(), Some("gamma")); + } + + #[tokio::test] + async fn profiles_rename_rejects_unknown_and_existing_names() { + let (service, _, _) = service_with_gateway( + FakeConfigStore::new(multi_profile_config()), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + ); + let error = service + .profiles_rename("gamma", "delta") + .await + .expect_err("unknown source should fail"); + assert_profile_validation_error(error, "unknown profile `gamma`"); + let error = service + .profiles_rename("alpha", "beta") + .await + .expect_err("existing target should fail"); + assert_profile_validation_error(error, "already exists"); + let error = service + .profiles_rename("no spaces", "delta") + .await + .expect_err("invalid source should fail"); + assert_profile_validation_error(error, "profile names must"); + } + #[tokio::test] async fn profiles_delete_inactive_profile_keeps_active() { let config_store = FakeConfigStore::new(multi_profile_config()); diff --git a/src/main.rs b/src/main.rs index 9f1e572..e36db54 100644 --- a/src/main.rs +++ b/src/main.rs @@ -184,6 +184,13 @@ async fn run( ProfilesCommands::Delete(args) => { emit_json_success(&service.profiles_delete(&args.name).await?); } + ProfilesCommands::Rename(args) => { + emit_json_success( + &service + .profiles_rename(&args.old_name, &args.new_name) + .await?, + ); + } }, Commands::Fields(FieldsArgs { refresh }) => { emit_json_success(&service.fields(refresh).await?); diff --git a/src/presentation/cli.rs b/src/presentation/cli.rs index 1dbddbb..c7b1021 100644 --- a/src/presentation/cli.rs +++ b/src/presentation/cli.rs @@ -343,6 +343,8 @@ pub enum ProfilesCommands { Show(ProfileShowArgs), /// Delete a profile. Delete(ProfileNameArgs), + /// Rename a profile (keeps its settings; follows the active selection). + Rename(ProfileRenameArgs), } #[derive(Debug, Args)] @@ -352,6 +354,16 @@ pub struct ProfileNameArgs { pub name: String, } +#[derive(Debug, Args)] +pub struct ProfileRenameArgs { + /// Current profile name. + #[arg(value_parser = parse_profile_value)] + pub old_name: String, + /// New profile name. + #[arg(value_parser = parse_profile_value)] + pub new_name: String, +} + #[derive(Debug, Args)] pub struct ProfileShowArgs { /// Profile name (defaults to the active profile). @@ -1013,6 +1025,32 @@ mod tests { } } + #[test] + fn profiles_rename_requires_two_valid_names() { + let cli = parse(&["graylog-cli", "profiles", "rename", "staging", "prod"]) + .expect("profiles rename should parse"); + + match cli.command { + Commands::Profiles { command } => match command { + ProfilesCommands::Rename(args) => { + assert_eq!(args.old_name, "staging"); + assert_eq!(args.new_name, "prod"); + } + _ => panic!("expected Rename subcommand"), + }, + _ => panic!("expected Profiles command"), + } + + assert!( + parse(&["graylog-cli", "profiles", "rename", "not valid", "prod"]).is_err(), + "profiles rename with invalid old name should fail" + ); + assert!( + parse(&["graylog-cli", "profiles", "rename", "staging"]).is_err(), + "profiles rename without new name should fail" + ); + } + #[test] fn profiles_delete_requires_name() { let cli = parse(&["graylog-cli", "profiles", "delete", "prod"]) From 68f526c67c5691d95cf63926eff8e4883b100cd8 Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Tue, 15 Sep 2026 18:03:02 +0200 Subject: [PATCH 08/10] docs: correct config paths per OS in skill (macOS uses Application Support) --- skills/graylog-cli/SKILL.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/skills/graylog-cli/SKILL.md b/skills/graylog-cli/SKILL.md index 203d12a..0ef5493 100644 --- a/skills/graylog-cli/SKILL.md +++ b/skills/graylog-cli/SKILL.md @@ -36,8 +36,9 @@ Config is stored at: | Condition | Path | | ------------------------ | ------------------------------------------ | -| `XDG_CONFIG_HOME` is set | `$XDG_CONFIG_HOME/graylog-cli/config.toml` | -| Unix default | `$HOME/.config/graylog-cli/config.toml` | +| `XDG_CONFIG_HOME` is set (Linux) | `$XDG_CONFIG_HOME/graylog-cli/config.toml` | +| macOS default | `~/Library/Application Support/graylog-cli/config.toml` | +| Linux default | `$HOME/.config/graylog-cli/config.toml` | | Windows default | `%APPDATA%\graylog-cli\config.toml` | On Unix, directory permissions are `0700` and file permissions are `0600`. On Windows, NTFS ACLs inherit from the parent directory — no explicit permission hardening is applied. From d09a8b1ae035ff8dd23bc2811a62e07257b7f3dc Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Tue, 15 Sep 2026 18:13:34 +0200 Subject: [PATCH 09/10] fix: address CI failures and Devin review findings - nix build: set doCheck=false on crane builds; the ping integration test needs loopback networking, which the nix sandbox blocks (suite runs with network in the CI test job) - cargo-deny: ignore RUSTSEC-2026-0173 (proc-macro-error2, unmaintained transitive via latest tabled_derive, compile-time only) - profiles: invalidate per-profile fields cache on re-auth so a replaced profile cannot serve the previous server field list - profiles: reject case-insensitive duplicate names on auth and rename (cache files live on case-insensitive filesystems) - treefmt: prettier realignment of skill docs table --- README.md | 2 +- deny.toml | 5 +- flake.nix | 4 ++ skills/graylog-cli/SKILL.md | 14 ++--- src/application/ports/cache_store.rs | 4 ++ src/application/service.rs | 87 ++++++++++++++++++++++++++++ src/application/test_support.rs | 8 +++ src/infrastructure/config_store.rs | 13 +++++ 8 files changed, 128 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 6384557..0c4fb4f 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ graylog-cli profiles rename staging prod # rename a profile graylog-cli profiles delete staging # remove a profile ``` -Deleting the active profile clears the active selection; the CLI then falls back to the first remaining profile. Deleting the last profile returns the CLI to its not-configured state. Profile names must start with an ASCII letter or digit and may only contain ASCII letters, digits, `.`, `_`, and `-`. +Deleting the active profile clears the active selection; the CLI then falls back to the first remaining profile. Deleting the last profile returns the CLI to its not-configured state. Profile names must start with an ASCII letter or digit and may only contain ASCII letters, digits, `.`, `_`, and `-`. Names that differ only by case from an existing profile are rejected, since the cache files live on filesystems that may be case-insensitive. The fields cache is scoped per profile (`fields-` cache files), so switching profiles never serves stale field lists from another instance. diff --git a/deny.toml b/deny.toml index 0e0a438..128f750 100644 --- a/deny.toml +++ b/deny.toml @@ -1,6 +1,9 @@ [advisories] yanked = "deny" -ignore = [] +# RUSTSEC-2026-0173: proc-macro-error2 is unmaintained. It arrives +# transitively via tabled_derive (already at latest) and runs only at +# compile time inside derive macros, so the exposure is accepted. +ignore = ["RUSTSEC-2026-0173"] [bans] multiple-versions = "warn" diff --git a/flake.nix b/flake.nix index bb3c2ba..5dbbe5d 100644 --- a/flake.nix +++ b/flake.nix @@ -63,6 +63,10 @@ inherit pname version; src = craneLib.cleanCargoSource self; strictDeps = true; + # Integration tests need loopback networking (the ping test serves + # a local HTTP server), which the nix sandbox blocks. The suite + # runs with network in the CI test job instead. + doCheck = false; }; # Dependencies are built once and reused by the package build, so a diff --git a/skills/graylog-cli/SKILL.md b/skills/graylog-cli/SKILL.md index 0ef5493..1bf629b 100644 --- a/skills/graylog-cli/SKILL.md +++ b/skills/graylog-cli/SKILL.md @@ -34,12 +34,12 @@ graylog-cli auth --url --token Config is stored at: -| Condition | Path | -| ------------------------ | ------------------------------------------ | -| `XDG_CONFIG_HOME` is set (Linux) | `$XDG_CONFIG_HOME/graylog-cli/config.toml` | -| macOS default | `~/Library/Application Support/graylog-cli/config.toml` | -| Linux default | `$HOME/.config/graylog-cli/config.toml` | -| Windows default | `%APPDATA%\graylog-cli\config.toml` | +| Condition | Path | +| -------------------------------- | ------------------------------------------------------- | +| `XDG_CONFIG_HOME` is set (Linux) | `$XDG_CONFIG_HOME/graylog-cli/config.toml` | +| macOS default | `~/Library/Application Support/graylog-cli/config.toml` | +| Linux default | `$HOME/.config/graylog-cli/config.toml` | +| Windows default | `%APPDATA%\graylog-cli\config.toml` | On Unix, directory permissions are `0700` and file permissions are `0600`. On Windows, NTFS ACLs inherit from the parent directory — no explicit permission hardening is applied. @@ -72,7 +72,7 @@ graylog-cli profiles rename old new # rename a profile (follows the active se graylog-cli profiles delete staging # remove a profile ``` -Every command runs against the active profile. Use the global `--profile` flag (or `GRAYLOG_PROFILE` env var) to target another profile for one invocation without switching: `graylog-cli --profile staging search 'level:ERROR'`. `ping` reports which profile it used (`profile`) and which are available (`available_profiles`). Profile names must start with an ASCII letter or digit and may only contain ASCII letters, digits, `.`, `_`, and `-`. +Every command runs against the active profile. Use the global `--profile` flag (or `GRAYLOG_PROFILE` env var) to target another profile for one invocation without switching: `graylog-cli --profile staging search 'level:ERROR'`. `ping` reports which profile it used (`profile`) and which are available (`available_profiles`). Profile names must start with an ASCII letter or digit and may only contain ASCII letters, digits, `.`, `_`, and `-`. Names that differ only by case from an existing profile are rejected. ## Graylog Query Language diff --git a/src/application/ports/cache_store.rs b/src/application/ports/cache_store.rs index fbd076f..58c9cb7 100644 --- a/src/application/ports/cache_store.rs +++ b/src/application/ports/cache_store.rs @@ -16,4 +16,8 @@ pub trait CacheStore: Send + Sync { async fn get_serialized(&self, key: &str) -> exn::Result, CacheError>; async fn save_serialized(&self, key: String, data: String) -> exn::Result<(), CacheError>; + + /// Drops a cached entry; missing keys are not an error. Used to + /// invalidate per-profile caches when a profile is replaced. + async fn remove_serialized(&self, key: &str) -> exn::Result<(), CacheError>; } diff --git a/src/application/service.rs b/src/application/service.rs index bd36afe..ea8e862 100644 --- a/src/application/service.rs +++ b/src/application/service.rs @@ -92,6 +92,15 @@ impl ApplicationService { let (mut profiles, updater) = existing .map(|config| (config.profiles, config.updater)) .unwrap_or_default(); + if let Some(conflict) = case_conflict(&profiles, &profile_name) { + return Err(CliError::Validation(ValidationError::InvalidValue { + field: "profile", + message: format!( + "profile `{profile_name}` conflicts with existing profile `{conflict}` (names are case-insensitive on some filesystems)" + ), + }) + .into()); + } profiles.insert( profile_name.clone(), GraylogConfig::new( @@ -110,6 +119,13 @@ impl ApplicationService { .await .or_raise(|| CliError::Config("failed to persist config".to_string()))?; + // Re-auth may point the profile at a different server; drop its + // fields cache so the next command cannot serve the old list. + let _ = self + .fields_cache_store + .remove_serialized(&fields_cache_key(&profile_name)) + .await; + Ok(AuthStatus::ok(base_url.to_string(), profile_name)) } @@ -244,6 +260,15 @@ impl ApplicationService { let mut config = self.load_config().await?; let graylog = self.require_profile(&config, old_name)?; + if let Some(conflict) = case_conflict(&config.profiles, new_name) { + return Err(CliError::Validation(ValidationError::InvalidValue { + field: "profile", + message: format!( + "profile `{new_name}` conflicts with existing profile `{conflict}` (names are case-insensitive on some filesystems)" + ), + }) + .into()); + } if config.profiles.contains_key(new_name) { let available = config .profiles @@ -888,6 +913,17 @@ fn fields_cache_key(profile: &str) -> String { format!("fields-{profile}") } +/// Existing profile name that differs from `name` only by case, if any. +/// Profile names are case-sensitive map keys, but the fields cache files +/// live on filesystems that may not be (default macOS/Windows), so +/// case-only distinct names would silently share one cache file. +fn case_conflict(profiles: &BTreeMap, name: &str) -> Option { + profiles + .keys() + .find(|key| key.eq_ignore_ascii_case(name) && key.as_str() != name) + .cloned() +} + fn apply_grouping(mut status: MessageSearchStatus, group_by: &str) -> MessageSearchStatus { status.grouped_by = Some(group_by.to_string()); status.groups = Some(build_search_groups(&status.messages, group_by)); @@ -2731,6 +2767,57 @@ mod tests { assert_profile_validation_error(error, "profile names must"); } + #[tokio::test] + async fn authenticate_invalidates_profile_fields_cache() { + let cache_store = FakeCacheStore::default(); + cache_store.insert("fields-default", "{\"stale\":true}".to_string()); + let (service, _, cache_store) = service_with_gateway( + FakeConfigStore::empty(), + cache_store, + FakeGraylogGateway::new(), + ); + service + .authenticate( + Url::parse("http://localhost:9000").expect("test URL should parse"), + secrecy::SecretString::new("test-token".to_owned().into()), + ) + .await + .expect("authentication should succeed"); + assert!(cache_store.get("fields-default").is_none()); + } + + #[tokio::test] + async fn authenticate_rejects_case_conflicting_profile_name() { + let (service, _, _, _) = service_with_gateway_and_profile( + FakeConfigStore::new(multi_profile_config()), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + Some("ALPHA"), + ); + let error = service + .authenticate( + Url::parse("http://localhost:9000").expect("test URL should parse"), + secrecy::SecretString::new("test-token".to_owned().into()), + ) + .await + .expect_err("case-conflicting profile should fail"); + assert_profile_validation_error(error, "conflicts with existing profile `alpha`"); + } + + #[tokio::test] + async fn profiles_rename_rejects_case_conflicting_target() { + let (service, _, _) = service_with_gateway( + FakeConfigStore::new(multi_profile_config()), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + ); + let error = service + .profiles_rename("beta", "ALPHA") + .await + .expect_err("case-conflicting target should fail"); + assert_profile_validation_error(error, "conflicts with existing profile `alpha`"); + } + #[tokio::test] async fn profiles_delete_inactive_profile_keeps_active() { let config_store = FakeConfigStore::new(multi_profile_config()); diff --git a/src/application/test_support.rs b/src/application/test_support.rs index 0a5b09d..308aeb2 100644 --- a/src/application/test_support.rs +++ b/src/application/test_support.rs @@ -47,5 +47,13 @@ pub(crate) mod fakes { .insert(key, data); Ok(()) } + + async fn remove_serialized(&self, key: &str) -> exn::Result<(), CacheError> { + self.storage + .lock() + .expect("cache mutex should not be poisoned") + .remove(key); + Ok(()) + } } } diff --git a/src/infrastructure/config_store.rs b/src/infrastructure/config_store.rs index 40c0a10..036d42b 100644 --- a/src/infrastructure/config_store.rs +++ b/src/infrastructure/config_store.rs @@ -166,6 +166,19 @@ impl CacheStore for FileConfigStore { .map_err(|error| CacheError::StoreUnavailable(format!("failed to write cache: {error}")))? .map_err(Into::into) } + + async fn remove_serialized(&self, key: &str) -> exn::Result<(), CacheError> { + let cache_path = Self::cache_path_for_key(key)?; + + task::spawn_blocking(move || match std::fs::remove_file(&cache_path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(CacheError::OperationFailure(error.to_string())), + }) + .await + .map_err(|error| CacheError::StoreUnavailable(format!("failed to clear cache: {error}")))? + .map_err(Into::into) + } } fn write_config_atomically(config_path: &Path, serialized: &str) -> Result<(), ConfigError> { From 82efea09bf0332730684ef43f6e01ca3efdc8bb6 Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Tue, 15 Sep 2026 18:33:50 +0200 Subject: [PATCH 10/10] fix: address Copilot review findings - nix builds: disable checks for the Windows cross package too; integration tests need loopback, unavailable in the sandbox - config load: reject profile keys that fail name validation so hand-edited keys cannot reach cache file paths - profiles list/show: validate a --profile override instead of reporting a phantom active profile - tests: set APPDATA in the harness so Windows resolves the temp config dir; AGENTS.md per-profile cache key doc --- AGENTS.md | 2 +- flake.nix | 3 +++ src/application/service.rs | 38 ++++++++++++++++++++++++++++++ src/infrastructure/config_store.rs | 28 +++++++++++++++++++++- tests/cli_integration.rs | 2 ++ 5 files changed, 71 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 55b54df..dcfc959 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,7 @@ graylog-cli/ ## UNIQUE STYLES - `lib.rs` is 4 lines; `main.rs` wires `Arc` twice (config + cache). -- Fields cache single global key `"fields"` with TTL 300s. +- Fields cache is per profile (`fields-.json` next to `config.toml`) with TTL 300s. - Release patches `Cargo.toml` version from tag; `publish=false`, ships binaries only. ## COMMANDS diff --git a/flake.nix b/flake.nix index 5dbbe5d..b64930a 100644 --- a/flake.nix +++ b/flake.nix @@ -102,6 +102,9 @@ inherit pname version; src = self; cargoLock.lockFile = ./Cargo.lock; + # Same reason as crane above: the ping integration test needs + # loopback networking, unavailable in the nix sandbox. + doCheck = false; cargoBuildTarget = windowsTarget; depsBuildBuild = lib.optionals pkgs.stdenv.isDarwin [ pkgs.libiconv diff --git a/src/application/service.rs b/src/application/service.rs index ea8e862..129b4e5 100644 --- a/src/application/service.rs +++ b/src/application/service.rs @@ -137,6 +137,11 @@ impl ApplicationService { .await .or_raise(|| CliError::Config("failed to load runtime config".to_string()))? .unwrap_or_default(); + // A mistyped --profile/GRAYLOG_PROFILE must fail here too, not + // surface as a phantom active profile with no summaries. + if let Some(override_name) = &self.profile_override { + self.require_profile(&config, override_name)?; + } let active = self.effective_active_profile(&config); Ok(ProfilesStatus { @@ -155,6 +160,9 @@ impl ApplicationService { /// Shows a single profile; `name` defaults to the resolved active profile. pub async fn profiles_show(&self, name: Option<&str>) -> exn::Result { let config = self.load_config().await?; + if let Some(override_name) = &self.profile_override { + self.require_profile(&config, override_name)?; + } let (name, graylog) = match name { Some(name) => (name.to_string(), self.require_profile(&config, name)?), None => self.select_profile(&config)?, @@ -2630,6 +2638,36 @@ mod tests { assert_profile_validation_error(error, "unknown profile `gamma`"); } + #[tokio::test] + async fn profiles_list_rejects_unknown_override() { + let (service, _, _, _) = service_with_gateway_and_profile( + FakeConfigStore::new(multi_profile_config()), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + Some("gamma"), + ); + let error = service + .profiles_list() + .await + .expect_err("unknown override should fail"); + assert_profile_validation_error(error, "unknown profile `gamma`"); + } + + #[tokio::test] + async fn profiles_show_named_rejects_unknown_override() { + let (service, _, _, _) = service_with_gateway_and_profile( + FakeConfigStore::new(multi_profile_config()), + FakeCacheStore::default(), + FakeGraylogGateway::new(), + Some("gamma"), + ); + let error = service + .profiles_show(Some("alpha")) + .await + .expect_err("unknown override should fail"); + assert_profile_validation_error(error, "unknown profile `gamma`"); + } + #[tokio::test] async fn profiles_use_switches_active_profile() { let config_store = FakeConfigStore::new(multi_profile_config()); diff --git a/src/infrastructure/config_store.rs b/src/infrastructure/config_store.rs index 036d42b..6abd95b 100644 --- a/src/infrastructure/config_store.rs +++ b/src/infrastructure/config_store.rs @@ -10,7 +10,9 @@ use tokio::task; use crate::application::ports::cache_store::{CacheError, CacheStore}; use crate::application::ports::config_store::{ConfigError, ConfigStore}; -use crate::domain::config::{Config, DEFAULT_PROFILE_NAME, GraylogConfig, UpdaterConfig}; +use crate::domain::config::{ + Config, DEFAULT_PROFILE_NAME, GraylogConfig, UpdaterConfig, validate_profile_name, +}; /// On-disk shape of `config.toml`, supporting both the current profile-based /// format and the legacy single-instance `[graylog]` format. @@ -53,6 +55,17 @@ fn parse_config_file(contents: &str) -> Result { .or_else(|| profiles.keys().next().cloned()) }); + // Profile names end up in cache file names, so reject hand-edited keys + // that could not have been created through the CLI (e.g. path + // separators) instead of letting them reach the filesystem. + for name in profiles.keys() { + if let Err(message) = validate_profile_name(name) { + return Err(ConfigError::InvalidFormat(format!( + "invalid profile name `{name}`: {message}" + ))); + } + } + Ok(Config { profiles, active_profile, @@ -338,4 +351,17 @@ mod tests { assert!(error.to_string().contains("failed to parse config")); } + + #[test] + fn profile_key_with_path_separators_is_rejected() { + let contents = r#" + [profiles."../evil"] + url = "https://graylog.example.com" + token = "token" + "#; + + let error = parse_config_file(contents).expect_err("unsafe profile key should fail"); + + assert!(error.to_string().contains("invalid profile name `../evil`")); + } } diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index e832140..a381c86 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -46,6 +46,8 @@ impl TestEnv { cmd.args(args) .env("HOME", self.config_home()) .env("XDG_CONFIG_HOME", self.config_home()) + // dirs::config_dir() reads %APPDATA% on Windows, ignoring HOME. + .env("APPDATA", self.config_home()) .env("GRAYLOG_CLI_AUTO_UPDATE", "0") .env_remove("GRAYLOG_PROFILE") .env_remove("GRAYLOG_TOKEN");