diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..892a65e --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,43 @@ +name: Rust scaffold + +on: + push: + branches: [main] + paths: ["rust/**", "src/bcli/registry/standard_v2.json", ".github/workflows/rust.yml"] + pull_request: + paths: ["rust/**", "src/bcli/registry/standard_v2.json", ".github/workflows/rust.yml"] + +permissions: + contents: read + +jobs: + check: + name: build / clippy / test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Windows is a follow-up: golden fixtures still assume Unix paths. + os: [ubuntu-latest, macos-latest] + defaults: + run: + working-directory: rust + steps: + - name: Checkout + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + + # rustup on the hosted runners honours rust/rust-toolchain.toml. + - name: Toolchain components + run: rustup component add clippy rustfmt + + - name: fmt + run: cargo fmt --all --check + + - name: build + run: cargo build --workspace --locked + + - name: clippy + run: cargo clippy --workspace --all-targets --locked -- -D warnings + + - name: test + run: cargo test --workspace --locked diff --git a/rust/.gitattributes b/rust/.gitattributes new file mode 100644 index 0000000..8ebc082 --- /dev/null +++ b/rust/.gitattributes @@ -0,0 +1 @@ +crates/bcli-cli/tests/fixtures/** -text diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1 @@ +/target diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..10c0891 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,3200 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +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 = "bcli-cli" +version = "0.8.2" +dependencies = [ + "assert_cmd", + "bcli-core", + "clap", + "comfy-table", + "csv", + "difflib", + "indexmap", + "predicates", + "serde_json", + "tempfile", + "tokio", + "wiremock", +] + +[[package]] +name = "bcli-core" +version = "0.8.2" +dependencies = [ + "chrono", + "indexmap", + "keyring", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "toml", + "wiremock", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "comfy-table" +version = "7.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" +dependencies = [ + "crossterm", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "document-features", + "parking_lot", + "rustix", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "openssl", + "sha2", + "zeroize", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "openssl", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.15+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.8", + "serde", + "sha2", + "zbus", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.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.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +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", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 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", +] + +[[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.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.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.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.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.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.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.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 = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.8", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..bc68a38 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,48 @@ +[workspace] +resolver = "2" +members = ["crates/bcli-core", "crates/bcli-cli"] + +[workspace.package] +version = "0.8.2" +edition = "2021" +rust-version = "1.86" +license = "Apache-2.0" +repository = "https://github.com/igor-ctrl/bcli" +authors = ["Igor"] + +[workspace.dependencies] +bcli-core = { path = "crates/bcli-core", default-features = false } + +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } +clap = { version = "4.5", features = ["derive", "wrap_help"] } +comfy-table = "7" +csv = "1.3" +difflib = "0.4" +indexmap = { version = "2", features = ["serde"] } +keyring = { version = "3.6", features = ["apple-native", "windows-native", "sync-secret-service", "crypto-rust", "vendored"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "http2"] } +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["preserve_order"] } +thiserror = "2" +tokio = { version = "1", features = ["rt", "macros", "time"] } +toml = { version = "0.8", features = ["preserve_order"] } + +assert_cmd = "2" +predicates = "3" +tempfile = "3" +wiremock = "0.6" + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +all = { level = "warn", priority = -1 } + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = true + +# The profile that cargo-dist builds release artifacts with. +[profile.dist] +inherits = "release" diff --git a/rust/crates/bcli-cli/Cargo.toml b/rust/crates/bcli-cli/Cargo.toml new file mode 100644 index 0000000..efe6029 --- /dev/null +++ b/rust/crates/bcli-cli/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "bcli-cli" +description = "CLI for Microsoft Dynamics 365 Business Central APIs" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +publish = false + +[[bin]] +name = "bcli" +path = "src/main.rs" + +[features] +default = ["keyring"] +keyring = ["bcli-core/keyring"] + +[dependencies] +bcli-core.workspace = true +clap.workspace = true +comfy-table.workspace = true +csv.workspace = true +difflib.workspace = true +indexmap.workspace = true +serde_json.workspace = true +tokio.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +serde_json.workspace = true +tempfile.workspace = true +tokio.workspace = true +wiremock.workspace = true + +[lints] +workspace = true diff --git a/rust/crates/bcli-cli/src/cli.rs b/rust/crates/bcli-cli/src/cli.rs new file mode 100644 index 0000000..3ea38f9 --- /dev/null +++ b/rust/crates/bcli-cli/src/cli.rs @@ -0,0 +1,833 @@ +//! clap command tree mirroring the Typer app in `src/bcli_cli/app.py`. +//! +//! Every command, positional and option of the Python CLI is declared here, +//! including the ones whose handlers are not ported yet, so scripts and the +//! installer see the same surface. `tests/surface_parity.rs` diffs this tree +//! against a dump of the Python CLI. +//! +//! Root options are only accepted before the subcommand (as with Typer); +//! subcommands that take `-f/--format` or `-p/--profile` declare their own. + +// Args of not-yet-ported commands are parsed but unread, and these enums are +// built once per process, so variant size is irrelevant. +#![allow(dead_code, clippy::large_enum_variant)] + +use std::path::PathBuf; + +use clap::{ArgAction, Args, Parser, Subcommand}; + +const ABOUT: &str = "CLI for Microsoft Dynamics 365 Business Central APIs."; +const LONG_ABOUT: &str = "CLI for Microsoft Dynamics 365 Business Central APIs. + +Discovery (handy for AI agents driving bcli): + bcli endpoint search fuzzy-find an endpoint + bcli endpoint info -f json structured metadata + bcli endpoint fields discover real field names (don't guess) + --profile alone is enough — environment, company, and + client_id resolve from the profile. Pass -e only to override."; + +const ESCAPE_HATCH: &str = "escape hatch — registry resolves this automatically"; + +#[derive(Debug, Parser)] +#[command( + name = "bcli", + about = ABOUT, + long_about = LONG_ABOUT, + version = bcli_core::VERSION, + disable_version_flag = true, + disable_help_subcommand = true, + arg_required_else_help = true +)] +pub struct Cli { + #[command(flatten)] + pub global: GlobalArgs, + + #[command(subcommand)] + pub command: Command, +} + +#[derive(Debug, Clone, Args)] +pub struct GlobalArgs { + /// Connection profile name + #[arg(short = 'p', long)] + pub profile: Option, + /// Override environment name + #[arg(short = 'e', long)] + pub env: Option, + /// Override company ID + #[arg(short = 'c', long)] + pub company: Option, + /// Output format: table, markdown, json, csv, ndjson, raw (auto-detects for non-TTY/AI agents) + #[arg(short = 'f', long)] + pub format: Option, + /// Show resolved URLs and timing + #[arg(short = 'v', long)] + pub verbose: bool, + /// Show full HTTP request/response + #[arg(long)] + pub debug: bool, + /// Show what would execute + #[arg(long)] + pub dry_run: bool, + /// Suppress context banner + #[arg(short = 'q', long)] + pub quiet: bool, + /// Print version + #[arg(short = 'V', long, action = ArgAction::Version)] + version: Option, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Configuration management + #[command(subcommand, arg_required_else_help = true)] + Config(ConfigCmd), + /// Authentication + #[command(subcommand, arg_required_else_help = true)] + Auth(AuthCmd), + /// Environment discovery and selection + #[command(subcommand, arg_required_else_help = true)] + Env(EnvCmd), + /// Company discovery and selection + #[command(subcommand, arg_required_else_help = true)] + Company(CompanyCmd), + /// Endpoint discovery + #[command(subcommand, arg_required_else_help = true)] + Endpoint(EndpointCmd), + /// Custom API registry management + #[command(subcommand, arg_required_else_help = true)] + Registry(RegistryCmd), + /// Connection and endpoint testing + #[command(subcommand, arg_required_else_help = true)] + Test(TestCmd), + /// Batch operations from YAML files + #[command(subcommand, arg_required_else_help = true)] + Batch(BatchCmd), + /// Document-attachment workflows (two-phase /attachments upload) + #[command(subcommand, arg_required_else_help = true)] + Attach(AttachCmd), + /// Generate a per-user bcli skill bundle + #[command(subcommand, arg_required_else_help = true)] + Skill(SkillCmd), + /// Install reusable query/batch/fragment packs + #[command(subcommand, arg_required_else_help = true)] + Pack(PackCmd), + /// ETL pipeline (requires dlt) + #[command(subcommand, arg_required_else_help = true)] + Etl(EtlCmd), + /// PDF → batch.yaml via AI vision + #[command(subcommand, arg_required_else_help = true)] + Extract(ExtractCmd), + + /// Query records from an endpoint + Get(GetArgs), + /// Create a record + Post(PostArgs), + /// Update a record + Patch(PatchArgs), + /// Delete a record + Delete(DeleteArgs), + /// Invoke an OData v4 bound action on a record + Action(ActionArgs), + /// Run a saved query (no OData required) + Q(QueryArgs), + /// Print a redacted context bundle for an LLM + AiContext, + /// Ask an LLM oracle about your recent bcli context + Ask(AskArgs), + /// Diagnose your bcli install (self-rescue for team users) + Doctor(DoctorArgs), + /// Project the CLI surface + registry + profile as JSON for agents + Describe(DescribeArgs), +} + +impl Command { + /// Space-separated command path, as used in messages and telemetry. + pub fn path(&self) -> String { + let (group, sub) = match self { + Command::Config(c) => ("config", c.name()), + Command::Auth(c) => ("auth", c.name()), + Command::Env(c) => ("env", c.name()), + Command::Company(c) => ("company", c.name()), + Command::Endpoint(c) => ("endpoint", c.name()), + Command::Registry(c) => ("registry", c.name()), + Command::Test(c) => ("test", c.name()), + Command::Batch(c) => ("batch", c.name()), + Command::Attach(c) => ("attach", c.name()), + Command::Skill(c) => ("skill", c.name()), + Command::Pack(c) => ("pack", c.name()), + Command::Etl(c) => ("etl", c.name()), + Command::Extract(c) => ("extract", c.name()), + Command::Get(_) => ("get", ""), + Command::Post(_) => ("post", ""), + Command::Patch(_) => ("patch", ""), + Command::Delete(_) => ("delete", ""), + Command::Action(_) => ("action", ""), + Command::Q(_) => ("q", ""), + Command::AiContext => ("ai-context", ""), + Command::Ask(_) => ("ask", ""), + Command::Doctor(_) => ("doctor", ""), + Command::Describe(_) => ("describe", ""), + }; + if sub.is_empty() { + group.to_string() + } else { + format!("{group} {sub}") + } + } +} + +/// Subcommand name in kebab-case, derived from the variant's `Debug` name. +macro_rules! named { + ($ty:ty { $($variant:ident => $name:literal),+ $(,)? }) => { + impl $ty { + pub fn name(&self) -> &'static str { + match self { $(Self::$variant { .. } => $name),+ } + } + } + }; +} + +// ─── config ───────────────────────────────────────────────────────── + +#[derive(Debug, Subcommand)] +pub enum ConfigCmd { + /// Interactive first-run setup + Init { + #[arg(short = 'p', long)] + profile: Option, + #[arg(long)] + auth: Option, + #[arg(long)] + automation: bool, + #[arg(long)] + headless: bool, + #[arg(long)] + scoped: bool, + #[arg(long)] + category: Vec, + #[arg(long = "import")] + import_file: Option, + }, + /// Show the resolved configuration + Show, + /// Set a configuration value + Set { key: String, value: String }, + /// Set the default profile + Use { name: String }, + /// Print the config file path + Path, + /// Open the config file in $EDITOR + Edit, + /// Pull the latest team bundle for a profile + Refresh { + #[arg(short = 'p', long)] + profile: Option, + #[arg(long)] + url: Option, + #[arg(long)] + dry_run: bool, + #[arg(long)] + skip_verify: bool, + }, + /// Restore the previous bundle for a profile + Rollback { + #[arg(short = 'p', long)] + profile: Option, + }, + /// Show the currently-installed bundle's manifest + BundleStatus { + #[arg(short = 'p', long)] + profile: Option, + }, + /// Build a bundle tarball from a directory (admin) + MakeBundle { + source_dir: PathBuf, + #[arg(short = 'p', long, required = true)] + profile: String, + #[arg(long, required = true)] + version: String, + #[arg(long)] + publisher: Option, + #[arg(long)] + notes: Option, + #[arg(long)] + previous: Option, + #[arg(short = 'o', long)] + output: Option, + }, +} +named!(ConfigCmd { Init => "init", Show => "show", Set => "set", Use => "use", Path => "path", + Edit => "edit", Refresh => "refresh", Rollback => "rollback", BundleStatus => "bundle-status", + MakeBundle => "make-bundle" }); + +// ─── auth ─────────────────────────────────────────────────────────── + +#[derive(Debug, Subcommand)] +pub enum AuthCmd { + /// Sign in (browser, device_code, or client_credentials) + Login { + #[arg(short = 'm', long)] + method: Option, + #[arg(short = 'i', long)] + incognito: bool, + }, + /// Show token status for the active profile + Status, + /// Clear cached tokens for the active profile + Logout, + /// Save the client secret to the OS keychain + StoreSecret, + /// Remove the client secret from the OS keychain + DeleteSecret, +} +named!(AuthCmd { Login => "login", Status => "status", Logout => "logout", + StoreSecret => "store-secret", DeleteSecret => "delete-secret" }); + +// ─── env / company / endpoint / registry / test ───────────────────── + +#[derive(Debug, Subcommand)] +pub enum EnvCmd { + /// List available Business Central environments + List, + /// Set the default environment for the active profile + Use { name: String }, +} +named!(EnvCmd { List => "list", Use => "use" }); + +#[derive(Debug, Subcommand)] +pub enum CompanyCmd { + /// List all companies in the current environment + List(FormatArg), + /// Set the default company for the active profile + Use { + /// Company ID (GUID) or alias + company: String, + }, + /// Assign a nickname to a company for quick access + Alias { + name: String, + company_id: String, + #[arg(short = 'n', long = "name")] + display_name: Option, + }, + /// Show all company aliases for the active profile + Aliases, + /// Copy company aliases from another profile into the active profile + AliasesImport { + #[arg(long = "from", required = true)] + from_profile: String, + #[arg(long)] + overwrite: bool, + #[arg(long)] + dry_run: bool, + }, +} +named!(CompanyCmd { List => "list", Use => "use", Alias => "alias", Aliases => "aliases", + AliasesImport => "aliases-import" }); + +#[derive(Debug, Clone, Args)] +pub struct FormatArg { + /// Output format: table (default), json, markdown, csv, ndjson + #[arg(short = 'f', long)] + pub format: Option, +} + +#[derive(Debug, Subcommand)] +pub enum EndpointCmd { + /// List all known endpoints (standard + custom) + List { + /// Show only custom (imported) endpoints + #[arg(long)] + custom: bool, + /// Show only standard v2.0 endpoints + #[arg(long)] + standard: bool, + /// Filter by category + #[arg(long)] + category: Option, + #[command(flatten)] + format: FormatArg, + }, + /// Fuzzy search endpoints by name or description + Search { + /// Search term + query: String, + }, + /// Show detailed metadata for an endpoint + Info { + /// Entity set name + name: String, + #[command(flatten)] + format: FormatArg, + }, + /// Discover field names and types by fetching one record from the API + Fields { + /// Entity set name + name: String, + }, +} +named!(EndpointCmd { List => "list", Search => "search", Info => "info", Fields => "fields" }); + +#[derive(Debug, Subcommand)] +pub enum RegistryCmd { + /// Import custom API endpoints + Import { + #[arg(long)] + from_postman: Option, + #[arg(long)] + from_json: Option, + #[arg(long)] + from_metadata: bool, + #[arg(short = 'p', long)] + profile: Option, + }, + /// List registries + List, +} +named!(RegistryCmd { Import => "import", List => "list" }); + +#[derive(Debug, Subcommand)] +pub enum TestCmd { + /// Test API connectivity + Connection, + /// Test token acquisition + Auth, + /// Test a single endpoint + Endpoint { name: String }, +} +named!(TestCmd { Connection => "connection", Auth => "auth", Endpoint => "endpoint" }); + +// ─── batch / attach / skill / pack / etl / extract ────────────────── + +#[derive(Debug, Clone, Args)] +pub struct ResultArgs { + /// Write the JSON result envelope to this path + #[arg(long)] + pub result_out: Option, + /// Write the JSON result envelope to this file descriptor + #[arg(long)] + pub result_fd: Option, +} + +#[derive(Debug, Subcommand)] +pub enum BatchCmd { + /// Run a batch YAML file + Run { + file: PathBuf, + #[arg(long)] + dry_run: bool, + #[arg(short = 'o', long)] + output: Option, + #[arg(short = 'f', long)] + format: Option, + #[arg(long = "set")] + set: Vec, + #[arg(long)] + params: Option, + #[arg(short = 'y', long)] + yes: bool, + #[command(flatten)] + result: ResultArgs, + #[arg(long)] + progress_fd: Option, + }, + /// Per-step detail for one run + State { + run_id: String, + #[arg(short = 'f', long)] + format: Option, + }, + /// Recent runs, newest first + List { + #[arg(long)] + state: Option, + #[arg(long)] + limit: Option, + #[arg(short = 'f', long)] + format: Option, + }, + /// Undo a run (POST → DELETE only) + Rollback { + run_id: String, + #[arg(long)] + dry_run: bool, + #[arg(short = 'y', long)] + yes: bool, + }, +} +named!(BatchCmd { Run => "run", State => "state", List => "list", Rollback => "rollback" }); + +#[derive(Debug, Clone, Args)] +pub struct AttachCommon { + #[arg(long)] + pub file_name: Option, + #[arg(long)] + pub content_type: Option, + #[command(flatten)] + pub route: HiddenRouteArgs, + /// Use the standard v2.0 /attachments route instead of the registry + #[arg(long = "no-registry", visible_alias = "standard")] + pub no_registry: bool, + #[arg(short = 'f', long)] + pub format: Option, +} + +#[derive(Debug, Subcommand)] +pub enum AttachCmd { + /// Two-phase upload of a document to a parent record + Upload { + file_path: PathBuf, + #[arg(long, required = true)] + parent_id: String, + #[arg(long)] + parent_type: Option, + #[command(flatten)] + common: AttachCommon, + #[arg(short = 'y', long)] + yes: bool, + #[command(flatten)] + result: ResultArgs, + #[arg(long)] + idempotency_key: Option, + }, + /// Upload against a throwaway purchase invoice to verify the flow + Test { + file_path: PathBuf, + #[arg(long, required = true)] + vendor_id: String, + #[arg(long)] + invoice_date: Option, + #[command(flatten)] + common: AttachCommon, + }, +} +named!(AttachCmd { Upload => "upload", Test => "test" }); + +#[derive(Debug, Subcommand)] +pub enum SkillCmd { + /// Generate a per-user skill bundle + Init { + #[arg(long)] + profile: Option, + #[arg(long)] + target_skills_dir: Option, + #[arg(long)] + non_interactive: bool, + }, + /// Regenerate the skill bundle + Update { + #[arg(long)] + profile: Option, + #[arg(long)] + non_interactive: bool, + }, + /// Project the skill bundle into an agent's skills directory + Install { + #[arg(short = 't', long)] + target: Option, + #[arg(long)] + dry_run: bool, + }, +} +named!(SkillCmd { Init => "init", Update => "update", Install => "install" }); + +#[derive(Debug, Subcommand)] +pub enum PackCmd { + /// List available packs + List { + #[arg(short = 'p', long)] + profile: Option, + }, + /// Show a pack's contents + Info { + name: String, + #[arg(short = 'p', long)] + profile: Option, + }, + /// Install a pack + Install { + name: String, + #[arg(short = 'p', long)] + profile: Option, + #[arg(long)] + target: Option, + #[arg(long)] + dry_run: bool, + #[arg(long)] + replace_owned: bool, + #[arg(long)] + accept_conflicts: bool, + #[arg(short = 'y', long)] + yes: bool, + }, + /// Remove an installed pack + Uninstall { + name: String, + #[arg(short = 'p', long)] + profile: Option, + #[arg(short = 'y', long)] + yes: bool, + }, +} +named!(PackCmd { List => "list", Info => "info", Install => "install", Uninstall => "uninstall" }); + +#[derive(Debug, Subcommand)] +pub enum EtlCmd { + /// List entities available for sync + Entities { + #[arg(long)] + include_standard: bool, + }, + /// Run a sync pipeline + Sync { + #[arg(long)] + entities: Option, + #[arg(short = 'd', long)] + destination: Option, + #[arg(long)] + dataset: Option, + #[arg(long)] + pipeline: Option, + #[arg(long)] + full_refresh: bool, + #[arg(long)] + include_standard: bool, + #[arg(long)] + file_format: Option, + #[arg(long)] + stamper: Vec, + #[arg(long)] + polaris_uri: Option, + #[arg(long)] + polaris_warehouse: Option, + #[arg(long)] + polaris_credential: Option, + #[arg(long)] + polaris_namespace: Option, + }, +} +named!(EtlCmd { Entities => "entities", Sync => "sync" }); + +#[derive(Debug, Subcommand)] +pub enum ExtractCmd { + /// Extract a PDF into a batch.yaml + Run { + pdf_path: PathBuf, + #[arg(short = 's', long, required = true)] + schema: String, + #[arg(short = 'o', long)] + output: Option, + #[arg(long)] + sidecar: Option, + #[arg(long)] + overwrite: bool, + #[arg(long)] + progress_fd: Option, + }, + /// List extraction schemas + ListSchemas, +} +named!(ExtractCmd { Run => "run", ListSchemas => "list-schemas" }); + +// ─── top-level verbs ──────────────────────────────────────────────── + +#[derive(Debug, Clone, Args)] +pub struct RouteArgs { + #[arg(long, help = format!("Custom API publisher override ({ESCAPE_HATCH})"))] + pub publisher: Option, + #[arg(long, help = format!("Custom API group override ({ESCAPE_HATCH})"))] + pub group: Option, + #[arg(long, help = format!("Custom API version override ({ESCAPE_HATCH})"))] + pub version: Option, +} + +#[derive(Debug, Clone, Args)] +pub struct HiddenRouteArgs { + #[arg(long, hide = true)] + pub publisher: Option, + #[arg(long, hide = true)] + pub group: Option, + #[arg(long, hide = true)] + pub version: Option, +} + +#[derive(Debug, Clone, Args)] +pub struct WriteArgs { + /// Skip the read-only-profile warning prompt + #[arg(short = 'y', long)] + pub yes: bool, + #[command(flatten)] + pub result: ResultArgs, + /// IETF Idempotency-Key header; re-enables safe retries + #[arg(long)] + pub idempotency_key: Option, +} + +#[derive(Debug, Args)] +pub struct GetArgs { + /// Entity set name (e.g., 'customers', 'vendors') + pub endpoint: String, + /// Record ID for single-record GET + pub record_id: Option, + /// OData $filter expression + #[arg(long)] + pub filter: Option, + /// Comma-separated field names + #[arg(long)] + pub select: Option, + /// Comma-separated navigation properties + #[arg(long)] + pub expand: Option, + /// OData $orderby expression + #[arg(long)] + pub orderby: Option, + /// Max records to return + #[arg(long)] + pub top: Option, + /// Records to skip + #[arg(long)] + pub skip: Option, + /// Include total record count + #[arg(long)] + pub count: bool, + /// Follow pagination to get all records + #[arg(long = "all")] + pub all_pages: bool, + /// Write the record's media stream (raw bytes) to this path + #[arg(long)] + pub out: Option, + /// Media property to download + #[arg(long)] + pub media: Option, + /// Replace an existing --out file + #[arg(long)] + pub overwrite: bool, + #[arg(short = 'f', long)] + pub format: Option, + #[command(flatten)] + pub route: RouteArgs, +} + +#[derive(Debug, Args)] +pub struct PostArgs { + /// Entity set name + pub endpoint: String, + /// JSON data or @filename + #[arg(short = 'd', long, required = true)] + pub data: String, + #[arg(short = 'f', long)] + pub format: Option, + #[command(flatten)] + pub route: RouteArgs, + #[command(flatten)] + pub write: WriteArgs, +} + +#[derive(Debug, Args)] +pub struct PatchArgs { + /// Entity set name + pub endpoint: String, + /// Record ID to update + pub record_id: String, + /// JSON data or @filename + #[arg(short = 'd', long, required = true)] + pub data: String, + /// ETag for optimistic concurrency + #[arg(long, default_value = "*")] + pub etag: String, + #[arg(short = 'f', long)] + pub format: Option, + #[command(flatten)] + pub route: RouteArgs, + #[command(flatten)] + pub write: WriteArgs, +} + +#[derive(Debug, Args)] +pub struct DeleteArgs { + /// Entity set name + pub endpoint: String, + /// Record ID to delete + pub record_id: String, + /// ETag for optimistic concurrency + #[arg(long, default_value = "*")] + pub etag: String, + #[arg(short = 'f', long)] + pub format: Option, + #[command(flatten)] + pub route: RouteArgs, + #[command(flatten)] + pub write: WriteArgs, +} + +#[derive(Debug, Args)] +pub struct ActionArgs { + pub entity_set: String, + pub key: String, + pub action_name: String, + #[arg(short = 'd', long)] + pub data: Option, + #[arg(long)] + pub no_data: bool, + #[arg(short = 'n', long)] + pub namespace: Option, + #[arg(short = 'f', long)] + pub format: Option, + #[command(flatten)] + pub route: HiddenRouteArgs, + #[arg(long)] + pub out: Option, + #[arg(long)] + pub overwrite: bool, + #[command(flatten)] + pub write: WriteArgs, +} + +#[derive(Debug, Args)] +pub struct QueryArgs { + pub name: Option, + #[arg(num_args = 0..)] + pub params: Vec, + #[arg(long)] + pub show: bool, + #[arg(short = 'f', long)] + pub format: Option, +} + +#[derive(Debug, Args)] +pub struct AskArgs { + pub question: String, + #[arg(long)] + pub no_context: bool, + #[arg(long)] + pub attach: Vec, + #[arg(long)] + pub backend: Option, + #[arg(long)] + pub dry_run: bool, + #[arg(long)] + pub include_bodies: bool, + #[arg(long)] + pub include_debug: bool, + #[arg(long)] + pub max_tokens: Option, +} + +#[derive(Debug, Args)] +pub struct DoctorArgs { + #[arg(short = 'p', long)] + pub profile: Option, + #[arg(long)] + pub json: bool, + #[arg(long)] + pub skip_network: bool, +} + +#[derive(Debug, Args)] +pub struct DescribeArgs { + #[arg(num_args = 0..)] + pub command_path: Vec, + #[arg(short = 'f', long)] + pub format: Option, +} diff --git a/rust/crates/bcli-cli/src/commands/company.rs b/rust/crates/bcli-cli/src/commands/company.rs new file mode 100644 index 0000000..1cb6a4d --- /dev/null +++ b/rust/crates/bcli-cli/src/commands/company.rs @@ -0,0 +1,100 @@ +//! `bcli company list` (`bcli_cli.commands.company_cmd`). + +use bcli_core::client::Record; +use bcli_core::Result; +use serde_json::{json, Value}; + +use super::{print_context_banner, CmdResult}; +use crate::context::Context; +use crate::output::{format_output, new_table}; +use crate::Io; + +/// JSON shape: `[{"id", "name", "alias" (string|null), "is_default"}]`. +/// Stable contract — consumed by the bcli-mcp `bcli_company_list` tool. +pub async fn list(ctx: &mut Context<'_>, io: &mut Io<'_>, format: Option<&str>) -> CmdResult { + let fmt = format.unwrap_or(&ctx.format).to_string(); + if matches!(fmt.as_str(), "json" | "csv" | "ndjson" | "markdown" | "md") { + ctx.quiet = true; + } + print_context_banner(ctx, io)?; + + // The Python command reports any failure here as `Error: …` on stdout + // with exit 1, bypassing the exit-code taxonomy. Kept for parity. + let rows = match fetch_rows(ctx).await { + Ok(rows) => rows, + Err(e) => { + writeln!(io.out, "Error: {e}")?; + return Ok(1); + } + }; + + if fmt != "table" { + format_output(&rows, &fmt, io)?; + return Ok(0); + } + + let mut table = new_table( + ["#", "Alias", "Company Name", "Company ID"] + .map(String::from) + .to_vec(), + ); + for (i, row) in rows.iter().enumerate() { + let is_default = row["is_default"].as_bool().unwrap_or(false); + let mut name = row["name"].as_str().unwrap_or_default().to_string(); + let mut alias = row["alias"].as_str().unwrap_or_default().to_string(); + if is_default { + name.push_str(" ◄"); + if alias.is_empty() { + alias = "default".into(); + } + } + table.add_row(vec![ + (i + 1).to_string(), + alias, + name, + row["id"].as_str().unwrap_or_default().to_string(), + ]); + } + writeln!(io.out, "{table}")?; + writeln!(io.out, "{} company(ies)", rows.len())?; + if ctx.profile()?.companies.is_empty() { + writeln!( + io.out, + "\nTip: assign nicknames with 'bcli company alias '" + )?; + } + Ok(0) +} + +async fn fetch_rows(ctx: &mut Context<'_>) -> Result> { + let mut client = ctx.client()?; + let companies = client.list_companies().await?; + let profile = ctx.profile()?; + let default_id = profile.company_id.clone().unwrap_or_default(); + + Ok(companies + .iter() + .map(|company| { + let id = company + .get("id") + .and_then(Value::as_str) + .unwrap_or_default(); + let alias = profile + .companies + .iter() + .rev() + .find(|(_, c)| c.id == id) + .map(|(alias, _)| alias.clone()) + .filter(|a| !a.is_empty()); + let Value::Object(row) = json!({ + "id": id, + "name": company.get("name").and_then(Value::as_str).unwrap_or_default(), + "alias": alias, + "is_default": !default_id.is_empty() && id == default_id, + }) else { + unreachable!("json! object literal") + }; + row + }) + .collect()) +} diff --git a/rust/crates/bcli-cli/src/commands/endpoint.rs b/rust/crates/bcli-cli/src/commands/endpoint.rs new file mode 100644 index 0000000..e2b67cb --- /dev/null +++ b/rust/crates/bcli-cli/src/commands/endpoint.rs @@ -0,0 +1,191 @@ +//! `bcli endpoint list|search|info` (`bcli_cli.commands.endpoint_cmd`). + +use std::io::Write; + +use bcli_core::client::Record; +use bcli_core::pyjson; +use bcli_core::registry::{EndpointMetadata, Registry}; +use comfy_table::{ColumnConstraint, Width}; +use serde_json::{json, Value}; + +use super::CmdResult; +use crate::context::Context; +use crate::output::{format_output, new_table}; +use crate::Io; + +/// Stable JSON shape for an endpoint — consumed by bcli-mcp. +fn endpoint_to_record(ep: &EndpointMetadata) -> Record { + let Value::Object(record) = json!({ + "name": ep.entity_set_name, + "category": ep.category, + "custom": ep.is_custom(), + "supported_ops": ep.supports, + "key_field": ep.key_field, + "publisher": ep.api_publisher, + "group": ep.api_group, + "version": ep.api_version, + "description": ep.description, + "caution": ep.caution.as_str(), + }) else { + unreachable!("json! object literal") + }; + record +} + +pub fn list( + ctx: &mut Context<'_>, + io: &mut Io<'_>, + custom: bool, + standard: bool, + category: Option<&str>, + format: Option<&str>, +) -> CmdResult { + let registry = ctx.registry()?; + let mut endpoints = registry.list_all(custom, standard); + if let Some(category) = category { + let wanted = category.to_lowercase(); + endpoints.retain(|e| e.category.to_lowercase() == wanted); + } + + let fmt = format.unwrap_or(&ctx.format).to_string(); + if fmt != "table" { + let rows: Vec = endpoints.iter().map(|e| endpoint_to_record(e)).collect(); + format_output(&rows, &fmt, io)?; + return Ok(0); + } + + let mut table = new_table( + ["Entity", "Route", "Operations", "Category", "Description"] + .map(String::from) + .to_vec(), + ); + if let Some(col) = table.column_mut(4) { + col.set_constraint(ColumnConstraint::UpperBoundary(Width::Fixed(50))); + } + for ep in &endpoints { + table.add_row(vec![ + ep.entity_set_name.clone(), + ep.route_display(), + ep.supports.join(", "), + ep.category.clone(), + ep.description.chars().take(50).collect(), + ]); + } + writeln!(io.out, "{table}")?; + writeln!( + io.out, + "{} endpoint(s) ({} standard, {} custom)", + endpoints.len(), + registry.standard_count(), + registry.custom_count() + )?; + Ok(0) +} + +pub fn search(ctx: &mut Context<'_>, io: &mut Io<'_>, query: &str) -> CmdResult { + let registry = ctx.registry()?; + let results = registry.search(query); + if results.is_empty() { + writeln!(io.out, "No endpoints matching '{query}'")?; + return Ok(0); + } + let mut table = new_table( + ["Entity", "Route", "Description"] + .map(String::from) + .to_vec(), + ); + if let Some(col) = table.column_mut(2) { + col.set_constraint(ColumnConstraint::UpperBoundary(Width::Fixed(60))); + } + for ep in results.iter().take(20) { + table.add_row(vec![ + ep.entity_set_name.clone(), + ep.route_display(), + ep.description.clone(), + ]); + } + writeln!(io.out, "{table}")?; + Ok(0) +} + +pub fn info(ctx: &mut Context<'_>, io: &mut Io<'_>, name: &str, format: Option<&str>) -> CmdResult { + let registry = ctx.registry()?; + let Some(ep) = registry.get(name) else { + return not_found(®istry, io, name); + }; + + if format.unwrap_or(&ctx.format) == "json" { + let mut payload = endpoint_to_record(ep); + payload.insert("entity_name".into(), json!(ep.entity_name)); + payload.insert( + "fields".into(), + Value::Array( + ep.field_names + .iter() + .map(|f| json!({"name": f, "type": ""})) + .collect(), + ), + ); + payload.insert( + "fields_discovered".into(), + json!(!ep.field_names.is_empty()), + ); + payload.insert("source_table".into(), json!(ep.source_table)); + payload.insert("page_number".into(), json!(ep.page_number)); + writeln!( + io.out, + "{}", + pyjson::dumps(&Value::Object(payload), Some(2)) + )?; + return Ok(0); + } + + let out = &mut io.out; + writeln!(out, "{}", ep.entity_set_name)?; + writeln!(out, " Entity name: {}", ep.entity_name)?; + writeln!(out, " Route: {}", ep.route_display())?; + writeln!(out, " Operations: {}", ep.supports.join(", "))?; + writeln!(out, " Key field: {}", ep.key_field)?; + writeln!(out, " Category: {}", ep.category)?; + writeln!(out, " Caution: {}", ep.caution.as_str())?; + writeln!( + out, + " Custom: {}", + if ep.is_custom() { + "Yes" + } else { + "No (standard v2.0)" + } + )?; + for (label, value) in [ + ("Description: ", &ep.description), + ("Source table:", &ep.source_table), + ("Page number: ", &ep.page_number), + ] { + if !value.is_empty() { + writeln!(out, " {label} {value}")?; + } + } + if !ep.field_names.is_empty() { + writeln!(out, " Fields: {}", ep.field_names.join(", "))?; + } + Ok(0) +} + +fn not_found(registry: &Registry, io: &mut Io<'_>, name: &str) -> CmdResult { + writeln!(io.err, "Endpoint '{name}' not found.")?; + let suggestions: Vec<&str> = registry + .search(name) + .into_iter() + .take(3) + .map(|m| m.entity_set_name.as_str()) + .collect(); + if !suggestions.is_empty() { + writeln!( + io.err, + "{}", + io.dim(&format!("Did you mean: {}?", suggestions.join(", "))) + )?; + } + Ok(1) +} diff --git a/rust/crates/bcli-cli/src/commands/mod.rs b/rust/crates/bcli-cli/src/commands/mod.rs new file mode 100644 index 0000000..16a01db --- /dev/null +++ b/rust/crates/bcli-cli/src/commands/mod.rs @@ -0,0 +1,84 @@ +//! Command dispatch. Ported commands have a module; everything else returns +//! a "not yet ported" error (exit 1) so a mixed install fails loudly rather +//! than silently doing less than the Python CLI. + +mod company; +mod endpoint; + +use std::io; + +use bcli_core::BcliError; + +use crate::cli::{Command, CompanyCmd, EndpointCmd}; +use crate::context::Context; +use crate::Io; + +#[derive(Debug)] +pub enum CmdError { + Bcli(BcliError), + Io(io::Error), +} + +impl From for CmdError { + fn from(e: BcliError) -> Self { + CmdError::Bcli(e) + } +} + +impl From for CmdError { + fn from(e: io::Error) -> Self { + CmdError::Io(e) + } +} + +/// Exit code on success paths; commands may exit non-zero without an error +/// (e.g. `endpoint info` on an unknown name prints its own message, exit 1). +pub type CmdResult = Result; + +pub async fn dispatch(command: &Command, ctx: &mut Context<'_>, io: &mut Io<'_>) -> CmdResult { + match command { + Command::Endpoint(EndpointCmd::List { + custom, + standard, + category, + format, + }) => endpoint::list( + ctx, + io, + *custom, + *standard, + category.as_deref(), + format.format.as_deref(), + ), + Command::Endpoint(EndpointCmd::Search { query }) => endpoint::search(ctx, io, query), + Command::Endpoint(EndpointCmd::Info { name, format }) => { + endpoint::info(ctx, io, name, format.format.as_deref()) + } + Command::Company(CompanyCmd::List(args)) => { + company::list(ctx, io, args.format.as_deref()).await + } + other => Err(BcliError::not_ported(&other.path()).into()), + } +} + +/// `[profile: X | env: Y | company: Z]` on stderr (`print_context_banner`). +pub fn print_context_banner(ctx: &mut Context<'_>, io: &mut Io<'_>) -> Result<(), CmdError> { + if ctx.quiet { + return Ok(()); + } + let profile = ctx.profile()?; + let mut parts = vec![ + format!("profile: {}", ctx.active_profile_name()?), + format!("env: {}", profile.environment), + ]; + match (&profile.company_name, &profile.company_id) { + (Some(name), _) if !name.is_empty() => parts.push(format!("company: {name}")), + (_, Some(id)) if !id.is_empty() => parts.push(format!( + "company: {}...", + id.chars().take(8).collect::() + )), + _ => {} + } + writeln!(io.err, "{}", io.dim(&format!("[{}]", parts.join(" | "))))?; + Ok(()) +} diff --git a/rust/crates/bcli-cli/src/context.rs b/rust/crates/bcli-cli/src/context.rs new file mode 100644 index 0000000..87c3179 --- /dev/null +++ b/rust/crates/bcli-cli/src/context.rs @@ -0,0 +1,114 @@ +//! Per-invocation state (`bcli_cli._state.CLIState`): global flags plus the +//! lazily loaded config, so commands that never touch config (`--version`, +//! `--help`) never read it. + +use std::time::Duration; + +use bcli_core::client::BcClient; +use bcli_core::config::{self, Config, Profile}; +use bcli_core::paths::Paths; +use bcli_core::registry::{Registry, RegistryOptions}; +use bcli_core::Result; + +use crate::cli::GlobalArgs; +use crate::output::detect_default_format; +use crate::Env; + +pub struct Context<'a> { + pub global: &'a GlobalArgs, + pub env: &'a Env, + pub paths: Paths, + /// `--format`, else auto-detected. + pub format: String, + /// Suppresses the context banner; implied by machine-readable formats. + pub quiet: bool, + config: Option, + warnings: Vec, +} + +impl<'a> Context<'a> { + pub fn new(global: &'a GlobalArgs, env: &'a Env, stdout_is_tty: bool) -> Self { + let format = global + .format + .clone() + .unwrap_or_else(|| detect_default_format(&*env.vars, stdout_is_tty)); + let quiet = global.quiet || matches!(format.as_str(), "json" | "csv" | "ndjson" | "raw"); + Self { + global, + env, + paths: Paths::from_home(&env.home), + format, + quiet, + config: None, + warnings: Vec::new(), + } + } + + pub fn config(&mut self) -> Result<&Config> { + if self.config.is_none() { + let loaded = config::load(&self.paths, self.env.cwd.as_deref(), &*self.env.vars)?; + self.warnings.extend(loaded.warnings); + self.config = Some(loaded.config); + } + Ok(self.config.get_or_insert_with(Config::default)) + } + + pub fn take_warnings(&mut self) -> Vec { + std::mem::take(&mut self.warnings) + } + + /// Profile names, if config has been loaded (for "Did you mean" hints). + pub fn profile_names(&self) -> Option> { + self.config + .as_ref() + .map(|c| c.profiles.keys().cloned().collect()) + } + + pub fn active_profile_name(&mut self) -> Result { + match &self.global.profile { + Some(name) => Ok(name.clone()), + None => Ok(self.config()?.defaults.profile.clone()), + } + } + + /// The profile as written in config, without `--env` / `--company`. + pub fn raw_profile(&mut self) -> Result { + let name = self.global.profile.clone(); + Ok(self.config()?.get_profile(name.as_deref())?.clone()) + } + + /// The profile with `--env` / `--company` applied. + pub fn profile(&mut self) -> Result { + let (env, company) = (self.global.env.clone(), self.global.company.clone()); + self.raw_profile()? + .with_overrides(env.as_deref(), company.as_deref()) + } + + pub fn registry(&mut self) -> Result { + let name = self.active_profile_name()?; + let profile = self.raw_profile()?; + Registry::load( + Some(&self.paths.custom_registry_file(&name)), + &RegistryOptions { + disable_standard: profile.disable_standard_api, + allowed_categories: profile.allowed_categories, + allowed_endpoints: profile.allowed_endpoints, + }, + ) + } + + /// Client bound to the overridden profile (`CLIState.make_async_client`). + pub fn client(&mut self) -> Result> { + let profile = self.profile()?; + let timeout = Duration::from_secs(self.config()?.defaults.timeout); + let env = self.env; + BcClient::new( + profile, + &self.paths, + &env.urls, + &*env.secrets, + &*env.vars, + timeout, + ) + } +} diff --git a/rust/crates/bcli-cli/src/errors.rs b/rust/crates/bcli-cli/src/errors.rs new file mode 100644 index 0000000..16f4ba5 --- /dev/null +++ b/rust/crates/bcli-cli/src/errors.rs @@ -0,0 +1,71 @@ +//! User-facing error text with a remediation hint (`bcli_cli._error_handler`). + +use bcli_core::{BcliError, ErrorKind}; + +pub fn format_for_cli( + err: &BcliError, + active_profile: Option<&str>, + available: Option>, +) -> String { + let base = err.to_string(); + let mut extras = Vec::new(); + match err.kind { + ErrorKind::Auth if !base.contains("bcli auth login") => extras.push(match active_profile { + Some(p) => format!("Run 'bcli auth login --profile {p}' to re-authenticate."), + None => "Run 'bcli auth login' to authenticate.".to_string(), + }), + ErrorKind::Config => { + if !base.contains("bcli config init") { + extras.push("Run 'bcli config init' to create a profile.".to_string()); + } + if let (Some(active), Some(available)) = (active_profile, available) { + if !available.iter().any(|p| p == active) { + let candidates: Vec<&str> = available.iter().map(String::as_str).collect(); + let matches = difflib::get_close_matches(active, candidates, 3, 0.5); + if !matches.is_empty() { + extras.push(format!("Did you mean: {}?", matches.join(", "))); + } + } + } + } + ErrorKind::Registry if !base.contains("Did you mean") && !base.contains("bcli registry import") => { + extras.push( + "Run 'bcli registry import --from-metadata ' or 'bcli registry import \ + --from-postman ' to register the endpoint." + .to_string(), + ) + } + _ => {} + } + if extras.is_empty() { + base + } else { + format!("{base}\n {}", extras.join("\n ")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auth_errors_get_a_login_hint() { + let msg = format_for_cli(&BcliError::auth("expired"), Some("prod"), None); + assert_eq!( + msg, + "expired\n Run 'bcli auth login --profile prod' to re-authenticate." + ); + } + + #[test] + fn unknown_profile_gets_did_you_mean() { + let err = + BcliError::config("Profile 'prdo' not found. Run 'bcli config init --profile prdo'"); + let msg = format_for_cli( + &err, + Some("prdo"), + Some(vec!["prod".into(), "sandbox".into()]), + ); + assert!(msg.ends_with("\n Did you mean: prod?"), "{msg}"); + } +} diff --git a/rust/crates/bcli-cli/src/lib.rs b/rust/crates/bcli-cli/src/lib.rs new file mode 100644 index 0000000..523af17 --- /dev/null +++ b/rust/crates/bcli-cli/src/lib.rs @@ -0,0 +1,183 @@ +//! `bcli` command-line front end. +//! +//! [`run`] is the whole program minus process plumbing: it takes the argv, +//! an [`Env`] (home dir, cwd, env vars, service URLs, keychain) and an +//! [`Io`] (stdout/stderr sinks), and returns the exit code. `main.rs` wires +//! the real process in; tests wire in temp dirs, mock servers and buffers. + +pub mod cli; +mod commands; +mod context; +mod errors; +pub mod output; + +use std::ffi::OsString; +use std::io::{self, IsTerminal, Write}; +use std::path::PathBuf; + +use bcli_core::auth::SecretStore; +use bcli_core::url::ServiceUrls; +use clap::Parser; + +use crate::cli::Cli; +use crate::context::Context; + +pub type EnvVars = dyn Fn(&str) -> Option; + +/// Everything the CLI reads from the outside world. +pub struct Env { + pub home: PathBuf, + pub cwd: Option, + pub vars: Box, + pub urls: ServiceUrls, + pub secrets: Box, +} + +impl Env { + pub fn from_process() -> Self { + let vars: Box = Box::new(|k| std::env::var(k).ok()); + Self { + home: home_dir(&*vars), + cwd: std::env::current_dir().ok(), + vars, + urls: ServiceUrls::default(), + secrets: default_secret_store(), + } + } + + pub fn var(&self, key: &str) -> Option { + (self.vars)(key) + } +} + +/// `pathlib.Path.home()`: `$HOME` on Unix; `%USERPROFILE%` (then +/// `%HOMEDRIVE%%HOMEPATH%`) on Windows. +fn home_dir(vars: &EnvVars) -> PathBuf { + let found = if cfg!(windows) { + vars("USERPROFILE").or_else(|| Some(format!("{}{}", vars("HOMEDRIVE")?, vars("HOMEPATH")?))) + } else { + vars("HOME") + }; + found + .filter(|h| !h.is_empty()) + .map(PathBuf::from) + .unwrap_or_default() +} + +#[cfg(feature = "keyring")] +fn default_secret_store() -> Box { + Box::new(bcli_core::auth::OsKeyring) +} + +#[cfg(not(feature = "keyring"))] +fn default_secret_store() -> Box { + Box::new(bcli_core::auth::NoSecretStore) +} + +/// Output sinks plus the terminal facts formatters need. +pub struct Io<'a> { + pub out: &'a mut dyn Write, + pub err: &'a mut dyn Write, + vars: &'a EnvVars, + pub stdout_is_tty: bool, + pub stderr_color: bool, +} + +impl<'a> Io<'a> { + pub fn new(out: &'a mut dyn Write, err: &'a mut dyn Write, vars: &'a EnvVars) -> Self { + Self { + out, + err, + vars, + stdout_is_tty: false, + stderr_color: false, + } + } + + pub fn var(&self, key: &str) -> Option { + (self.vars)(key) + } + + pub fn dim(&self, text: &str) -> String { + if self.stderr_color { + format!("\x1b[2m{text}\x1b[0m") + } else { + text.to_string() + } + } + + pub fn terminal_width(&self) -> usize { + self.var("COLUMNS") + .and_then(|c| c.parse().ok()) + .filter(|w| *w > 0) + .unwrap_or(120) + } +} + +/// Entry point for `main.rs`. +pub fn main_with_process() -> i32 { + let env = Env::from_process(); + let stdout = io::stdout(); + let stderr = io::stderr(); + let stdout_is_tty = stdout.is_terminal(); + let stderr_color = stderr.is_terminal() && env.var("NO_COLOR").is_none(); + let mut out = stdout.lock(); + let mut err = stderr.lock(); + let mut io = Io::new(&mut out, &mut err, &*env.vars); + io.stdout_is_tty = stdout_is_tty; + io.stderr_color = stderr_color; + + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + let _ = writeln!(io.err, "Error: cannot start async runtime: {e}"); + return bcli_core::error::EXIT_GENERIC_ERROR; + } + }; + let code = runtime.block_on(run(std::env::args_os().collect(), &env, &mut io)); + let _ = io.out.flush(); + code +} + +pub async fn run(args: Vec, env: &Env, io: &mut Io<'_>) -> i32 { + let cli = match Cli::try_parse_from(args) { + Ok(cli) => cli, + Err(e) => { + let rendered = if io.stderr_color { + e.render().ansi().to_string() + } else { + e.render().to_string() + }; + let sink: &mut dyn Write = if e.use_stderr() { + &mut *io.err + } else { + &mut *io.out + }; + let _ = write!(sink, "{rendered}"); + return e.exit_code(); + } + }; + + let mut ctx = Context::new(&cli.global, env, io.stdout_is_tty); + let result = commands::dispatch(&cli.command, &mut ctx, io).await; + for warning in ctx.take_warnings() { + let _ = writeln!(io.err, "bcli: warning: {warning}"); + } + match result { + Ok(code) => code, + Err(commands::CmdError::Io(e)) if e.kind() == io::ErrorKind::BrokenPipe => 0, + Err(commands::CmdError::Io(e)) => { + let _ = writeln!(io.err, "Error: {e}"); + bcli_core::error::EXIT_GENERIC_ERROR + } + Err(commands::CmdError::Bcli(e)) => { + let message = + errors::format_for_cli(&e, cli.global.profile.as_deref(), ctx.profile_names()); + let _ = writeln!(io.err, "Error: {message}"); + e.exit_code() + } + } +} diff --git a/rust/crates/bcli-cli/src/main.rs b/rust/crates/bcli-cli/src/main.rs new file mode 100644 index 0000000..1acb87f --- /dev/null +++ b/rust/crates/bcli-cli/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + std::process::exit(bcli_cli::main_with_process()); +} diff --git a/rust/crates/bcli-cli/src/output.rs b/rust/crates/bcli-cli/src/output.rs new file mode 100644 index 0000000..db6d9a7 --- /dev/null +++ b/rust/crates/bcli-cli/src/output.rs @@ -0,0 +1,344 @@ +//! Record formatters (`bcli_cli.output._formatters`). +//! +//! `json`, `ndjson`, `raw`, `markdown`, `records` and `csv` reproduce the +//! Python output byte-for-byte for JSON-typed values. `table` is a +//! comfy-table rendering, not a pixel copy of rich: humans read it, agents +//! get markdown or JSON. + +use std::io; + +use bcli_core::client::Record; +use bcli_core::pyjson; +use comfy_table::{presets, ColumnConstraint, ContentArrangement, Table, Width}; +use serde_json::Value; + +use crate::Io; + +const TABLE_MAX_COLS: usize = 12; +const MARKDOWN_MAX_WIDTH: usize = 60; + +/// `detect_default_format`: env pin, agent markers, non-TTY, legacy Windows console. +pub fn detect_default_format(var: &dyn Fn(&str) -> Option, stdout_is_tty: bool) -> String { + if let Some(fmt) = var("BCLI_FORMAT").filter(|v| !v.is_empty()) { + return fmt; + } + let set = |k: &str| var(k).is_some_and(|v| !v.is_empty()); + if set("CLAUDECODE") || set("BCLI_AGENT") { + return "markdown".into(); + } + if !stdout_is_tty { + return "json".into(); + } + if cfg!(windows) && !set("WT_SESSION") { + return "markdown".into(); + } + "table".into() +} + +pub fn format_output(records: &[Record], fmt: &str, io: &mut Io<'_>) -> io::Result<()> { + if records.is_empty() { + return writeln!(io.err, "{}", io.dim("No records found.")); + } + let fmt = if matches!(fmt, "table" | "markdown" | "md") && should_auto_records(records, io) { + "records" + } else { + fmt + }; + match fmt { + "markdown" | "md" => markdown(records, io), + "records" | "record" | "r" | "vertical" => vertical(records, io), + "json" => writeln!(io.out, "{}", pyjson::dumps(&to_array(records), Some(2))), + "csv" => csv_out(records, io), + "ndjson" => records.iter().try_for_each(|r| { + writeln!(io.out, "{}", pyjson::dumps(&Value::Object(r.clone()), None)) + }), + "raw" => { + let mut wrapper = Record::new(); + wrapper.insert("value".into(), to_array(records)); + writeln!( + io.out, + "{}", + pyjson::dumps(&Value::Object(wrapper), Some(2)) + ) + } + _ => table(records, io), + } +} + +fn to_array(records: &[Record]) -> Value { + Value::Array(records.iter().cloned().map(Value::Object).collect()) +} + +fn columns(records: &[Record]) -> Vec<&str> { + records[0] + .keys() + .map(String::as_str) + .filter(|k| !k.starts_with("@odata")) + .collect() +} + +/// Flip 1–2 wide rows to the vertical view (`_should_auto_records`). +fn should_auto_records(records: &[Record], io: &Io<'_>) -> bool { + if io + .var("BCLI_NO_AUTO_RECORDS") + .is_some_and(|v| !v.is_empty()) + || records.len() > 2 + { + return false; + } + let cols = columns(records); + if cols.len() <= 6 { + return false; + } + if cols.len() > 8 { + return true; + } + let estimated: usize = cols + .iter() + .map(|c| { + c.chars() + .count() + .max(cell(records[0].get(*c)).chars().count()) + + 3 + }) + .sum(); + estimated > io.terminal_width() +} + +/// `_format_cell`: `None` → "", bools lower-case, containers as compact JSON. +pub fn cell(value: Option<&Value>) -> String { + match value { + None | Some(Value::Null) => String::new(), + Some(Value::Bool(b)) => b.to_string(), + Some(Value::String(s)) => s.clone(), + Some(Value::Number(n)) => pyjson::number_repr(n), + Some(v @ (Value::Array(_) | Value::Object(_))) => pyjson::dumps(v, None), + } +} + +fn markdown_cell(value: Option<&Value>) -> String { + cell(value) + .replace('|', "\\|") + .replace('\n', " ") + .replace('\r', "") +} + +fn pad(cell: &str, width: usize) -> String { + let len = cell.chars().count(); + if len > width { + let mut s: String = cell.chars().take(width.saturating_sub(1)).collect(); + s.push('…'); + s + } else { + format!("{cell}{}", " ".repeat(width - len)) + } +} + +fn markdown(records: &[Record], io: &mut Io<'_>) -> io::Result<()> { + let cols = columns(records); + let rows: Vec> = records + .iter() + .map(|r| cols.iter().map(|c| markdown_cell(r.get(*c))).collect()) + .collect(); + let mut widths: Vec = cols.iter().map(|c| c.chars().count()).collect(); + for row in &rows { + for (w, c) in widths.iter_mut().zip(row) { + let len = c.chars().count(); + if len > *w { + *w = len.min(MARKDOWN_MAX_WIDTH); + } + } + } + let line = |cells: Vec| format!("| {} |", cells.join(" | ")); + writeln!( + io.out, + "{}", + line(cols.iter().zip(&widths).map(|(c, w)| pad(c, *w)).collect()) + )?; + writeln!( + io.out, + "{}", + line(widths.iter().map(|w| "-".repeat(*w)).collect()) + )?; + for row in &rows { + writeln!( + io.out, + "{}", + line(row.iter().zip(&widths).map(|(c, w)| pad(c, *w)).collect()) + )?; + } + record_count(records.len(), io) +} + +fn vertical(records: &[Record], io: &mut Io<'_>) -> io::Result<()> { + let cols = columns(records); + if cols.is_empty() { + return Ok(()); + } + let label_width = cols + .iter() + .map(|c| c.chars().count()) + .max() + .unwrap_or(0) + .min(40); + let multi = records.len() > 1; + for (idx, record) in records.iter().enumerate() { + if multi { + writeln!(io.out, "record {}", idx + 1)?; + } + for col in &cols { + let value = cell(record.get(*col)); + let mut lines = split_lines(&value).into_iter(); + let first = lines.next().unwrap_or_default(); + let label = format!("{col: Vec<&str> { + let mut out = Vec::new(); + let mut rest = s; + while !rest.is_empty() { + match rest.find(['\n', '\r']) { + Some(i) => { + out.push(&rest[..i]); + let skip = if rest[i..].starts_with("\r\n") { 2 } else { 1 }; + rest = &rest[i + skip..]; + } + None => { + out.push(rest); + break; + } + } + } + out +} + +/// Python's `csv` writes `str(value)`, and `""` for None. +fn csv_cell(value: Option<&Value>) -> String { + match value { + None | Some(Value::Null) => String::new(), + Some(Value::String(s)) => s.clone(), + Some(other) => pyjson::py_repr(other), + } +} + +fn csv_out(records: &[Record], io: &mut Io<'_>) -> io::Result<()> { + let cols = columns(records); + let mut writer = csv::WriterBuilder::new() + .terminator(csv::Terminator::CRLF) + .from_writer(Vec::new()); + let to_io = |e: csv::Error| io::Error::other(e.to_string()); + writer.write_record(&cols).map_err(to_io)?; + for r in records { + writer + .write_record(cols.iter().map(|c| csv_cell(r.get(*c)))) + .map_err(to_io)?; + } + let bytes = writer + .into_inner() + .map_err(|e| io::Error::other(e.to_string()))?; + io.out.write_all(&bytes) +} + +fn table(records: &[Record], io: &mut Io<'_>) -> io::Result<()> { + let cols = columns(records); + let truncated = cols.len() > TABLE_MAX_COLS; + let shown = &cols[..cols.len().min(TABLE_MAX_COLS)]; + let mut header: Vec = shown.iter().map(|c| c.to_string()).collect(); + if truncated { + header.push("...".into()); + } + let mut t = new_table(header); + for (i, column) in t.column_iter_mut().enumerate() { + if i > 0 { + column.set_constraint(ColumnConstraint::UpperBoundary(Width::Fixed(40))); + } + } + for r in records { + let mut row: Vec = shown.iter().map(|c| cell(r.get(*c))).collect(); + if truncated { + row.push(format!("+{} cols", cols.len() - TABLE_MAX_COLS)); + } + t.add_row(row); + } + writeln!(io.out, "{t}")?; + record_count(records.len(), io) +} + +pub fn new_table(header: Vec) -> Table { + let mut t = Table::new(); + t.load_preset(presets::UTF8_FULL_CONDENSED) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(header); + t +} + +fn record_count(n: usize, io: &mut Io<'_>) -> io::Result<()> { + writeln!(io.err, "{}", io.dim(&format!("{n} record(s)"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn rec(v: Value) -> Record { + match v { + Value::Object(o) => o, + _ => unreachable!(), + } + } + + #[test] + fn default_format_detection() { + let none = |_: &str| None; + assert_eq!(detect_default_format(&none, false), "json"); + let agent = |k: &str| (k == "CLAUDECODE").then(|| "1".to_string()); + assert_eq!(detect_default_format(&agent, true), "markdown"); + let pinned = |k: &str| (k == "BCLI_FORMAT").then(|| "csv".to_string()); + assert_eq!(detect_default_format(&pinned, false), "csv"); + } + + #[test] + fn cells_match_python() { + assert_eq!(cell(Some(&json!(true))), "true"); + assert_eq!(cell(Some(&json!(null))), ""); + assert_eq!(cell(Some(&json!(2.0))), "2.0"); + assert_eq!(cell(Some(&json!({"a": [1]}))), r#"{"a": [1]}"#); + assert_eq!(markdown_cell(Some(&json!("a|b\nc"))), r"a\|b c"); + assert_eq!(csv_cell(Some(&json!(false))), "False"); + } + + #[test] + fn markdown_pads_and_truncates() { + assert_eq!(pad("abc", 5), "abc "); + assert_eq!(pad("abcdef", 4), "abc…"); + } + + #[test] + fn split_lines_handles_crlf() { + assert_eq!(split_lines("a\r\nb\nc\rd"), ["a", "b", "c", "d"]); + assert!(split_lines("").is_empty()); + } + + #[test] + fn csv_uses_crlf_and_minimal_quoting() { + let mut out = Vec::new(); + let mut err = Vec::new(); + let vars = |_: &str| None; + let mut io = Io::new(&mut out, &mut err, &vars); + let rows = [rec(json!({"a": "x,y", "b": true, "@odata.etag": "e"}))]; + csv_out(&rows, &mut io).unwrap(); + assert_eq!(String::from_utf8(out).unwrap(), "a,b\r\n\"x,y\",True\r\n"); + } +} diff --git a/rust/crates/bcli-cli/tests/company_list.rs b/rust/crates/bcli-cli/tests/company_list.rs new file mode 100644 index 0000000..28bfc8d --- /dev/null +++ b/rust/crates/bcli-cli/tests/company_list.rs @@ -0,0 +1,213 @@ +//! `bcli company list` end to end: argv → config → client-credentials token +//! → BC companies request → formatted output, against a local mock of Entra +//! and Business Central. + +use std::collections::HashMap; +use std::path::Path; + +use bcli_cli::{run, Env, Io}; +use bcli_core::auth::NoSecretStore; +use bcli_core::url::ServiceUrls; +use serde_json::{json, Value}; +use wiremock::matchers::{header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const CONFIG: &str = r#" +[defaults] +profile = "prod" + +[profiles.prod] +tenant_id = "tenant-1" +environment = "Production" +company_id = "c-2" +auth_method = "client_credentials" +client_id = "client-1" +client_secret_env = "BC_SECRET" + +[profiles.prod.companies.LLC] +id = "c-1" +name = "Contoso LLC" +"#; + +struct Run { + code: i32, + stdout: String, + stderr: String, +} + +async fn bcli(server: &MockServer, home: &Path, args: &[&str]) -> Run { + let vars: HashMap = [("BC_SECRET".to_string(), "s3cret".to_string())].into(); + let env = Env { + home: home.to_path_buf(), + cwd: None, + vars: Box::new(move |k| vars.get(k).cloned()), + urls: ServiceUrls { + bc_base: format!("{}/v2.0", server.uri()), + bc_admin_base: format!("{}/admin/v2.1", server.uri()), + authority_base: server.uri(), + }, + secrets: Box::new(NoSecretStore), + }; + let (mut out, mut err) = (Vec::new(), Vec::new()); + let mut io = Io::new(&mut out, &mut err, &*env.vars); + let argv = std::iter::once("bcli") + .chain(args.iter().copied()) + .map(Into::into) + .collect(); + let code = run(argv, &env, &mut io).await; + Run { + code, + stdout: String::from_utf8(out).unwrap(), + stderr: String::from_utf8(err).unwrap(), + } +} + +async fn server_with_companies() -> MockServer { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/tenant-1/oauth2/v2.0/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "tok", "expires_in": 3600 + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/v2.0/Sandbox/api/v2.0/companies")) + .and(header("authorization", "Bearer tok")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "@odata.context": "ctx", + "value": [ + {"id": "c-1", "systemVersion": "1", "name": "Contoso LLC", "displayName": "Contoso"}, + {"id": "c-2", "systemVersion": "1", "name": "Contoso Ltd", "displayName": "Ltd"} + ] + }))) + .mount(&server) + .await; + server +} + +fn home() -> tempfile::TempDir { + let home = tempfile::tempdir().unwrap(); + let dir = home.path().join(".config").join("bcli"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("config.toml"), CONFIG).unwrap(); + home +} + +#[tokio::test] +async fn json_rows_follow_the_mcp_contract() { + let server = server_with_companies().await; + let home = home(); + let r = bcli( + &server, + home.path(), + &["-e", "Sandbox", "company", "list", "-f", "json"], + ) + .await; + + assert_eq!(r.code, 0, "{}", r.stderr); + assert_eq!(r.stderr, "", "json output suppresses the banner"); + let rows: Value = serde_json::from_str(&r.stdout).unwrap(); + assert_eq!( + rows, + json!([ + {"id": "c-1", "name": "Contoso LLC", "alias": "LLC", "is_default": false}, + {"id": "c-2", "name": "Contoso Ltd", "alias": null, "is_default": true} + ]) + ); + assert!( + r.stdout.starts_with("[\n {\n \"id\": \"c-1\","), + "Python indent=2 layout" + ); + assert!( + home.path().join(".config/bcli/tokens.json").is_file(), + "token cached for the next call" + ); +} + +#[tokio::test] +async fn markdown_and_banner_with_company_override() { + let server = server_with_companies().await; + let home = home(); + let r = bcli( + &server, + home.path(), + &[ + "-e", "Sandbox", "-c", "LLC", "-f", "table", "company", "list", + ], + ) + .await; + + assert_eq!(r.code, 0, "{}", r.stderr); + assert_eq!( + r.stderr, + "[profile: prod | env: Sandbox | company: Contoso LLC]\n" + ); + assert!( + r.stdout.contains("Contoso LLC ◄"), + "-c LLC makes c-1 the default:\n{}", + r.stdout + ); + assert!(r.stdout.ends_with("2 company(ies)\n")); + + let md = bcli( + &server, + home.path(), + &["-e", "Sandbox", "company", "list", "-f", "markdown"], + ) + .await; + assert_eq!( + md.stdout, + "| id | name | alias | is_default |\n\ + | --- | ----------- | ----- | ---------- |\n\ + | c-1 | Contoso LLC | LLC | false |\n\ + | c-2 | Contoso Ltd | | true |\n" + ); + assert_eq!(md.stderr, "2 record(s)\n"); +} + +#[tokio::test] +async fn http_failure_reports_on_stdout_with_exit_1() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"access_token": "tok"}))) + .mount(&server) + .await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(403).set_body_json(json!({ + "error": {"code": "Authorization_InsufficientPermissions", "message": "No access"} + }))) + .mount(&server) + .await; + let home = home(); + let r = bcli(&server, home.path(), &["company", "list", "-f", "json"]).await; + + assert_eq!(r.code, 1); + assert!( + r.stdout.starts_with("Error: HTTP 403 Forbidden: GET ") + && r.stdout.ends_with(" | BC says: No access\n"), + "{}", + r.stdout + ); +} + +#[tokio::test] +async fn unknown_profile_goes_through_the_central_handler() { + let server = MockServer::start().await; + let home = home(); + let r = bcli( + &server, + home.path(), + &["-p", "prdo", "-f", "table", "company", "list"], + ) + .await; + assert_eq!(r.code, 2); + assert!(r + .stderr + .starts_with("Error: Profile 'prdo' not found. Available: prod.")); + assert!( + r.stderr.ends_with("\n Did you mean: prod?\n"), + "{}", + r.stderr + ); +} diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/cases.txt b/rust/crates/bcli-cli/tests/fixtures/golden/cases.txt new file mode 100644 index 0000000..a89a1e8 --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/cases.txt @@ -0,0 +1,21 @@ +# Golden parity cases: | +# Outputs in this directory were produced by the Python bcli against +# ../home (see rust/scripts/parity.sh); the Rust binary must match them. +version | --version +list-json | endpoint list -f json +list-ndjson | endpoint list -f ndjson +list-csv | endpoint list -f csv +list-raw | endpoint list -f raw +list-markdown | endpoint list -f markdown +list-custom-markdown | endpoint list --custom -f markdown +list-category-json | endpoint list --custom --category OPS -f json +list-scoped-profile | -p scoped endpoint list -f json +list-auto-records | endpoint list --custom --category ops -f markdown +info-custom-json | endpoint info widgetLines -f json +info-shadowed-json | endpoint info CUSTOMERS -f json +info-standard-json | endpoint info accounts -f json +info-text | endpoint info widgetLines +info-unknown | endpoint info widget +search-none | endpoint search zzzz +error-unknown-profile | -p nope endpoint list -f json +error-profile-typo | -p prdo endpoint list -f json diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/error-profile-typo.code b/rust/crates/bcli-cli/tests/fixtures/golden/error-profile-typo.code new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/error-profile-typo.code @@ -0,0 +1 @@ +2 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/error-profile-typo.err b/rust/crates/bcli-cli/tests/fixtures/golden/error-profile-typo.err new file mode 100644 index 0000000..779e657 --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/error-profile-typo.err @@ -0,0 +1,2 @@ +Error: Profile 'prdo' not found. Available: prod, scoped. Run 'bcli config init --profile prdo' to create it, or 'bcli config use ' to switch. + Did you mean: prod? diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/error-profile-typo.out b/rust/crates/bcli-cli/tests/fixtures/golden/error-profile-typo.out new file mode 100644 index 0000000..e69de29 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/error-unknown-profile.code b/rust/crates/bcli-cli/tests/fixtures/golden/error-unknown-profile.code new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/error-unknown-profile.code @@ -0,0 +1 @@ +2 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/error-unknown-profile.err b/rust/crates/bcli-cli/tests/fixtures/golden/error-unknown-profile.err new file mode 100644 index 0000000..d4adbb4 --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/error-unknown-profile.err @@ -0,0 +1,2 @@ +Error: Profile 'nope' not found. Available: prod, scoped. Run 'bcli config init --profile nope' to create it, or 'bcli config use ' to switch. + Did you mean: scoped? diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/error-unknown-profile.out b/rust/crates/bcli-cli/tests/fixtures/golden/error-unknown-profile.out new file mode 100644 index 0000000..e69de29 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/info-custom-json.code b/rust/crates/bcli-cli/tests/fixtures/golden/info-custom-json.code new file mode 100644 index 0000000..573541a --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/info-custom-json.code @@ -0,0 +1 @@ +0 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/info-custom-json.err b/rust/crates/bcli-cli/tests/fixtures/golden/info-custom-json.err new file mode 100644 index 0000000..e69de29 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/info-custom-json.out b/rust/crates/bcli-cli/tests/fixtures/golden/info-custom-json.out new file mode 100644 index 0000000..317c757 --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/info-custom-json.out @@ -0,0 +1,29 @@ +{ + "name": "widgetLines", + "category": "ops", + "custom": true, + "supported_ops": [ + "GET", + "PATCH" + ], + "key_field": "id", + "publisher": "acme", + "group": "ops", + "version": "v1.0", + "description": "Widget lines \u2014 \u00dcberpr\u00fcfung", + "caution": "low", + "entity_name": "widgetLine", + "fields": [ + { + "name": "no", + "type": "" + }, + { + "name": "qty", + "type": "" + } + ], + "fields_discovered": true, + "source_table": "50100", + "page_number": "" +} diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/info-shadowed-json.code b/rust/crates/bcli-cli/tests/fixtures/golden/info-shadowed-json.code new file mode 100644 index 0000000..573541a --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/info-shadowed-json.code @@ -0,0 +1 @@ +0 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/info-shadowed-json.err b/rust/crates/bcli-cli/tests/fixtures/golden/info-shadowed-json.err new file mode 100644 index 0000000..e69de29 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/info-shadowed-json.out b/rust/crates/bcli-cli/tests/fixtures/golden/info-shadowed-json.out new file mode 100644 index 0000000..ccf3c60 --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/info-shadowed-json.out @@ -0,0 +1,19 @@ +{ + "name": "customers", + "category": "sales", + "custom": true, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": "acme", + "group": "sales", + "version": "v2.0", + "description": "Custom customers", + "caution": "high", + "entity_name": "", + "fields": [], + "fields_discovered": false, + "source_table": "", + "page_number": "" +} diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/info-standard-json.code b/rust/crates/bcli-cli/tests/fixtures/golden/info-standard-json.code new file mode 100644 index 0000000..573541a --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/info-standard-json.code @@ -0,0 +1 @@ +0 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/info-standard-json.err b/rust/crates/bcli-cli/tests/fixtures/golden/info-standard-json.err new file mode 100644 index 0000000..e69de29 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/info-standard-json.out b/rust/crates/bcli-cli/tests/fixtures/golden/info-standard-json.out new file mode 100644 index 0000000..0964e3a --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/info-standard-json.out @@ -0,0 +1,19 @@ +{ + "name": "accounts", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "G/L accounts (chart of accounts)", + "caution": "low", + "entity_name": "account", + "fields": [], + "fields_discovered": false, + "source_table": "", + "page_number": "" +} diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/info-text.code b/rust/crates/bcli-cli/tests/fixtures/golden/info-text.code new file mode 100644 index 0000000..573541a --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/info-text.code @@ -0,0 +1 @@ +0 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/info-text.err b/rust/crates/bcli-cli/tests/fixtures/golden/info-text.err new file mode 100644 index 0000000..e69de29 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/info-text.out b/rust/crates/bcli-cli/tests/fixtures/golden/info-text.out new file mode 100644 index 0000000..317c757 --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/info-text.out @@ -0,0 +1,29 @@ +{ + "name": "widgetLines", + "category": "ops", + "custom": true, + "supported_ops": [ + "GET", + "PATCH" + ], + "key_field": "id", + "publisher": "acme", + "group": "ops", + "version": "v1.0", + "description": "Widget lines \u2014 \u00dcberpr\u00fcfung", + "caution": "low", + "entity_name": "widgetLine", + "fields": [ + { + "name": "no", + "type": "" + }, + { + "name": "qty", + "type": "" + } + ], + "fields_discovered": true, + "source_table": "50100", + "page_number": "" +} diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/info-unknown.code b/rust/crates/bcli-cli/tests/fixtures/golden/info-unknown.code new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/info-unknown.code @@ -0,0 +1 @@ +1 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/info-unknown.err b/rust/crates/bcli-cli/tests/fixtures/golden/info-unknown.err new file mode 100644 index 0000000..21e1027 --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/info-unknown.err @@ -0,0 +1,2 @@ +Endpoint 'widget' not found. +Did you mean: widgetLines? diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/info-unknown.out b/rust/crates/bcli-cli/tests/fixtures/golden/info-unknown.out new file mode 100644 index 0000000..e69de29 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-auto-records.code b/rust/crates/bcli-cli/tests/fixtures/golden/list-auto-records.code new file mode 100644 index 0000000..573541a --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/list-auto-records.code @@ -0,0 +1 @@ +0 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-auto-records.err b/rust/crates/bcli-cli/tests/fixtures/golden/list-auto-records.err new file mode 100644 index 0000000..ad80510 --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/list-auto-records.err @@ -0,0 +1 @@ +1 record(s) diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-auto-records.out b/rust/crates/bcli-cli/tests/fixtures/golden/list-auto-records.out new file mode 100644 index 0000000..6102a43 --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/list-auto-records.out @@ -0,0 +1,10 @@ + name : widgetLines + category : ops + custom : true + supported_ops : ["GET", "PATCH"] + key_field : id + publisher : acme + group : ops + version : v1.0 + description : Widget lines — Überprüfung + caution : low diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-category-json.code b/rust/crates/bcli-cli/tests/fixtures/golden/list-category-json.code new file mode 100644 index 0000000..573541a --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/list-category-json.code @@ -0,0 +1 @@ +0 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-category-json.err b/rust/crates/bcli-cli/tests/fixtures/golden/list-category-json.err new file mode 100644 index 0000000..e69de29 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-category-json.out b/rust/crates/bcli-cli/tests/fixtures/golden/list-category-json.out new file mode 100644 index 0000000..8daf4be --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/list-category-json.out @@ -0,0 +1,17 @@ +[ + { + "name": "widgetLines", + "category": "ops", + "custom": true, + "supported_ops": [ + "GET", + "PATCH" + ], + "key_field": "id", + "publisher": "acme", + "group": "ops", + "version": "v1.0", + "description": "Widget lines \u2014 \u00dcberpr\u00fcfung", + "caution": "low" + } +] diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-csv.code b/rust/crates/bcli-cli/tests/fixtures/golden/list-csv.code new file mode 100644 index 0000000..573541a --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/list-csv.code @@ -0,0 +1 @@ +0 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-csv.err b/rust/crates/bcli-cli/tests/fixtures/golden/list-csv.err new file mode 100644 index 0000000..e69de29 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-csv.out b/rust/crates/bcli-cli/tests/fixtures/golden/list-csv.out new file mode 100644 index 0000000..e8b7ef7 --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/list-csv.out @@ -0,0 +1,82 @@ +name,category,custom,supported_ops,key_field,publisher,group,version,description,caution +customers,sales,True,['GET'],id,acme,sales,v2.0,Custom customers,high +widgetLines,ops,True,"['GET', 'PATCH']",id,acme,ops,v1.0,Widget lines — Überprüfung,low +accountingPeriods,financials,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Accounting periods,low +accounts,financials,False,['GET'],id,,,,G/L accounts (chart of accounts),low +agedAccountsPayables,purchasing,False,['GET'],vendorId,,,,Aged accounts payable,low +agedAccountsReceivables,sales,False,['GET'],customerId,,,,Aged accounts receivable,low +applyVendorEntries,purchasing,False,"['GET', 'POST']",id,,,,Apply vendor ledger entries,low +attachments,setup,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,File attachments,low +balanceSheets,financials,False,['GET'],lineNumber,,,,Balance sheet report data,low +bankAccounts,financials,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Bank accounts,low +cashFlowStatements,financials,False,['GET'],lineNumber,,,,Cash flow statement report data,low +companies,setup,False,['GET'],id,,,,Companies in the BC environment,low +companyInformation,setup,False,"['GET', 'PATCH']",id,,,,Company information and settings,low +contacts,sales,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Contacts,low +contactsInformation,sales,False,['GET'],contactId,,,,Contact information details,low +countriesRegions,setup,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Countries and regions,low +currencies,setup,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Currencies,low +currencyExchangeRates,setup,False,['GET'],id,,,,Currency exchange rates,low +customerContacts,sales,False,"['GET', 'POST', 'PATCH']",contactId,,,,Customer contact relationships,low +customerFinancialDetails,sales,False,['GET'],id,,,,Customer financial summary,low +customerPaymentJournals,sales,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Customer payment journal batches,low +customerPayments,sales,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Customer payment journal lines,low +customerReturnReasons,sales,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Customer return reason codes,low +customerSales,sales,False,['GET'],customerId,,,,Customer sales summary,low +customers,sales,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Customer master data,low +defaultDimensions,dimensions,False,"['GET', 'POST', 'PATCH', 'DELETE']",parentId,,,,Default dimensions for entities,low +dimensionSetLines,dimensions,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Dimension set line entries,low +dimensionValues,dimensions,False,['GET'],id,,,,Dimension values,low +dimensions,dimensions,False,['GET'],id,,,,Dimensions,low +disputeStatus,setup,False,['GET'],id,,,,Dispute status codes,low +documentAttachments,setup,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Document-level attachments,low +employees,hr,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Employee records,low +fixedAssetLocations,inventory,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Fixed asset location codes,low +fixedAssets,inventory,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Fixed asset records,low +generalLedgerEntries,financials,False,['GET'],id,,,,G/L ledger entries,low +generalLedgerSetup,financials,False,['GET'],id,,,,G/L setup configuration,low +generalProductPostingGroups,setup,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,General product posting groups,low +incomeStatements,financials,False,['GET'],lineNumber,,,,Income statement report data,low +inventoryPostingGroups,setup,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Inventory posting groups,low +itemCategories,inventory,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Item categories,low +itemLedgerEntries,inventory,False,['GET'],id,,,,Item ledger entries,low +itemVariants,inventory,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Item variants,low +items,inventory,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Item master data,low +journalLines,financials,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,General journal lines,low +journals,financials,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,General journal batches,low +locations,inventory,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Inventory locations,low +opportunities,sales,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Sales opportunities,low +paymentMethods,setup,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Payment methods,low +paymentTerms,setup,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Payment terms,low +pdfDocument,setup,False,['GET'],id,,,,PDF document generation for posted documents,low +pictures,setup,False,"['GET', 'POST', 'PATCH']",id,,,,Entity pictures/images,low +projects,projects,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Projects (jobs),low +purchaseCreditMemoLines,purchasing,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Purchase credit memo lines,low +purchaseCreditMemos,purchasing,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Purchase credit memos,low +purchaseInvoiceLines,purchasing,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Purchase invoice lines,low +purchaseInvoices,purchasing,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Purchase invoices,low +purchaseOrderLines,purchasing,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Purchase order lines,low +purchaseOrders,purchasing,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Purchase orders,low +purchaseReceiptLines,purchasing,False,['GET'],id,,,,Purchase receipt lines,low +purchaseReceipts,purchasing,False,['GET'],id,,,,Purchase receipts,low +retainedEarningsStatements,financials,False,['GET'],lineNumber,,,,Retained earnings statement,low +salesCreditMemoLines,sales,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Sales credit memo lines,low +salesCreditMemos,sales,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Sales credit memos,low +salesInvoiceLines,sales,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Sales invoice lines,low +salesInvoices,sales,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Sales invoices,low +salesOrderLines,sales,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Sales order lines,low +salesOrders,sales,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Sales orders,low +salesQuoteLines,sales,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Sales quote lines,low +salesQuotes,sales,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Sales quotes,low +salesShipmentLines,sales,False,['GET'],id,,,,Sales shipment lines,low +salesShipments,sales,False,['GET'],id,,,,Posted sales shipments,low +shipmentMethods,setup,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Shipment methods,low +taxAreas,setup,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Tax areas,low +taxGroups,setup,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Tax groups,low +timeRegistrationEntries,hr,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Time registration entries,low +trialBalances,financials,False,['GET'],accountId,,,,Trial balance report data,low +unitsOfMeasure,setup,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Units of measure,low +vendorPaymentJournals,purchasing,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Vendor payment journal batches,low +vendorPayments,purchasing,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Vendor payment journal lines,low +vendorPurchases,purchasing,False,['GET'],vendorId,,,,Vendor purchase summary,low +vendors,purchasing,False,"['GET', 'POST', 'PATCH', 'DELETE']",id,,,,Vendor master data,low diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-custom-markdown.code b/rust/crates/bcli-cli/tests/fixtures/golden/list-custom-markdown.code new file mode 100644 index 0000000..573541a --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/list-custom-markdown.code @@ -0,0 +1 @@ +0 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-custom-markdown.err b/rust/crates/bcli-cli/tests/fixtures/golden/list-custom-markdown.err new file mode 100644 index 0000000..aea6a85 --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/list-custom-markdown.err @@ -0,0 +1 @@ +2 record(s) diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-custom-markdown.out b/rust/crates/bcli-cli/tests/fixtures/golden/list-custom-markdown.out new file mode 100644 index 0000000..96900e9 --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/list-custom-markdown.out @@ -0,0 +1,23 @@ +record 1 + name : customers + category : sales + custom : true + supported_ops : ["GET"] + key_field : id + publisher : acme + group : sales + version : v2.0 + description : Custom customers + caution : high + +record 2 + name : widgetLines + category : ops + custom : true + supported_ops : ["GET", "PATCH"] + key_field : id + publisher : acme + group : ops + version : v1.0 + description : Widget lines — Überprüfung + caution : low diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-json.code b/rust/crates/bcli-cli/tests/fixtures/golden/list-json.code new file mode 100644 index 0000000..573541a --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/list-json.code @@ -0,0 +1 @@ +0 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-json.err b/rust/crates/bcli-cli/tests/fixtures/golden/list-json.err new file mode 100644 index 0000000..e69de29 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-json.out b/rust/crates/bcli-cli/tests/fixtures/golden/list-json.out new file mode 100644 index 0000000..eb7c27c --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/list-json.out @@ -0,0 +1,1293 @@ +[ + { + "name": "customers", + "category": "sales", + "custom": true, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": "acme", + "group": "sales", + "version": "v2.0", + "description": "Custom customers", + "caution": "high" + }, + { + "name": "widgetLines", + "category": "ops", + "custom": true, + "supported_ops": [ + "GET", + "PATCH" + ], + "key_field": "id", + "publisher": "acme", + "group": "ops", + "version": "v1.0", + "description": "Widget lines \u2014 \u00dcberpr\u00fcfung", + "caution": "low" + }, + { + "name": "accountingPeriods", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Accounting periods", + "caution": "low" + }, + { + "name": "accounts", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "G/L accounts (chart of accounts)", + "caution": "low" + }, + { + "name": "agedAccountsPayables", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "vendorId", + "publisher": null, + "group": null, + "version": null, + "description": "Aged accounts payable", + "caution": "low" + }, + { + "name": "agedAccountsReceivables", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "customerId", + "publisher": null, + "group": null, + "version": null, + "description": "Aged accounts receivable", + "caution": "low" + }, + { + "name": "applyVendorEntries", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET", + "POST" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Apply vendor ledger entries", + "caution": "low" + }, + { + "name": "attachments", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "File attachments", + "caution": "low" + }, + { + "name": "balanceSheets", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "lineNumber", + "publisher": null, + "group": null, + "version": null, + "description": "Balance sheet report data", + "caution": "low" + }, + { + "name": "bankAccounts", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Bank accounts", + "caution": "low" + }, + { + "name": "cashFlowStatements", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "lineNumber", + "publisher": null, + "group": null, + "version": null, + "description": "Cash flow statement report data", + "caution": "low" + }, + { + "name": "companies", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Companies in the BC environment", + "caution": "low" + }, + { + "name": "companyInformation", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "PATCH" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Company information and settings", + "caution": "low" + }, + { + "name": "contacts", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Contacts", + "caution": "low" + }, + { + "name": "contactsInformation", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "contactId", + "publisher": null, + "group": null, + "version": null, + "description": "Contact information details", + "caution": "low" + }, + { + "name": "countriesRegions", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Countries and regions", + "caution": "low" + }, + { + "name": "currencies", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Currencies", + "caution": "low" + }, + { + "name": "currencyExchangeRates", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Currency exchange rates", + "caution": "low" + }, + { + "name": "customerContacts", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH" + ], + "key_field": "contactId", + "publisher": null, + "group": null, + "version": null, + "description": "Customer contact relationships", + "caution": "low" + }, + { + "name": "customerFinancialDetails", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Customer financial summary", + "caution": "low" + }, + { + "name": "customerPaymentJournals", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Customer payment journal batches", + "caution": "low" + }, + { + "name": "customerPayments", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Customer payment journal lines", + "caution": "low" + }, + { + "name": "customerReturnReasons", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Customer return reason codes", + "caution": "low" + }, + { + "name": "customerSales", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "customerId", + "publisher": null, + "group": null, + "version": null, + "description": "Customer sales summary", + "caution": "low" + }, + { + "name": "customers", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Customer master data", + "caution": "low" + }, + { + "name": "defaultDimensions", + "category": "dimensions", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "parentId", + "publisher": null, + "group": null, + "version": null, + "description": "Default dimensions for entities", + "caution": "low" + }, + { + "name": "dimensionSetLines", + "category": "dimensions", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Dimension set line entries", + "caution": "low" + }, + { + "name": "dimensionValues", + "category": "dimensions", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Dimension values", + "caution": "low" + }, + { + "name": "dimensions", + "category": "dimensions", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Dimensions", + "caution": "low" + }, + { + "name": "disputeStatus", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Dispute status codes", + "caution": "low" + }, + { + "name": "documentAttachments", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Document-level attachments", + "caution": "low" + }, + { + "name": "employees", + "category": "hr", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Employee records", + "caution": "low" + }, + { + "name": "fixedAssetLocations", + "category": "inventory", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Fixed asset location codes", + "caution": "low" + }, + { + "name": "fixedAssets", + "category": "inventory", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Fixed asset records", + "caution": "low" + }, + { + "name": "generalLedgerEntries", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "G/L ledger entries", + "caution": "low" + }, + { + "name": "generalLedgerSetup", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "G/L setup configuration", + "caution": "low" + }, + { + "name": "generalProductPostingGroups", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "General product posting groups", + "caution": "low" + }, + { + "name": "incomeStatements", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "lineNumber", + "publisher": null, + "group": null, + "version": null, + "description": "Income statement report data", + "caution": "low" + }, + { + "name": "inventoryPostingGroups", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Inventory posting groups", + "caution": "low" + }, + { + "name": "itemCategories", + "category": "inventory", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Item categories", + "caution": "low" + }, + { + "name": "itemLedgerEntries", + "category": "inventory", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Item ledger entries", + "caution": "low" + }, + { + "name": "itemVariants", + "category": "inventory", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Item variants", + "caution": "low" + }, + { + "name": "items", + "category": "inventory", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Item master data", + "caution": "low" + }, + { + "name": "journalLines", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "General journal lines", + "caution": "low" + }, + { + "name": "journals", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "General journal batches", + "caution": "low" + }, + { + "name": "locations", + "category": "inventory", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Inventory locations", + "caution": "low" + }, + { + "name": "opportunities", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Sales opportunities", + "caution": "low" + }, + { + "name": "paymentMethods", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Payment methods", + "caution": "low" + }, + { + "name": "paymentTerms", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Payment terms", + "caution": "low" + }, + { + "name": "pdfDocument", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "PDF document generation for posted documents", + "caution": "low" + }, + { + "name": "pictures", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Entity pictures/images", + "caution": "low" + }, + { + "name": "projects", + "category": "projects", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Projects (jobs)", + "caution": "low" + }, + { + "name": "purchaseCreditMemoLines", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Purchase credit memo lines", + "caution": "low" + }, + { + "name": "purchaseCreditMemos", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Purchase credit memos", + "caution": "low" + }, + { + "name": "purchaseInvoiceLines", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Purchase invoice lines", + "caution": "low" + }, + { + "name": "purchaseInvoices", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Purchase invoices", + "caution": "low" + }, + { + "name": "purchaseOrderLines", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Purchase order lines", + "caution": "low" + }, + { + "name": "purchaseOrders", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Purchase orders", + "caution": "low" + }, + { + "name": "purchaseReceiptLines", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Purchase receipt lines", + "caution": "low" + }, + { + "name": "purchaseReceipts", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Purchase receipts", + "caution": "low" + }, + { + "name": "retainedEarningsStatements", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "lineNumber", + "publisher": null, + "group": null, + "version": null, + "description": "Retained earnings statement", + "caution": "low" + }, + { + "name": "salesCreditMemoLines", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Sales credit memo lines", + "caution": "low" + }, + { + "name": "salesCreditMemos", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Sales credit memos", + "caution": "low" + }, + { + "name": "salesInvoiceLines", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Sales invoice lines", + "caution": "low" + }, + { + "name": "salesInvoices", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Sales invoices", + "caution": "low" + }, + { + "name": "salesOrderLines", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Sales order lines", + "caution": "low" + }, + { + "name": "salesOrders", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Sales orders", + "caution": "low" + }, + { + "name": "salesQuoteLines", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Sales quote lines", + "caution": "low" + }, + { + "name": "salesQuotes", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Sales quotes", + "caution": "low" + }, + { + "name": "salesShipmentLines", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Sales shipment lines", + "caution": "low" + }, + { + "name": "salesShipments", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Posted sales shipments", + "caution": "low" + }, + { + "name": "shipmentMethods", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Shipment methods", + "caution": "low" + }, + { + "name": "taxAreas", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Tax areas", + "caution": "low" + }, + { + "name": "taxGroups", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Tax groups", + "caution": "low" + }, + { + "name": "timeRegistrationEntries", + "category": "hr", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Time registration entries", + "caution": "low" + }, + { + "name": "trialBalances", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "accountId", + "publisher": null, + "group": null, + "version": null, + "description": "Trial balance report data", + "caution": "low" + }, + { + "name": "unitsOfMeasure", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Units of measure", + "caution": "low" + }, + { + "name": "vendorPaymentJournals", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Vendor payment journal batches", + "caution": "low" + }, + { + "name": "vendorPayments", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Vendor payment journal lines", + "caution": "low" + }, + { + "name": "vendorPurchases", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "vendorId", + "publisher": null, + "group": null, + "version": null, + "description": "Vendor purchase summary", + "caution": "low" + }, + { + "name": "vendors", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Vendor master data", + "caution": "low" + } +] diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-markdown.code b/rust/crates/bcli-cli/tests/fixtures/golden/list-markdown.code new file mode 100644 index 0000000..573541a --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/list-markdown.code @@ -0,0 +1 @@ +0 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-markdown.err b/rust/crates/bcli-cli/tests/fixtures/golden/list-markdown.err new file mode 100644 index 0000000..ab51d54 --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/list-markdown.err @@ -0,0 +1 @@ +81 record(s) diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-markdown.out b/rust/crates/bcli-cli/tests/fixtures/golden/list-markdown.out new file mode 100644 index 0000000..1da38dc --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/list-markdown.out @@ -0,0 +1,83 @@ +| name | category | custom | supported_ops | key_field | publisher | group | version | description | caution | +| --------------------------- | ---------- | ------ | ---------------------------------- | ---------- | --------- | ----- | ------- | -------------------------------------------- | ------- | +| customers | sales | true | ["GET"] | id | acme | sales | v2.0 | Custom customers | high | +| widgetLines | ops | true | ["GET", "PATCH"] | id | acme | ops | v1.0 | Widget lines — Überprüfung | low | +| accountingPeriods | financials | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Accounting periods | low | +| accounts | financials | false | ["GET"] | id | | | | G/L accounts (chart of accounts) | low | +| agedAccountsPayables | purchasing | false | ["GET"] | vendorId | | | | Aged accounts payable | low | +| agedAccountsReceivables | sales | false | ["GET"] | customerId | | | | Aged accounts receivable | low | +| applyVendorEntries | purchasing | false | ["GET", "POST"] | id | | | | Apply vendor ledger entries | low | +| attachments | setup | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | File attachments | low | +| balanceSheets | financials | false | ["GET"] | lineNumber | | | | Balance sheet report data | low | +| bankAccounts | financials | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Bank accounts | low | +| cashFlowStatements | financials | false | ["GET"] | lineNumber | | | | Cash flow statement report data | low | +| companies | setup | false | ["GET"] | id | | | | Companies in the BC environment | low | +| companyInformation | setup | false | ["GET", "PATCH"] | id | | | | Company information and settings | low | +| contacts | sales | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Contacts | low | +| contactsInformation | sales | false | ["GET"] | contactId | | | | Contact information details | low | +| countriesRegions | setup | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Countries and regions | low | +| currencies | setup | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Currencies | low | +| currencyExchangeRates | setup | false | ["GET"] | id | | | | Currency exchange rates | low | +| customerContacts | sales | false | ["GET", "POST", "PATCH"] | contactId | | | | Customer contact relationships | low | +| customerFinancialDetails | sales | false | ["GET"] | id | | | | Customer financial summary | low | +| customerPaymentJournals | sales | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Customer payment journal batches | low | +| customerPayments | sales | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Customer payment journal lines | low | +| customerReturnReasons | sales | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Customer return reason codes | low | +| customerSales | sales | false | ["GET"] | customerId | | | | Customer sales summary | low | +| customers | sales | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Customer master data | low | +| defaultDimensions | dimensions | false | ["GET", "POST", "PATCH", "DELETE"] | parentId | | | | Default dimensions for entities | low | +| dimensionSetLines | dimensions | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Dimension set line entries | low | +| dimensionValues | dimensions | false | ["GET"] | id | | | | Dimension values | low | +| dimensions | dimensions | false | ["GET"] | id | | | | Dimensions | low | +| disputeStatus | setup | false | ["GET"] | id | | | | Dispute status codes | low | +| documentAttachments | setup | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Document-level attachments | low | +| employees | hr | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Employee records | low | +| fixedAssetLocations | inventory | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Fixed asset location codes | low | +| fixedAssets | inventory | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Fixed asset records | low | +| generalLedgerEntries | financials | false | ["GET"] | id | | | | G/L ledger entries | low | +| generalLedgerSetup | financials | false | ["GET"] | id | | | | G/L setup configuration | low | +| generalProductPostingGroups | setup | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | General product posting groups | low | +| incomeStatements | financials | false | ["GET"] | lineNumber | | | | Income statement report data | low | +| inventoryPostingGroups | setup | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Inventory posting groups | low | +| itemCategories | inventory | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Item categories | low | +| itemLedgerEntries | inventory | false | ["GET"] | id | | | | Item ledger entries | low | +| itemVariants | inventory | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Item variants | low | +| items | inventory | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Item master data | low | +| journalLines | financials | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | General journal lines | low | +| journals | financials | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | General journal batches | low | +| locations | inventory | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Inventory locations | low | +| opportunities | sales | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Sales opportunities | low | +| paymentMethods | setup | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Payment methods | low | +| paymentTerms | setup | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Payment terms | low | +| pdfDocument | setup | false | ["GET"] | id | | | | PDF document generation for posted documents | low | +| pictures | setup | false | ["GET", "POST", "PATCH"] | id | | | | Entity pictures/images | low | +| projects | projects | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Projects (jobs) | low | +| purchaseCreditMemoLines | purchasing | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Purchase credit memo lines | low | +| purchaseCreditMemos | purchasing | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Purchase credit memos | low | +| purchaseInvoiceLines | purchasing | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Purchase invoice lines | low | +| purchaseInvoices | purchasing | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Purchase invoices | low | +| purchaseOrderLines | purchasing | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Purchase order lines | low | +| purchaseOrders | purchasing | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Purchase orders | low | +| purchaseReceiptLines | purchasing | false | ["GET"] | id | | | | Purchase receipt lines | low | +| purchaseReceipts | purchasing | false | ["GET"] | id | | | | Purchase receipts | low | +| retainedEarningsStatements | financials | false | ["GET"] | lineNumber | | | | Retained earnings statement | low | +| salesCreditMemoLines | sales | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Sales credit memo lines | low | +| salesCreditMemos | sales | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Sales credit memos | low | +| salesInvoiceLines | sales | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Sales invoice lines | low | +| salesInvoices | sales | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Sales invoices | low | +| salesOrderLines | sales | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Sales order lines | low | +| salesOrders | sales | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Sales orders | low | +| salesQuoteLines | sales | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Sales quote lines | low | +| salesQuotes | sales | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Sales quotes | low | +| salesShipmentLines | sales | false | ["GET"] | id | | | | Sales shipment lines | low | +| salesShipments | sales | false | ["GET"] | id | | | | Posted sales shipments | low | +| shipmentMethods | setup | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Shipment methods | low | +| taxAreas | setup | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Tax areas | low | +| taxGroups | setup | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Tax groups | low | +| timeRegistrationEntries | hr | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Time registration entries | low | +| trialBalances | financials | false | ["GET"] | accountId | | | | Trial balance report data | low | +| unitsOfMeasure | setup | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Units of measure | low | +| vendorPaymentJournals | purchasing | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Vendor payment journal batches | low | +| vendorPayments | purchasing | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Vendor payment journal lines | low | +| vendorPurchases | purchasing | false | ["GET"] | vendorId | | | | Vendor purchase summary | low | +| vendors | purchasing | false | ["GET", "POST", "PATCH", "DELETE"] | id | | | | Vendor master data | low | diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-ndjson.code b/rust/crates/bcli-cli/tests/fixtures/golden/list-ndjson.code new file mode 100644 index 0000000..573541a --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/list-ndjson.code @@ -0,0 +1 @@ +0 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-ndjson.err b/rust/crates/bcli-cli/tests/fixtures/golden/list-ndjson.err new file mode 100644 index 0000000..e69de29 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-ndjson.out b/rust/crates/bcli-cli/tests/fixtures/golden/list-ndjson.out new file mode 100644 index 0000000..9b452ae --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/list-ndjson.out @@ -0,0 +1,81 @@ +{"name": "customers", "category": "sales", "custom": true, "supported_ops": ["GET"], "key_field": "id", "publisher": "acme", "group": "sales", "version": "v2.0", "description": "Custom customers", "caution": "high"} +{"name": "widgetLines", "category": "ops", "custom": true, "supported_ops": ["GET", "PATCH"], "key_field": "id", "publisher": "acme", "group": "ops", "version": "v1.0", "description": "Widget lines \u2014 \u00dcberpr\u00fcfung", "caution": "low"} +{"name": "accountingPeriods", "category": "financials", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Accounting periods", "caution": "low"} +{"name": "accounts", "category": "financials", "custom": false, "supported_ops": ["GET"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "G/L accounts (chart of accounts)", "caution": "low"} +{"name": "agedAccountsPayables", "category": "purchasing", "custom": false, "supported_ops": ["GET"], "key_field": "vendorId", "publisher": null, "group": null, "version": null, "description": "Aged accounts payable", "caution": "low"} +{"name": "agedAccountsReceivables", "category": "sales", "custom": false, "supported_ops": ["GET"], "key_field": "customerId", "publisher": null, "group": null, "version": null, "description": "Aged accounts receivable", "caution": "low"} +{"name": "applyVendorEntries", "category": "purchasing", "custom": false, "supported_ops": ["GET", "POST"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Apply vendor ledger entries", "caution": "low"} +{"name": "attachments", "category": "setup", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "File attachments", "caution": "low"} +{"name": "balanceSheets", "category": "financials", "custom": false, "supported_ops": ["GET"], "key_field": "lineNumber", "publisher": null, "group": null, "version": null, "description": "Balance sheet report data", "caution": "low"} +{"name": "bankAccounts", "category": "financials", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Bank accounts", "caution": "low"} +{"name": "cashFlowStatements", "category": "financials", "custom": false, "supported_ops": ["GET"], "key_field": "lineNumber", "publisher": null, "group": null, "version": null, "description": "Cash flow statement report data", "caution": "low"} +{"name": "companies", "category": "setup", "custom": false, "supported_ops": ["GET"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Companies in the BC environment", "caution": "low"} +{"name": "companyInformation", "category": "setup", "custom": false, "supported_ops": ["GET", "PATCH"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Company information and settings", "caution": "low"} +{"name": "contacts", "category": "sales", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Contacts", "caution": "low"} +{"name": "contactsInformation", "category": "sales", "custom": false, "supported_ops": ["GET"], "key_field": "contactId", "publisher": null, "group": null, "version": null, "description": "Contact information details", "caution": "low"} +{"name": "countriesRegions", "category": "setup", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Countries and regions", "caution": "low"} +{"name": "currencies", "category": "setup", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Currencies", "caution": "low"} +{"name": "currencyExchangeRates", "category": "setup", "custom": false, "supported_ops": ["GET"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Currency exchange rates", "caution": "low"} +{"name": "customerContacts", "category": "sales", "custom": false, "supported_ops": ["GET", "POST", "PATCH"], "key_field": "contactId", "publisher": null, "group": null, "version": null, "description": "Customer contact relationships", "caution": "low"} +{"name": "customerFinancialDetails", "category": "sales", "custom": false, "supported_ops": ["GET"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Customer financial summary", "caution": "low"} +{"name": "customerPaymentJournals", "category": "sales", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Customer payment journal batches", "caution": "low"} +{"name": "customerPayments", "category": "sales", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Customer payment journal lines", "caution": "low"} +{"name": "customerReturnReasons", "category": "sales", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Customer return reason codes", "caution": "low"} +{"name": "customerSales", "category": "sales", "custom": false, "supported_ops": ["GET"], "key_field": "customerId", "publisher": null, "group": null, "version": null, "description": "Customer sales summary", "caution": "low"} +{"name": "customers", "category": "sales", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Customer master data", "caution": "low"} +{"name": "defaultDimensions", "category": "dimensions", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "parentId", "publisher": null, "group": null, "version": null, "description": "Default dimensions for entities", "caution": "low"} +{"name": "dimensionSetLines", "category": "dimensions", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Dimension set line entries", "caution": "low"} +{"name": "dimensionValues", "category": "dimensions", "custom": false, "supported_ops": ["GET"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Dimension values", "caution": "low"} +{"name": "dimensions", "category": "dimensions", "custom": false, "supported_ops": ["GET"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Dimensions", "caution": "low"} +{"name": "disputeStatus", "category": "setup", "custom": false, "supported_ops": ["GET"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Dispute status codes", "caution": "low"} +{"name": "documentAttachments", "category": "setup", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Document-level attachments", "caution": "low"} +{"name": "employees", "category": "hr", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Employee records", "caution": "low"} +{"name": "fixedAssetLocations", "category": "inventory", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Fixed asset location codes", "caution": "low"} +{"name": "fixedAssets", "category": "inventory", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Fixed asset records", "caution": "low"} +{"name": "generalLedgerEntries", "category": "financials", "custom": false, "supported_ops": ["GET"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "G/L ledger entries", "caution": "low"} +{"name": "generalLedgerSetup", "category": "financials", "custom": false, "supported_ops": ["GET"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "G/L setup configuration", "caution": "low"} +{"name": "generalProductPostingGroups", "category": "setup", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "General product posting groups", "caution": "low"} +{"name": "incomeStatements", "category": "financials", "custom": false, "supported_ops": ["GET"], "key_field": "lineNumber", "publisher": null, "group": null, "version": null, "description": "Income statement report data", "caution": "low"} +{"name": "inventoryPostingGroups", "category": "setup", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Inventory posting groups", "caution": "low"} +{"name": "itemCategories", "category": "inventory", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Item categories", "caution": "low"} +{"name": "itemLedgerEntries", "category": "inventory", "custom": false, "supported_ops": ["GET"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Item ledger entries", "caution": "low"} +{"name": "itemVariants", "category": "inventory", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Item variants", "caution": "low"} +{"name": "items", "category": "inventory", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Item master data", "caution": "low"} +{"name": "journalLines", "category": "financials", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "General journal lines", "caution": "low"} +{"name": "journals", "category": "financials", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "General journal batches", "caution": "low"} +{"name": "locations", "category": "inventory", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Inventory locations", "caution": "low"} +{"name": "opportunities", "category": "sales", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Sales opportunities", "caution": "low"} +{"name": "paymentMethods", "category": "setup", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Payment methods", "caution": "low"} +{"name": "paymentTerms", "category": "setup", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Payment terms", "caution": "low"} +{"name": "pdfDocument", "category": "setup", "custom": false, "supported_ops": ["GET"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "PDF document generation for posted documents", "caution": "low"} +{"name": "pictures", "category": "setup", "custom": false, "supported_ops": ["GET", "POST", "PATCH"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Entity pictures/images", "caution": "low"} +{"name": "projects", "category": "projects", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Projects (jobs)", "caution": "low"} +{"name": "purchaseCreditMemoLines", "category": "purchasing", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Purchase credit memo lines", "caution": "low"} +{"name": "purchaseCreditMemos", "category": "purchasing", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Purchase credit memos", "caution": "low"} +{"name": "purchaseInvoiceLines", "category": "purchasing", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Purchase invoice lines", "caution": "low"} +{"name": "purchaseInvoices", "category": "purchasing", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Purchase invoices", "caution": "low"} +{"name": "purchaseOrderLines", "category": "purchasing", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Purchase order lines", "caution": "low"} +{"name": "purchaseOrders", "category": "purchasing", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Purchase orders", "caution": "low"} +{"name": "purchaseReceiptLines", "category": "purchasing", "custom": false, "supported_ops": ["GET"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Purchase receipt lines", "caution": "low"} +{"name": "purchaseReceipts", "category": "purchasing", "custom": false, "supported_ops": ["GET"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Purchase receipts", "caution": "low"} +{"name": "retainedEarningsStatements", "category": "financials", "custom": false, "supported_ops": ["GET"], "key_field": "lineNumber", "publisher": null, "group": null, "version": null, "description": "Retained earnings statement", "caution": "low"} +{"name": "salesCreditMemoLines", "category": "sales", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Sales credit memo lines", "caution": "low"} +{"name": "salesCreditMemos", "category": "sales", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Sales credit memos", "caution": "low"} +{"name": "salesInvoiceLines", "category": "sales", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Sales invoice lines", "caution": "low"} +{"name": "salesInvoices", "category": "sales", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Sales invoices", "caution": "low"} +{"name": "salesOrderLines", "category": "sales", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Sales order lines", "caution": "low"} +{"name": "salesOrders", "category": "sales", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Sales orders", "caution": "low"} +{"name": "salesQuoteLines", "category": "sales", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Sales quote lines", "caution": "low"} +{"name": "salesQuotes", "category": "sales", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Sales quotes", "caution": "low"} +{"name": "salesShipmentLines", "category": "sales", "custom": false, "supported_ops": ["GET"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Sales shipment lines", "caution": "low"} +{"name": "salesShipments", "category": "sales", "custom": false, "supported_ops": ["GET"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Posted sales shipments", "caution": "low"} +{"name": "shipmentMethods", "category": "setup", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Shipment methods", "caution": "low"} +{"name": "taxAreas", "category": "setup", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Tax areas", "caution": "low"} +{"name": "taxGroups", "category": "setup", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Tax groups", "caution": "low"} +{"name": "timeRegistrationEntries", "category": "hr", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Time registration entries", "caution": "low"} +{"name": "trialBalances", "category": "financials", "custom": false, "supported_ops": ["GET"], "key_field": "accountId", "publisher": null, "group": null, "version": null, "description": "Trial balance report data", "caution": "low"} +{"name": "unitsOfMeasure", "category": "setup", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Units of measure", "caution": "low"} +{"name": "vendorPaymentJournals", "category": "purchasing", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Vendor payment journal batches", "caution": "low"} +{"name": "vendorPayments", "category": "purchasing", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Vendor payment journal lines", "caution": "low"} +{"name": "vendorPurchases", "category": "purchasing", "custom": false, "supported_ops": ["GET"], "key_field": "vendorId", "publisher": null, "group": null, "version": null, "description": "Vendor purchase summary", "caution": "low"} +{"name": "vendors", "category": "purchasing", "custom": false, "supported_ops": ["GET", "POST", "PATCH", "DELETE"], "key_field": "id", "publisher": null, "group": null, "version": null, "description": "Vendor master data", "caution": "low"} diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-raw.code b/rust/crates/bcli-cli/tests/fixtures/golden/list-raw.code new file mode 100644 index 0000000..573541a --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/list-raw.code @@ -0,0 +1 @@ +0 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-raw.err b/rust/crates/bcli-cli/tests/fixtures/golden/list-raw.err new file mode 100644 index 0000000..e69de29 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-raw.out b/rust/crates/bcli-cli/tests/fixtures/golden/list-raw.out new file mode 100644 index 0000000..224938b --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/list-raw.out @@ -0,0 +1,1295 @@ +{ + "value": [ + { + "name": "customers", + "category": "sales", + "custom": true, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": "acme", + "group": "sales", + "version": "v2.0", + "description": "Custom customers", + "caution": "high" + }, + { + "name": "widgetLines", + "category": "ops", + "custom": true, + "supported_ops": [ + "GET", + "PATCH" + ], + "key_field": "id", + "publisher": "acme", + "group": "ops", + "version": "v1.0", + "description": "Widget lines \u2014 \u00dcberpr\u00fcfung", + "caution": "low" + }, + { + "name": "accountingPeriods", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Accounting periods", + "caution": "low" + }, + { + "name": "accounts", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "G/L accounts (chart of accounts)", + "caution": "low" + }, + { + "name": "agedAccountsPayables", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "vendorId", + "publisher": null, + "group": null, + "version": null, + "description": "Aged accounts payable", + "caution": "low" + }, + { + "name": "agedAccountsReceivables", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "customerId", + "publisher": null, + "group": null, + "version": null, + "description": "Aged accounts receivable", + "caution": "low" + }, + { + "name": "applyVendorEntries", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET", + "POST" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Apply vendor ledger entries", + "caution": "low" + }, + { + "name": "attachments", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "File attachments", + "caution": "low" + }, + { + "name": "balanceSheets", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "lineNumber", + "publisher": null, + "group": null, + "version": null, + "description": "Balance sheet report data", + "caution": "low" + }, + { + "name": "bankAccounts", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Bank accounts", + "caution": "low" + }, + { + "name": "cashFlowStatements", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "lineNumber", + "publisher": null, + "group": null, + "version": null, + "description": "Cash flow statement report data", + "caution": "low" + }, + { + "name": "companies", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Companies in the BC environment", + "caution": "low" + }, + { + "name": "companyInformation", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "PATCH" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Company information and settings", + "caution": "low" + }, + { + "name": "contacts", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Contacts", + "caution": "low" + }, + { + "name": "contactsInformation", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "contactId", + "publisher": null, + "group": null, + "version": null, + "description": "Contact information details", + "caution": "low" + }, + { + "name": "countriesRegions", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Countries and regions", + "caution": "low" + }, + { + "name": "currencies", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Currencies", + "caution": "low" + }, + { + "name": "currencyExchangeRates", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Currency exchange rates", + "caution": "low" + }, + { + "name": "customerContacts", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH" + ], + "key_field": "contactId", + "publisher": null, + "group": null, + "version": null, + "description": "Customer contact relationships", + "caution": "low" + }, + { + "name": "customerFinancialDetails", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Customer financial summary", + "caution": "low" + }, + { + "name": "customerPaymentJournals", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Customer payment journal batches", + "caution": "low" + }, + { + "name": "customerPayments", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Customer payment journal lines", + "caution": "low" + }, + { + "name": "customerReturnReasons", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Customer return reason codes", + "caution": "low" + }, + { + "name": "customerSales", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "customerId", + "publisher": null, + "group": null, + "version": null, + "description": "Customer sales summary", + "caution": "low" + }, + { + "name": "customers", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Customer master data", + "caution": "low" + }, + { + "name": "defaultDimensions", + "category": "dimensions", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "parentId", + "publisher": null, + "group": null, + "version": null, + "description": "Default dimensions for entities", + "caution": "low" + }, + { + "name": "dimensionSetLines", + "category": "dimensions", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Dimension set line entries", + "caution": "low" + }, + { + "name": "dimensionValues", + "category": "dimensions", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Dimension values", + "caution": "low" + }, + { + "name": "dimensions", + "category": "dimensions", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Dimensions", + "caution": "low" + }, + { + "name": "disputeStatus", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Dispute status codes", + "caution": "low" + }, + { + "name": "documentAttachments", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Document-level attachments", + "caution": "low" + }, + { + "name": "employees", + "category": "hr", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Employee records", + "caution": "low" + }, + { + "name": "fixedAssetLocations", + "category": "inventory", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Fixed asset location codes", + "caution": "low" + }, + { + "name": "fixedAssets", + "category": "inventory", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Fixed asset records", + "caution": "low" + }, + { + "name": "generalLedgerEntries", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "G/L ledger entries", + "caution": "low" + }, + { + "name": "generalLedgerSetup", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "G/L setup configuration", + "caution": "low" + }, + { + "name": "generalProductPostingGroups", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "General product posting groups", + "caution": "low" + }, + { + "name": "incomeStatements", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "lineNumber", + "publisher": null, + "group": null, + "version": null, + "description": "Income statement report data", + "caution": "low" + }, + { + "name": "inventoryPostingGroups", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Inventory posting groups", + "caution": "low" + }, + { + "name": "itemCategories", + "category": "inventory", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Item categories", + "caution": "low" + }, + { + "name": "itemLedgerEntries", + "category": "inventory", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Item ledger entries", + "caution": "low" + }, + { + "name": "itemVariants", + "category": "inventory", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Item variants", + "caution": "low" + }, + { + "name": "items", + "category": "inventory", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Item master data", + "caution": "low" + }, + { + "name": "journalLines", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "General journal lines", + "caution": "low" + }, + { + "name": "journals", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "General journal batches", + "caution": "low" + }, + { + "name": "locations", + "category": "inventory", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Inventory locations", + "caution": "low" + }, + { + "name": "opportunities", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Sales opportunities", + "caution": "low" + }, + { + "name": "paymentMethods", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Payment methods", + "caution": "low" + }, + { + "name": "paymentTerms", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Payment terms", + "caution": "low" + }, + { + "name": "pdfDocument", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "PDF document generation for posted documents", + "caution": "low" + }, + { + "name": "pictures", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Entity pictures/images", + "caution": "low" + }, + { + "name": "projects", + "category": "projects", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Projects (jobs)", + "caution": "low" + }, + { + "name": "purchaseCreditMemoLines", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Purchase credit memo lines", + "caution": "low" + }, + { + "name": "purchaseCreditMemos", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Purchase credit memos", + "caution": "low" + }, + { + "name": "purchaseInvoiceLines", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Purchase invoice lines", + "caution": "low" + }, + { + "name": "purchaseInvoices", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Purchase invoices", + "caution": "low" + }, + { + "name": "purchaseOrderLines", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Purchase order lines", + "caution": "low" + }, + { + "name": "purchaseOrders", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Purchase orders", + "caution": "low" + }, + { + "name": "purchaseReceiptLines", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Purchase receipt lines", + "caution": "low" + }, + { + "name": "purchaseReceipts", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Purchase receipts", + "caution": "low" + }, + { + "name": "retainedEarningsStatements", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "lineNumber", + "publisher": null, + "group": null, + "version": null, + "description": "Retained earnings statement", + "caution": "low" + }, + { + "name": "salesCreditMemoLines", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Sales credit memo lines", + "caution": "low" + }, + { + "name": "salesCreditMemos", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Sales credit memos", + "caution": "low" + }, + { + "name": "salesInvoiceLines", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Sales invoice lines", + "caution": "low" + }, + { + "name": "salesInvoices", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Sales invoices", + "caution": "low" + }, + { + "name": "salesOrderLines", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Sales order lines", + "caution": "low" + }, + { + "name": "salesOrders", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Sales orders", + "caution": "low" + }, + { + "name": "salesQuoteLines", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Sales quote lines", + "caution": "low" + }, + { + "name": "salesQuotes", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Sales quotes", + "caution": "low" + }, + { + "name": "salesShipmentLines", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Sales shipment lines", + "caution": "low" + }, + { + "name": "salesShipments", + "category": "sales", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Posted sales shipments", + "caution": "low" + }, + { + "name": "shipmentMethods", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Shipment methods", + "caution": "low" + }, + { + "name": "taxAreas", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Tax areas", + "caution": "low" + }, + { + "name": "taxGroups", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Tax groups", + "caution": "low" + }, + { + "name": "timeRegistrationEntries", + "category": "hr", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Time registration entries", + "caution": "low" + }, + { + "name": "trialBalances", + "category": "financials", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "accountId", + "publisher": null, + "group": null, + "version": null, + "description": "Trial balance report data", + "caution": "low" + }, + { + "name": "unitsOfMeasure", + "category": "setup", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Units of measure", + "caution": "low" + }, + { + "name": "vendorPaymentJournals", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Vendor payment journal batches", + "caution": "low" + }, + { + "name": "vendorPayments", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Vendor payment journal lines", + "caution": "low" + }, + { + "name": "vendorPurchases", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET" + ], + "key_field": "vendorId", + "publisher": null, + "group": null, + "version": null, + "description": "Vendor purchase summary", + "caution": "low" + }, + { + "name": "vendors", + "category": "purchasing", + "custom": false, + "supported_ops": [ + "GET", + "POST", + "PATCH", + "DELETE" + ], + "key_field": "id", + "publisher": null, + "group": null, + "version": null, + "description": "Vendor master data", + "caution": "low" + } + ] +} diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-scoped-profile.code b/rust/crates/bcli-cli/tests/fixtures/golden/list-scoped-profile.code new file mode 100644 index 0000000..573541a --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/list-scoped-profile.code @@ -0,0 +1 @@ +0 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-scoped-profile.err b/rust/crates/bcli-cli/tests/fixtures/golden/list-scoped-profile.err new file mode 100644 index 0000000..e69de29 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/list-scoped-profile.out b/rust/crates/bcli-cli/tests/fixtures/golden/list-scoped-profile.out new file mode 100644 index 0000000..8daf4be --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/list-scoped-profile.out @@ -0,0 +1,17 @@ +[ + { + "name": "widgetLines", + "category": "ops", + "custom": true, + "supported_ops": [ + "GET", + "PATCH" + ], + "key_field": "id", + "publisher": "acme", + "group": "ops", + "version": "v1.0", + "description": "Widget lines \u2014 \u00dcberpr\u00fcfung", + "caution": "low" + } +] diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/search-none.code b/rust/crates/bcli-cli/tests/fixtures/golden/search-none.code new file mode 100644 index 0000000..573541a --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/search-none.code @@ -0,0 +1 @@ +0 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/search-none.err b/rust/crates/bcli-cli/tests/fixtures/golden/search-none.err new file mode 100644 index 0000000..e69de29 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/search-none.out b/rust/crates/bcli-cli/tests/fixtures/golden/search-none.out new file mode 100644 index 0000000..75877a3 --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/search-none.out @@ -0,0 +1 @@ +No endpoints matching 'zzzz' diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/version.code b/rust/crates/bcli-cli/tests/fixtures/golden/version.code new file mode 100644 index 0000000..573541a --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/version.code @@ -0,0 +1 @@ +0 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/version.err b/rust/crates/bcli-cli/tests/fixtures/golden/version.err new file mode 100644 index 0000000..e69de29 diff --git a/rust/crates/bcli-cli/tests/fixtures/golden/version.out b/rust/crates/bcli-cli/tests/fixtures/golden/version.out new file mode 100644 index 0000000..06b6127 --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/golden/version.out @@ -0,0 +1 @@ +bcli 0.8.2 diff --git a/rust/crates/bcli-cli/tests/fixtures/home/.config/bcli/config.toml b/rust/crates/bcli-cli/tests/fixtures/home/.config/bcli/config.toml new file mode 100644 index 0000000..5402f9b --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/home/.config/bcli/config.toml @@ -0,0 +1,19 @@ +[defaults] +profile = "prod" + +[profiles.prod] +tenant_id = "00000000-0000-0000-0000-000000000001" +environment = "Production" +company_id = "11111111-1111-1111-1111-111111111111" +auth_method = "client_credentials" +client_id = "00000000-0000-0000-0000-000000000002" + +[profiles.prod.companies.LLC] +id = "22222222-2222-2222-2222-222222222222" +name = "Contoso LLC" + +[profiles.scoped] +tenant_id = "t" +environment = "Sandbox" +disable_standard_api = true +allowed_categories = ["ops"] diff --git a/rust/crates/bcli-cli/tests/fixtures/home/.config/bcli/last-error.json b/rust/crates/bcli-cli/tests/fixtures/home/.config/bcli/last-error.json new file mode 100644 index 0000000..05b4616 --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/home/.config/bcli/last-error.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1.0", + "timestamp": "2026-10-01T00:08:30+00:00", + "command": "prdo endpoint", + "error_class": "ConfigError", + "exit_code": 2, + "status": 0, + "profile": "prdo", + "environment": "", + "company": "", + "url": "", + "method": "", + "correlation_id": "", + "endpoint": "", + "hint": "", + "bc_message": "", + "traceback_excerpt": "" +} \ No newline at end of file diff --git a/rust/crates/bcli-cli/tests/fixtures/home/.config/bcli/registries/prod.json b/rust/crates/bcli-cli/tests/fixtures/home/.config/bcli/registries/prod.json new file mode 100644 index 0000000..625b32f --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/home/.config/bcli/registries/prod.json @@ -0,0 +1,6 @@ +{"endpoints": [ + {"entity_set_name": "widgetLines", "entity_name": "widgetLine", "description": "Widget lines — Überprüfung", "category": "ops", + "api_publisher": "acme", "api_group": "ops", "api_version": "v1.0", "supports": ["GET", "PATCH"], "field_names": ["no", "qty"], "source_table": "50100"}, + {"entity_set_name": "customers", "description": "Custom customers", "category": "sales", + "api_publisher": "acme", "api_group": "sales", "api_version": "v2.0", "caution": "high"} +]} diff --git a/rust/crates/bcli-cli/tests/fixtures/home/.config/bcli/registries/scoped.json b/rust/crates/bcli-cli/tests/fixtures/home/.config/bcli/registries/scoped.json new file mode 100644 index 0000000..625b32f --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/home/.config/bcli/registries/scoped.json @@ -0,0 +1,6 @@ +{"endpoints": [ + {"entity_set_name": "widgetLines", "entity_name": "widgetLine", "description": "Widget lines — Überprüfung", "category": "ops", + "api_publisher": "acme", "api_group": "ops", "api_version": "v1.0", "supports": ["GET", "PATCH"], "field_names": ["no", "qty"], "source_table": "50100"}, + {"entity_set_name": "customers", "description": "Custom customers", "category": "sales", + "api_publisher": "acme", "api_group": "sales", "api_version": "v2.0", "caution": "high"} +]} diff --git a/rust/crates/bcli-cli/tests/fixtures/python-cli-surface.json b/rust/crates/bcli-cli/tests/fixtures/python-cli-surface.json new file mode 100644 index 0000000..e1fd8df --- /dev/null +++ b/rust/crates/bcli-cli/tests/fixtures/python-cli-surface.json @@ -0,0 +1,2846 @@ +[ + { + "path": [], + "group": true, + "options": [ + { + "long": [ + "--profile" + ], + "short": [ + "-p" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--env" + ], + "short": [ + "-e" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--company" + ], + "short": [ + "-c" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--format" + ], + "short": [ + "-f" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--verbose" + ], + "short": [ + "-v" + ], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--debug" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--dry-run" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--quiet" + ], + "short": [ + "-q" + ], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--version" + ], + "short": [ + "-V" + ], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--install-completion" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--show-completion" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [] + }, + { + "path": [ + "action" + ], + "group": false, + "options": [ + { + "long": [ + "--data" + ], + "short": [ + "-d" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--no-data" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--namespace" + ], + "short": [ + "-n" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--format" + ], + "short": [ + "-f" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--publisher" + ], + "short": [], + "flag": false, + "hidden": true, + "multiple": false, + "required": false + }, + { + "long": [ + "--group" + ], + "short": [], + "flag": false, + "hidden": true, + "multiple": false, + "required": false + }, + { + "long": [ + "--version" + ], + "short": [], + "flag": false, + "hidden": true, + "multiple": false, + "required": false + }, + { + "long": [ + "--yes" + ], + "short": [ + "-y" + ], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--out" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--overwrite" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--result-out" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--result-fd" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--idempotency-key" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [ + { + "name": "entity_set", + "required": true, + "nargs": 1 + }, + { + "name": "key", + "required": true, + "nargs": 1 + }, + { + "name": "action_name", + "required": true, + "nargs": 1 + } + ] + }, + { + "path": [ + "ai-context" + ], + "group": false, + "options": [], + "positionals": [] + }, + { + "path": [ + "ask" + ], + "group": false, + "options": [ + { + "long": [ + "--no-context" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--attach" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": true, + "required": false + }, + { + "long": [ + "--backend" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--dry-run" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--include-bodies" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--include-debug" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--max-tokens" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [ + { + "name": "question", + "required": true, + "nargs": 1 + } + ] + }, + { + "path": [ + "attach" + ], + "group": true, + "options": [], + "positionals": [] + }, + { + "path": [ + "attach", + "test" + ], + "group": false, + "options": [ + { + "long": [ + "--vendor-id" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": true + }, + { + "long": [ + "--invoice-date" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--file-name" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--content-type" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--publisher" + ], + "short": [], + "flag": false, + "hidden": true, + "multiple": false, + "required": false + }, + { + "long": [ + "--group" + ], + "short": [], + "flag": false, + "hidden": true, + "multiple": false, + "required": false + }, + { + "long": [ + "--version" + ], + "short": [], + "flag": false, + "hidden": true, + "multiple": false, + "required": false + }, + { + "long": [ + "--no-registry", + "--standard" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--format" + ], + "short": [ + "-f" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [ + { + "name": "file_path", + "required": true, + "nargs": 1 + } + ] + }, + { + "path": [ + "attach", + "upload" + ], + "group": false, + "options": [ + { + "long": [ + "--parent-id" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": true + }, + { + "long": [ + "--parent-type" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--file-name" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--content-type" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--publisher" + ], + "short": [], + "flag": false, + "hidden": true, + "multiple": false, + "required": false + }, + { + "long": [ + "--group" + ], + "short": [], + "flag": false, + "hidden": true, + "multiple": false, + "required": false + }, + { + "long": [ + "--version" + ], + "short": [], + "flag": false, + "hidden": true, + "multiple": false, + "required": false + }, + { + "long": [ + "--no-registry", + "--standard" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--format" + ], + "short": [ + "-f" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--yes" + ], + "short": [ + "-y" + ], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--result-out" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--result-fd" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--idempotency-key" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [ + { + "name": "file_path", + "required": true, + "nargs": 1 + } + ] + }, + { + "path": [ + "auth" + ], + "group": true, + "options": [], + "positionals": [] + }, + { + "path": [ + "auth", + "delete-secret" + ], + "group": false, + "options": [], + "positionals": [] + }, + { + "path": [ + "auth", + "login" + ], + "group": false, + "options": [ + { + "long": [ + "--method" + ], + "short": [ + "-m" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--incognito" + ], + "short": [ + "-i" + ], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [] + }, + { + "path": [ + "auth", + "logout" + ], + "group": false, + "options": [], + "positionals": [] + }, + { + "path": [ + "auth", + "status" + ], + "group": false, + "options": [], + "positionals": [] + }, + { + "path": [ + "auth", + "store-secret" + ], + "group": false, + "options": [], + "positionals": [] + }, + { + "path": [ + "batch" + ], + "group": true, + "options": [], + "positionals": [] + }, + { + "path": [ + "batch", + "list" + ], + "group": false, + "options": [ + { + "long": [ + "--state" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--limit" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--format" + ], + "short": [ + "-f" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [] + }, + { + "path": [ + "batch", + "rollback" + ], + "group": false, + "options": [ + { + "long": [ + "--dry-run" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--yes" + ], + "short": [ + "-y" + ], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [ + { + "name": "run_id", + "required": true, + "nargs": 1 + } + ] + }, + { + "path": [ + "batch", + "run" + ], + "group": false, + "options": [ + { + "long": [ + "--dry-run" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--output" + ], + "short": [ + "-o" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--format" + ], + "short": [ + "-f" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--set" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": true, + "required": false + }, + { + "long": [ + "--params" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--yes" + ], + "short": [ + "-y" + ], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--result-out" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--result-fd" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--progress-fd" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [ + { + "name": "file", + "required": true, + "nargs": 1 + } + ] + }, + { + "path": [ + "batch", + "state" + ], + "group": false, + "options": [ + { + "long": [ + "--format" + ], + "short": [ + "-f" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [ + { + "name": "run_id", + "required": true, + "nargs": 1 + } + ] + }, + { + "path": [ + "company" + ], + "group": true, + "options": [], + "positionals": [] + }, + { + "path": [ + "company", + "alias" + ], + "group": false, + "options": [ + { + "long": [ + "--name" + ], + "short": [ + "-n" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [ + { + "name": "name", + "required": true, + "nargs": 1 + }, + { + "name": "company_id", + "required": true, + "nargs": 1 + } + ] + }, + { + "path": [ + "company", + "aliases" + ], + "group": false, + "options": [], + "positionals": [] + }, + { + "path": [ + "company", + "aliases-import" + ], + "group": false, + "options": [ + { + "long": [ + "--from" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": true + }, + { + "long": [ + "--overwrite" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--dry-run" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [] + }, + { + "path": [ + "company", + "list" + ], + "group": false, + "options": [ + { + "long": [ + "--format" + ], + "short": [ + "-f" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [] + }, + { + "path": [ + "company", + "use" + ], + "group": false, + "options": [], + "positionals": [ + { + "name": "company", + "required": true, + "nargs": 1 + } + ] + }, + { + "path": [ + "config" + ], + "group": true, + "options": [], + "positionals": [] + }, + { + "path": [ + "config", + "bundle-status" + ], + "group": false, + "options": [ + { + "long": [ + "--profile" + ], + "short": [ + "-p" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [] + }, + { + "path": [ + "config", + "edit" + ], + "group": false, + "options": [], + "positionals": [] + }, + { + "path": [ + "config", + "init" + ], + "group": false, + "options": [ + { + "long": [ + "--profile" + ], + "short": [ + "-p" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--auth" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--automation" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--headless" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--scoped" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--category" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": true, + "required": false + }, + { + "long": [ + "--import" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [] + }, + { + "path": [ + "config", + "make-bundle" + ], + "group": false, + "options": [ + { + "long": [ + "--profile" + ], + "short": [ + "-p" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": true + }, + { + "long": [ + "--version" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": true + }, + { + "long": [ + "--publisher" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--notes" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--previous" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--output" + ], + "short": [ + "-o" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [ + { + "name": "source_dir", + "required": true, + "nargs": 1 + } + ] + }, + { + "path": [ + "config", + "path" + ], + "group": false, + "options": [], + "positionals": [] + }, + { + "path": [ + "config", + "refresh" + ], + "group": false, + "options": [ + { + "long": [ + "--profile" + ], + "short": [ + "-p" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--url" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--dry-run" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--skip-verify" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [] + }, + { + "path": [ + "config", + "rollback" + ], + "group": false, + "options": [ + { + "long": [ + "--profile" + ], + "short": [ + "-p" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [] + }, + { + "path": [ + "config", + "set" + ], + "group": false, + "options": [], + "positionals": [ + { + "name": "key", + "required": true, + "nargs": 1 + }, + { + "name": "value", + "required": true, + "nargs": 1 + } + ] + }, + { + "path": [ + "config", + "show" + ], + "group": false, + "options": [], + "positionals": [] + }, + { + "path": [ + "config", + "use" + ], + "group": false, + "options": [], + "positionals": [ + { + "name": "name", + "required": true, + "nargs": 1 + } + ] + }, + { + "path": [ + "delete" + ], + "group": false, + "options": [ + { + "long": [ + "--etag" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--format" + ], + "short": [ + "-f" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--publisher" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--group" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--version" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--yes" + ], + "short": [ + "-y" + ], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--result-out" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--result-fd" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--idempotency-key" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [ + { + "name": "endpoint", + "required": true, + "nargs": 1 + }, + { + "name": "record_id", + "required": true, + "nargs": 1 + } + ] + }, + { + "path": [ + "describe" + ], + "group": false, + "options": [ + { + "long": [ + "--format" + ], + "short": [ + "-f" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [ + { + "name": "command_path", + "required": false, + "nargs": -1 + } + ] + }, + { + "path": [ + "doctor" + ], + "group": false, + "options": [ + { + "long": [ + "--profile" + ], + "short": [ + "-p" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--json" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--skip-network" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [] + }, + { + "path": [ + "endpoint" + ], + "group": true, + "options": [], + "positionals": [] + }, + { + "path": [ + "endpoint", + "fields" + ], + "group": false, + "options": [], + "positionals": [ + { + "name": "name", + "required": true, + "nargs": 1 + } + ] + }, + { + "path": [ + "endpoint", + "info" + ], + "group": false, + "options": [ + { + "long": [ + "--format" + ], + "short": [ + "-f" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [ + { + "name": "name", + "required": true, + "nargs": 1 + } + ] + }, + { + "path": [ + "endpoint", + "list" + ], + "group": false, + "options": [ + { + "long": [ + "--custom" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--standard" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--category" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--format" + ], + "short": [ + "-f" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [] + }, + { + "path": [ + "endpoint", + "search" + ], + "group": false, + "options": [], + "positionals": [ + { + "name": "query", + "required": true, + "nargs": 1 + } + ] + }, + { + "path": [ + "env" + ], + "group": true, + "options": [], + "positionals": [] + }, + { + "path": [ + "env", + "list" + ], + "group": false, + "options": [], + "positionals": [] + }, + { + "path": [ + "env", + "use" + ], + "group": false, + "options": [], + "positionals": [ + { + "name": "name", + "required": true, + "nargs": 1 + } + ] + }, + { + "path": [ + "etl" + ], + "group": true, + "options": [], + "positionals": [] + }, + { + "path": [ + "etl", + "entities" + ], + "group": false, + "options": [ + { + "long": [ + "--include-standard" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [] + }, + { + "path": [ + "etl", + "sync" + ], + "group": false, + "options": [ + { + "long": [ + "--entities" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--destination" + ], + "short": [ + "-d" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--dataset" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--pipeline" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--full-refresh" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--include-standard" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--file-format" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--stamper" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": true, + "required": false + }, + { + "long": [ + "--polaris-uri" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--polaris-warehouse" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--polaris-credential" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--polaris-namespace" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [] + }, + { + "path": [ + "extract" + ], + "group": true, + "options": [], + "positionals": [] + }, + { + "path": [ + "extract", + "list-schemas" + ], + "group": false, + "options": [], + "positionals": [] + }, + { + "path": [ + "extract", + "run" + ], + "group": false, + "options": [ + { + "long": [ + "--schema" + ], + "short": [ + "-s" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": true + }, + { + "long": [ + "--output" + ], + "short": [ + "-o" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--sidecar" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--overwrite" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--progress-fd" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [ + { + "name": "pdf_path", + "required": true, + "nargs": 1 + } + ] + }, + { + "path": [ + "get" + ], + "group": false, + "options": [ + { + "long": [ + "--filter" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--select" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--expand" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--orderby" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--top" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--skip" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--count" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--all" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--out" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--media" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--overwrite" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--format" + ], + "short": [ + "-f" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--publisher" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--group" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--version" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [ + { + "name": "endpoint", + "required": true, + "nargs": 1 + }, + { + "name": "record_id", + "required": false, + "nargs": 1 + } + ] + }, + { + "path": [ + "pack" + ], + "group": true, + "options": [], + "positionals": [] + }, + { + "path": [ + "pack", + "info" + ], + "group": false, + "options": [ + { + "long": [ + "--profile" + ], + "short": [ + "-p" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [ + { + "name": "name", + "required": true, + "nargs": 1 + } + ] + }, + { + "path": [ + "pack", + "install" + ], + "group": false, + "options": [ + { + "long": [ + "--profile" + ], + "short": [ + "-p" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--target" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--dry-run" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--replace-owned" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--accept-conflicts" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--yes" + ], + "short": [ + "-y" + ], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [ + { + "name": "name", + "required": true, + "nargs": 1 + } + ] + }, + { + "path": [ + "pack", + "list" + ], + "group": false, + "options": [ + { + "long": [ + "--profile" + ], + "short": [ + "-p" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [] + }, + { + "path": [ + "pack", + "uninstall" + ], + "group": false, + "options": [ + { + "long": [ + "--profile" + ], + "short": [ + "-p" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--yes" + ], + "short": [ + "-y" + ], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [ + { + "name": "name", + "required": true, + "nargs": 1 + } + ] + }, + { + "path": [ + "patch" + ], + "group": false, + "options": [ + { + "long": [ + "--data" + ], + "short": [ + "-d" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": true + }, + { + "long": [ + "--etag" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--format" + ], + "short": [ + "-f" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--publisher" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--group" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--version" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--yes" + ], + "short": [ + "-y" + ], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--result-out" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--result-fd" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--idempotency-key" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [ + { + "name": "endpoint", + "required": true, + "nargs": 1 + }, + { + "name": "record_id", + "required": true, + "nargs": 1 + } + ] + }, + { + "path": [ + "post" + ], + "group": false, + "options": [ + { + "long": [ + "--data" + ], + "short": [ + "-d" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": true + }, + { + "long": [ + "--format" + ], + "short": [ + "-f" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--publisher" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--group" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--version" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--yes" + ], + "short": [ + "-y" + ], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--result-out" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--result-fd" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--idempotency-key" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [ + { + "name": "endpoint", + "required": true, + "nargs": 1 + } + ] + }, + { + "path": [ + "q" + ], + "group": false, + "options": [ + { + "long": [ + "--show" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--format" + ], + "short": [ + "-f" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [ + { + "name": "name", + "required": false, + "nargs": 1 + }, + { + "name": "params", + "required": false, + "nargs": -1 + } + ] + }, + { + "path": [ + "registry" + ], + "group": true, + "options": [], + "positionals": [] + }, + { + "path": [ + "registry", + "import" + ], + "group": false, + "options": [ + { + "long": [ + "--from-postman" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--from-json" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--from-metadata" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--profile" + ], + "short": [ + "-p" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [] + }, + { + "path": [ + "registry", + "list" + ], + "group": false, + "options": [], + "positionals": [] + }, + { + "path": [ + "skill" + ], + "group": true, + "options": [], + "positionals": [] + }, + { + "path": [ + "skill", + "init" + ], + "group": false, + "options": [ + { + "long": [ + "--profile" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--target-skills-dir" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--non-interactive" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [] + }, + { + "path": [ + "skill", + "install" + ], + "group": false, + "options": [ + { + "long": [ + "--target" + ], + "short": [ + "-t" + ], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--dry-run" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [] + }, + { + "path": [ + "skill", + "update" + ], + "group": false, + "options": [ + { + "long": [ + "--profile" + ], + "short": [], + "flag": false, + "hidden": false, + "multiple": false, + "required": false + }, + { + "long": [ + "--non-interactive" + ], + "short": [], + "flag": true, + "hidden": false, + "multiple": false, + "required": false + } + ], + "positionals": [] + }, + { + "path": [ + "test" + ], + "group": true, + "options": [], + "positionals": [] + }, + { + "path": [ + "test", + "auth" + ], + "group": false, + "options": [], + "positionals": [] + }, + { + "path": [ + "test", + "connection" + ], + "group": false, + "options": [], + "positionals": [] + }, + { + "path": [ + "test", + "endpoint" + ], + "group": false, + "options": [], + "positionals": [ + { + "name": "name", + "required": true, + "nargs": 1 + } + ] + } +] \ No newline at end of file diff --git a/rust/crates/bcli-cli/tests/golden.rs b/rust/crates/bcli-cli/tests/golden.rs new file mode 100644 index 0000000..77ed4d8 --- /dev/null +++ b/rust/crates/bcli-cli/tests/golden.rs @@ -0,0 +1,74 @@ +//! The Rust binary must reproduce the Python CLI's recorded output exactly +//! (stdout, stderr and exit code) for every case in `fixtures/golden/cases.txt`. +//! Regenerate the recordings with `rust/scripts/parity.sh`. + +use std::path::{Path, PathBuf}; + +use assert_cmd::Command; + +fn fixtures() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") +} + +fn read(path: &Path) -> String { + std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("{}: {e}", path.display())) + .replace("\r\n", "\n") +} + +#[test] +fn matches_python_golden_outputs() { + let golden = fixtures().join("golden"); + let home = fixtures().join("home"); + let cases = read(&golden.join("cases.txt")); + let mut failures = Vec::new(); + let mut count = 0; + + for line in cases + .lines() + .filter(|l| !l.trim().is_empty() && !l.starts_with('#')) + { + let (name, args) = line.split_once('|').expect("case line is `name | args`"); + let (name, args) = (name.trim(), args.split_whitespace().collect::>()); + count += 1; + + let output = Command::cargo_bin("bcli") + .unwrap() + .args(&args) + .env_clear() + .env("HOME", &home) + .env("USERPROFILE", &home) + .current_dir(&home) + .output() + .unwrap(); + + let actual_out = String::from_utf8_lossy(&output.stdout).replace("\r\n", "\n"); + let actual_err = String::from_utf8_lossy(&output.stderr).replace("\r\n", "\n"); + let actual_code = output.status.code().unwrap_or(-1).to_string(); + let expect = |ext: &str| read(&golden.join(format!("{name}.{ext}"))); + + // CSV is CRLF on both sides; compare it byte-for-byte separately. + if args.contains(&"csv") { + let raw = std::fs::read(golden.join(format!("{name}.out"))).unwrap(); + if raw != output.stdout { + failures.push(format!("{name}: csv bytes differ")); + } + } + for (what, expected, actual) in [ + ("stdout", expect("out"), actual_out), + ("stderr", expect("err"), actual_err), + ("exit code", expect("code").trim().to_string(), actual_code), + ] { + if expected != actual { + failures.push(format!( + "{name}: {what} differs\n--- python\n{expected}\n--- rust\n{actual}" + )); + } + } + } + + assert!(count >= 10, "expected the golden cases to be discovered"); + assert!(failures.is_empty(), "{}", failures.join("\n\n")); +} diff --git a/rust/crates/bcli-cli/tests/surface_parity.rs b/rust/crates/bcli-cli/tests/surface_parity.rs new file mode 100644 index 0000000..dcb567f --- /dev/null +++ b/rust/crates/bcli-cli/tests/surface_parity.rs @@ -0,0 +1,188 @@ +//! The clap tree must declare the same commands, positionals and options as +//! the Python Typer app. `fixtures/python-cli-surface.json` is produced by +//! `rust/scripts/dump_python_surface.py`; refresh it when the Python CLI +//! changes and this test will list what the Rust tree is missing. + +use std::collections::BTreeSet; + +use bcli_cli::cli::Cli; +use clap::{ArgAction, CommandFactory}; +use serde_json::Value; + +/// Typer's shell-completion installers; the Rust build will ship +/// `clap_complete`-generated scripts instead. +const INTENTIONALLY_OMITTED: [&str; 2] = ["--install-completion", "--show-completion"]; + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +struct Opt { + long: Vec, + short: Vec, + flag: bool, + hidden: bool, + multiple: bool, + required: bool, +} + +#[derive(Debug, PartialEq, Eq)] +struct Positional { + name: String, + required: bool, + variadic: bool, +} + +fn strings(v: &Value) -> Vec { + let mut out: Vec = v + .as_array() + .unwrap() + .iter() + .map(|s| s.as_str().unwrap().to_string()) + .collect(); + out.sort(); + out +} + +fn python_options(entry: &Value) -> BTreeSet { + entry["options"] + .as_array() + .unwrap() + .iter() + .filter(|o| { + !strings(&o["long"]) + .iter() + .any(|l| INTENTIONALLY_OMITTED.contains(&l.as_str())) + }) + .map(|o| Opt { + long: strings(&o["long"]), + short: strings(&o["short"]), + flag: o["flag"].as_bool().unwrap(), + hidden: o["hidden"].as_bool().unwrap(), + multiple: o["multiple"].as_bool().unwrap(), + required: o["required"].as_bool().unwrap(), + }) + .collect() +} + +fn rust_options(cmd: &clap::Command) -> BTreeSet { + cmd.get_arguments() + .filter(|a| !a.is_positional() && a.get_id() != "help") + .map(|a| { + let mut long: Vec = a + .get_long() + .into_iter() + .chain(a.get_all_aliases().unwrap_or_default()) + .map(|l| format!("--{l}")) + .collect(); + long.sort(); + Opt { + long, + short: a + .get_short() + .map(|s| vec![format!("-{s}")]) + .unwrap_or_default(), + flag: !a.get_action().takes_values(), + hidden: a.is_hide_set(), + multiple: matches!(a.get_action(), ArgAction::Append), + required: a.is_required_set(), + } + }) + .collect() +} + +fn python_positionals(entry: &Value) -> Vec { + entry["positionals"] + .as_array() + .unwrap() + .iter() + .map(|p| Positional { + name: p["name"].as_str().unwrap().to_string(), + required: p["required"].as_bool().unwrap(), + variadic: p["nargs"].as_i64() == Some(-1), + }) + .collect() +} + +fn rust_positionals(cmd: &clap::Command) -> Vec { + cmd.get_positionals() + .map(|a| Positional { + name: a.get_id().to_string(), + required: a.is_required_set(), + variadic: a.get_num_args().is_some_and(|n| n.max_values() > 1), + }) + .collect() +} + +fn find<'a>(root: &'a clap::Command, path: &[String]) -> Option<&'a clap::Command> { + path.iter() + .try_fold(root, |cmd, name| cmd.find_subcommand(name)) +} + +fn rust_paths(cmd: &clap::Command, prefix: Vec, out: &mut BTreeSet>) { + for sub in cmd.get_subcommands() { + let mut path = prefix.clone(); + path.push(sub.get_name().to_string()); + out.insert(path.clone()); + rust_paths(sub, path, out); + } +} + +#[test] +fn clap_tree_matches_python_surface() { + let fixture: Value = + serde_json::from_str(include_str!("fixtures/python-cli-surface.json")).unwrap(); + let mut root = Cli::command(); + root.build(); + + let mut problems = Vec::new(); + let mut python_paths = BTreeSet::new(); + for entry in fixture.as_array().unwrap() { + let path = strings_unsorted(&entry["path"]); + python_paths.insert(path.clone()); + let label = if path.is_empty() { + "(root)".to_string() + } else { + path.join(" ") + }; + let Some(cmd) = find(&root, &path) else { + problems.push(format!("missing command: bcli {label}")); + continue; + }; + let (py_opts, rs_opts) = (python_options(entry), rust_options(cmd)); + for o in py_opts.difference(&rs_opts) { + problems.push(format!("{label}: python option not matched in clap: {o:?}")); + } + for o in rs_opts.difference(&py_opts) { + problems.push(format!("{label}: clap option not in python: {o:?}")); + } + let (py_pos, rs_pos) = (python_positionals(entry), rust_positionals(cmd)); + if py_pos != rs_pos { + problems.push(format!( + "{label}: positionals differ\n python {py_pos:?}\n rust {rs_pos:?}" + )); + } + } + + let mut ours = BTreeSet::new(); + rust_paths(&root, Vec::new(), &mut ours); + for extra in ours.difference(&python_paths) { + problems.push(format!( + "clap command not in python: bcli {}", + extra.join(" ") + )); + } + + assert!(python_paths.len() > 50, "fixture looks truncated"); + assert!( + problems.is_empty(), + "{} surface mismatches:\n{}", + problems.len(), + problems.join("\n") + ); +} + +fn strings_unsorted(v: &Value) -> Vec { + v.as_array() + .unwrap() + .iter() + .map(|s| s.as_str().unwrap().to_string()) + .collect() +} diff --git a/rust/crates/bcli-core/Cargo.toml b/rust/crates/bcli-core/Cargo.toml new file mode 100644 index 0000000..5fa192d --- /dev/null +++ b/rust/crates/bcli-core/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "bcli-core" +description = "Business Central API client core for bcli (config, registry, auth, HTTP)" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +publish = false + +[features] +default = ["keyring"] +# OS keychain lookup for client secrets (service "bcli", user ":"). +keyring = ["dep:keyring"] + +[dependencies] +chrono.workspace = true +indexmap.workspace = true +keyring = { workspace = true, optional = true } +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +toml.workspace = true + +[dev-dependencies] +tempfile.workspace = true +wiremock.workspace = true + +[lints] +workspace = true diff --git a/rust/crates/bcli-core/src/auth/mod.rs b/rust/crates/bcli-core/src/auth/mod.rs new file mode 100644 index 0000000..6612e75 --- /dev/null +++ b/rust/crates/bcli-core/src/auth/mod.rs @@ -0,0 +1,201 @@ +//! Token acquisition (`bcli.auth`). +//! +//! Ported: the shared `tokens.json` cache (every method checks it first) and +//! the `client_credentials` flow against the Entra v2.0 token endpoint. +//! Not ported yet: interactive `browser` / `device_code` sign-in and silent +//! renewal from MSAL's `msal_cache.json`; those profiles work for as long as +//! the cached access token (from a Python `bcli auth login`) is valid. + +pub mod secure_io; +pub mod token_cache; + +use chrono::Utc; +use serde::Deserialize; + +use crate::config::Profile; +use crate::error::{BcliError, Result}; +use crate::paths::Paths; +use crate::url::{ServiceUrls, BC_SCOPE}; +use token_cache::TokenCache; + +pub const KEYRING_SERVICE: &str = "bcli"; + +/// Read-only view of the OS keychain, injectable for tests. +pub trait SecretStore { + fn get(&self, service: &str, user: &str) -> Option; +} + +pub struct NoSecretStore; + +impl SecretStore for NoSecretStore { + fn get(&self, _service: &str, _user: &str) -> Option { + None + } +} + +#[cfg(feature = "keyring")] +pub struct OsKeyring; + +#[cfg(feature = "keyring")] +impl SecretStore for OsKeyring { + fn get(&self, service: &str, user: &str) -> Option { + keyring::Entry::new(service, user).ok()?.get_password().ok() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthMethod { + ClientCredentials, + Browser, + DeviceCode, +} + +impl AuthMethod { + pub fn parse(value: &str) -> Result { + match value { + "client_credentials" | "client-credentials" => Ok(Self::ClientCredentials), + "browser" => Ok(Self::Browser), + "device_code" => Ok(Self::DeviceCode), + other => Err(BcliError::config(format!( + "Unsupported auth_method '{other}'. Use 'browser', 'device_code', or 'client_credentials'." + ))), + } + } + + fn as_str(self) -> &'static str { + match self { + Self::ClientCredentials => "client_credentials", + Self::Browser => "browser", + Self::DeviceCode => "device_code", + } + } +} + +pub struct Authenticator<'a> { + method: AuthMethod, + tenant_id: String, + client_id: String, + client_secret_env: Option, + cache: TokenCache, + urls: &'a ServiceUrls, + secrets: &'a dyn SecretStore, + env: &'a dyn Fn(&str) -> Option, +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: Option, + expires_in: Option, + error: Option, + error_description: Option, +} + +impl<'a> Authenticator<'a> { + pub fn for_profile( + profile: &Profile, + paths: &Paths, + urls: &'a ServiceUrls, + secrets: &'a dyn SecretStore, + env: &'a dyn Fn(&str) -> Option, + ) -> Result { + Ok(Self { + method: AuthMethod::parse(&profile.auth_method)?, + tenant_id: profile.tenant_id.clone(), + client_id: profile.client_id.clone().unwrap_or_default(), + client_secret_env: profile.client_secret_env.clone(), + cache: TokenCache::new(paths.token_cache_file()), + urls, + secrets, + env, + }) + } + + pub fn take_warnings(&mut self) -> Vec { + std::mem::take(&mut self.cache.warnings) + } + + pub async fn access_token(&mut self, http: &reqwest::Client) -> Result { + if let Some(token) = self.cache.get(&self.tenant_id, &self.client_id, Utc::now()) { + return Ok(token); + } + match self.method { + AuthMethod::ClientCredentials => self.client_credentials(http).await, + AuthMethod::Browser | AuthMethod::DeviceCode => Err(BcliError::auth(format!( + "No valid cached token for auth_method '{}', and interactive sign-in is not yet \ + ported to the Rust build. Sign in with the Python bcli ('bcli auth login'); \ + the Rust build reuses the cached token.", + self.method.as_str() + ))), + } + } + + /// Keychain, then `client_secret_env`, then `BCLI_CLIENT_SECRET` / `BCLI_SECRET`. + fn resolve_secret(&self) -> Result { + let keyring_user = format!("{}:{}", self.tenant_id, self.client_id); + if let Some(secret) = self + .secrets + .get(KEYRING_SERVICE, &keyring_user) + .filter(|s| !s.is_empty()) + { + return Ok(secret); + } + let from_env = |name: &str| (self.env)(name).filter(|s| !s.is_empty()); + if let Some(secret) = self.client_secret_env.as_deref().and_then(from_env) { + return Ok(secret); + } + if let Some(secret) = from_env("BCLI_CLIENT_SECRET").or_else(|| from_env("BCLI_SECRET")) { + return Ok(secret); + } + let mut hints = vec!["bcli auth store-secret (saves to OS keychain)".to_string()]; + if let Some(var) = &self.client_secret_env { + hints.push(format!("export {var}=")); + } + Err(BcliError::config(format!( + "No client secret found. Options:\n {}", + hints.join("\n ") + ))) + } + + async fn client_credentials(&mut self, http: &reqwest::Client) -> Result { + let secret = self.resolve_secret()?; + let url = format!( + "{}/{}/oauth2/v2.0/token", + self.urls.authority_base, self.tenant_id + ); + let response = http + .post(&url) + .form(&[ + ("client_id", self.client_id.as_str()), + ("client_secret", secret.as_str()), + ("scope", BC_SCOPE), + ("grant_type", "client_credentials"), + ]) + .send() + .await + .map_err(|e| { + BcliError::auth(format!("Failed to acquire token: {e}")).with_status(401) + })?; + let body: TokenResponse = response.json().await.map_err(|e| { + BcliError::auth(format!("Failed to acquire token: {e}")).with_status(401) + })?; + + let Some(token) = body.access_token else { + let detail = body + .error_description + .or(body.error) + .unwrap_or_else(|| "Unknown error".into()); + return Err( + BcliError::auth(format!("Failed to acquire token: {detail}")).with_status(401), + ); + }; + let expires_in = body.expires_in.unwrap_or(3600); + self.cache.put( + &self.tenant_id, + &self.client_id, + &token, + expires_in, + Utc::now(), + )?; + Ok(token) + } +} diff --git a/rust/crates/bcli-core/src/auth/secure_io.rs b/rust/crates/bcli-core/src/auth/secure_io.rs new file mode 100644 index 0000000..2c708d1 --- /dev/null +++ b/rust/crates/bcli-core/src/auth/secure_io.rs @@ -0,0 +1,119 @@ +//! Private (0600) atomic writes for files that hold credentials. +//! +//! Same contract as `bcli.auth._secure_io`: parent dir 0700, payload written +//! to `.tmp` created with 0600, then renamed into place. On Windows +//! only the atomic rename applies; the threat model there is the user account. + +use std::fs; +use std::io::{self, Write}; +use std::path::Path; + +#[cfg(unix)] +const DIR_MODE: u32 = 0o700; +#[cfg(unix)] +const FILE_MODE: u32 = 0o600; +#[cfg(unix)] +const INSECURE_MASK: u32 = 0o077; + +pub fn write_secret_file(path: &Path, content: &str) -> io::Result> { + let mut warnings = Vec::new(); + if let Some(parent) = path.parent() { + ensure_private_dir(parent, &mut warnings)?; + } + let mut tmp_name = path.file_name().unwrap_or_default().to_os_string(); + tmp_name.push(".tmp"); + let tmp = path.with_file_name(tmp_name); + let _ = fs::remove_file(&tmp); + + let mut options = fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(FILE_MODE); + } + let result = options.open(&tmp).and_then(|mut f| { + f.write_all(content.as_bytes())?; + f.sync_all() + }); + if let Err(e) = result { + let _ = fs::remove_file(&tmp); + return Err(e); + } + fs::rename(&tmp, path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(FILE_MODE))?; + } + Ok(warnings) +} + +/// Tighten a credential file left group/other-readable by an older bcli. +pub fn tighten_if_insecure(path: &Path) -> Option { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(path).ok()?.permissions().mode() & 0o777; + if mode & INSECURE_MASK != 0 { + let _ = fs::set_permissions(path, fs::Permissions::from_mode(FILE_MODE)); + return Some(format!( + "{} had loose permissions ({mode:#o}); tightening to {FILE_MODE:#o}. Other local \ + users may have been able to read cached credentials before this bcli upgrade.", + path.display() + )); + } + None + } + #[cfg(not(unix))] + { + let _ = path; + None + } +} + +fn ensure_private_dir(dir: &Path, warnings: &mut Vec) -> io::Result<()> { + fs::create_dir_all(dir)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(dir)?.permissions().mode() & 0o777; + if mode & INSECURE_MASK != 0 { + warnings.push(format!( + "{} had loose permissions ({mode:#o}); tightening to {DIR_MODE:#o}.", + dir.display() + )); + fs::set_permissions(dir, fs::Permissions::from_mode(DIR_MODE))?; + } + } + #[cfg(not(unix))] + let _ = warnings; + Ok(()) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + #[test] + fn writes_0600_inside_0700_dir() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("bcli"); + fs::create_dir(&dir).unwrap(); + fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap(); + let file = dir.join("tokens.json"); + let warnings = write_secret_file(&file, "{}").unwrap(); + assert_eq!(warnings.len(), 1); + assert_eq!( + fs::metadata(&dir).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + fs::metadata(&file).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!(fs::read_to_string(&file).unwrap(), "{}"); + assert!(!dir.join("tokens.json.tmp").exists()); + } +} diff --git a/rust/crates/bcli-core/src/auth/token_cache.rs b/rust/crates/bcli-core/src/auth/token_cache.rs new file mode 100644 index 0000000..fc45417 --- /dev/null +++ b/rust/crates/bcli-core/src/auth/token_cache.rs @@ -0,0 +1,147 @@ +//! `tokens.json` access-token cache, shared on disk with the Python build. +//! +//! Format: `{":": {"access_token", "expires_at", "cached_at"}}` +//! with ISO-8601 timestamps (`datetime.isoformat()`), written by +//! `json.dumps(indent=2)`. A token is reused while more than 5 minutes remain. + +use std::path::PathBuf; + +use chrono::{DateTime, Duration, SecondsFormat, Utc}; +use serde_json::{json, Map, Value}; + +use super::secure_io::{tighten_if_insecure, write_secret_file}; +use crate::error::{BcliError, Result}; +use crate::pyjson; + +const EXPIRY_BUFFER_SECS: i64 = 300; + +pub struct TokenCache { + file: PathBuf, + data: Option>, + pub warnings: Vec, +} + +impl TokenCache { + pub fn new(file: PathBuf) -> Self { + Self { + file, + data: None, + warnings: Vec::new(), + } + } + + fn key(tenant_id: &str, client_id: &str) -> String { + format!("{tenant_id}:{client_id}") + } + + fn load(&mut self) -> &mut Map { + if self.data.is_none() { + let mut data = Map::new(); + if self.file.is_file() { + self.warnings.extend(tighten_if_insecure(&self.file)); + if let Ok(Value::Object(obj)) = std::fs::read_to_string(&self.file) + .map_err(|_| ()) + .and_then(|s| serde_json::from_str(&s).map_err(|_| ())) + { + data = obj; + } + } + self.data = Some(data); + } + self.data.get_or_insert_with(Map::new) + } + + pub fn get(&mut self, tenant_id: &str, client_id: &str, now: DateTime) -> Option { + let entry = self.load().get(&Self::key(tenant_id, client_id))?; + let expires_at = DateTime::parse_from_rfc3339(entry.get("expires_at")?.as_str()?).ok()?; + let remaining = expires_at.with_timezone(&Utc) - now; + if remaining > Duration::seconds(EXPIRY_BUFFER_SECS) { + entry.get("access_token")?.as_str().map(str::to_owned) + } else { + None + } + } + + pub fn put( + &mut self, + tenant_id: &str, + client_id: &str, + access_token: &str, + expires_in: i64, + now: DateTime, + ) -> Result<()> { + let iso = |t: DateTime| t.to_rfc3339_opts(SecondsFormat::Micros, false); + let entry = json!({ + "access_token": access_token, + "expires_at": iso(now + Duration::seconds(expires_in)), + "cached_at": iso(now), + }); + self.load().insert(Self::key(tenant_id, client_id), entry); + self.save() + } + + fn save(&mut self) -> Result<()> { + let body = pyjson::dumps(&Value::Object(self.load().clone()), Some(2)); + let warnings = write_secret_file(&self.file, &body) + .map_err(|e| BcliError::auth(format!("Cannot write {}: {e}", self.file.display())))?; + self.warnings.extend(warnings); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_a_python_written_cache() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("tokens.json"); + std::fs::write( + &file, + r#"{ + "t:c": { + "access_token": "tok", + "expires_at": "2026-09-30T12:00:00.123456+00:00", + "cached_at": "2026-09-30T11:00:00+00:00" + } +}"#, + ) + .unwrap(); + let mut cache = TokenCache::new(file); + let early = DateTime::parse_from_rfc3339("2026-09-30T11:50:00Z") + .unwrap() + .to_utc(); + let late = DateTime::parse_from_rfc3339("2026-09-30T11:56:00Z") + .unwrap() + .to_utc(); + assert_eq!(cache.get("t", "c", early).as_deref(), Some("tok")); + assert_eq!( + cache.get("t", "c", late), + None, + "inside the 5-minute buffer" + ); + assert_eq!(cache.get("t", "other", early), None); + } + + #[test] + fn put_round_trips_and_preserves_other_entries() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("bcli").join("tokens.json"); + let now = DateTime::parse_from_rfc3339("2026-09-30T11:00:00Z") + .unwrap() + .to_utc(); + let mut cache = TokenCache::new(file.clone()); + cache.put("t", "a", "tok-a", 3600, now).unwrap(); + cache.put("t", "b", "tok-b", 3600, now).unwrap(); + + let text = std::fs::read_to_string(&file).unwrap(); + assert!( + text.contains("\"expires_at\": \"2026-09-30T12:00:00.000000+00:00\""), + "{text}" + ); + let mut fresh = TokenCache::new(file); + assert_eq!(fresh.get("t", "a", now).as_deref(), Some("tok-a")); + assert_eq!(fresh.get("t", "b", now).as_deref(), Some("tok-b")); + } +} diff --git a/rust/crates/bcli-core/src/client.rs b/rust/crates/bcli-core/src/client.rs new file mode 100644 index 0000000..fb528fc --- /dev/null +++ b/rust/crates/bcli-core/src/client.rs @@ -0,0 +1,66 @@ +//! High-level Business Central client (`bcli.client.AsyncBCClient`). + +use std::time::Duration; + +use serde_json::{Map, Value}; + +use crate::auth::{Authenticator, SecretStore}; +use crate::config::Profile; +use crate::error::Result; +use crate::http::Transport; +use crate::paths::Paths; +use crate::url::{build_companies_url, ServiceUrls}; + +pub type Record = Map; + +pub struct BcClient<'a> { + pub transport: Transport<'a>, + profile: Profile, + urls: &'a ServiceUrls, +} + +impl<'a> BcClient<'a> { + pub fn new( + profile: Profile, + paths: &Paths, + urls: &'a ServiceUrls, + secrets: &'a dyn SecretStore, + env: &'a dyn Fn(&str) -> Option, + timeout: Duration, + ) -> Result { + let auth = Authenticator::for_profile(&profile, paths, urls, secrets, env)?; + Ok(Self { + transport: Transport::new(auth, timeout)?, + profile, + urls, + }) + } + + pub fn profile(&self) -> &Profile { + &self.profile + } + + /// All companies in the profile's environment (single request, no paging). + pub async fn list_companies(&mut self) -> Result> { + let url = build_companies_url(self.urls, &self.profile.environment); + let body = self.transport.get_json(&url).await?; + Ok(value_array(body)) + } +} + +/// `data.get("value", [])`, keeping only object rows. +fn value_array(body: Value) -> Vec { + match body { + Value::Object(mut obj) => match obj.remove("value") { + Some(Value::Array(items)) => items + .into_iter() + .filter_map(|v| match v { + Value::Object(o) => Some(o), + _ => None, + }) + .collect(), + _ => Vec::new(), + }, + _ => Vec::new(), + } +} diff --git a/rust/crates/bcli-core/src/config.rs b/rust/crates/bcli-core/src/config.rs new file mode 100644 index 0000000..2d92b73 --- /dev/null +++ b/rust/crates/bcli-core/src/config.rs @@ -0,0 +1,517 @@ +//! Configuration model and layered loader (`bcli.config`). +//! +//! Resolution order, identical to the Python loader: +//! 1. global `~/.config/bcli/config.toml` +//! 2. project `.bcli.toml` found by walking up from the cwd, with +//! `[telemetry]` reduced to `enabled` (a checked-out repo must not be able +//! to select a telemetry backend) +//! 3. `BCLI_PROFILE` / `BCLI_FORMAT` / `BCLI_TIMEOUT` +//! +//! Unknown keys and sections (`[telemetry]`, `[audit]`, `[ask]`, …) are kept +//! in `extra` so a future `save` can round-trip them. + +use std::path::{Path, PathBuf}; + +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; +use toml::{Table, Value}; + +use crate::error::{BcliError, Result}; +use crate::paths::{Paths, PROJECT_CONFIG_FILE}; + +pub const DEFAULT_FORMAT: &str = "table"; +pub const DEFAULT_PAGE_SIZE: u32 = 100; +pub const DEFAULT_TIMEOUT: u64 = 60; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CompanyAlias { + pub id: String, + #[serde(default)] + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Profile { + pub tenant_id: String, + pub environment: String, + #[serde(default)] + pub company_id: Option, + #[serde(default)] + pub company_name: Option, + #[serde(default)] + pub companies: IndexMap, + + #[serde(default = "default_auth_method")] + pub auth_method: String, + #[serde(default)] + pub client_id: Option, + #[serde(default)] + pub client_secret_env: Option, + + #[serde(default)] + pub api_publisher: Option, + #[serde(default)] + pub api_group: Option, + #[serde(default)] + pub api_version: Option, + + #[serde(default)] + pub disable_standard_api: bool, + #[serde(default)] + pub allowed_categories: Vec, + #[serde(default)] + pub allowed_endpoints: Vec, + #[serde(default)] + pub disable_writes: bool, + + #[serde(flatten)] + pub extra: Table, +} + +fn default_auth_method() -> String { + "client_credentials".into() +} + +/// Result of resolving `--company`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CompanySelection { + One { + id: String, + name: Option, + }, + /// `--company all`: the caller iterates over every known company. + All, +} + +impl Profile { + /// `BCProfile.resolve_company`: alias (exact, then case-insensitive), a + /// GUID-looking string, `all`, or the profile default when `None`. + pub fn resolve_company(&self, alias_or_id: Option<&str>) -> Result { + let Some(value) = alias_or_id else { + return match &self.company_id { + Some(id) if !id.is_empty() => Ok(CompanySelection::One { + id: id.clone(), + name: self.company_name.clone(), + }), + _ => Err(BcliError::config( + "No company_id configured. Run 'bcli company list' and 'bcli company use '.", + )), + }; + }; + + if value.eq_ignore_ascii_case("all") { + return Ok(CompanySelection::All); + } + if let Some(alias) = self.companies.get(value) { + return Ok(one(alias, value)); + } + if value.chars().count() > 8 && value.contains('-') { + return Ok(CompanySelection::One { + id: value.to_string(), + name: None, + }); + } + let lowered = value.to_lowercase(); + if let Some((key, alias)) = self + .companies + .iter() + .find(|(key, _)| key.to_lowercase() == lowered) + { + return Ok(one(alias, key)); + } + + let available = if self.companies.is_empty() { + "(none)".to_string() + } else { + self.companies + .keys() + .cloned() + .collect::>() + .join(", ") + }; + Err(BcliError::config(format!( + "Company alias '{value}' not found. Available: {available}. \ + Use 'bcli company alias ' to create one." + ))) + } + + /// Apply `--env` / `--company` the way `CLIState.profile` does. + pub fn with_overrides(&self, env: Option<&str>, company: Option<&str>) -> Result { + let mut profile = self.clone(); + if let Some(env) = env.filter(|e| !e.is_empty()) { + profile.environment = env.to_string(); + } + if let Some(company) = company.filter(|c| !c.is_empty()) { + if let CompanySelection::One { id, name } = self.resolve_company(Some(company))? { + profile.company_id = Some(id); + profile.company_name = Some(name.unwrap_or_else(|| company.to_string())); + } + } + Ok(profile) + } +} + +fn one(alias: &CompanyAlias, fallback_name: &str) -> CompanySelection { + let name = if alias.name.is_empty() { + fallback_name.to_string() + } else { + alias.name.clone() + }; + CompanySelection::One { + id: alias.id.clone(), + name: Some(name), + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Defaults { + #[serde(default = "default_profile_name")] + pub profile: String, + #[serde(default = "default_format")] + pub format: String, + #[serde(default = "default_page_size")] + pub page_size: u32, + #[serde(default = "default_timeout")] + pub timeout: u64, + #[serde(flatten)] + pub extra: Table, +} + +impl Default for Defaults { + fn default() -> Self { + Self { + profile: default_profile_name(), + format: default_format(), + page_size: DEFAULT_PAGE_SIZE, + timeout: DEFAULT_TIMEOUT, + extra: Table::new(), + } + } +} + +fn default_profile_name() -> String { + "default".into() +} +fn default_format() -> String { + DEFAULT_FORMAT.into() +} +fn default_page_size() -> u32 { + DEFAULT_PAGE_SIZE +} +fn default_timeout() -> u64 { + DEFAULT_TIMEOUT +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Config { + #[serde(default)] + pub defaults: Defaults, + #[serde(default)] + pub profiles: IndexMap, + #[serde(flatten)] + pub extra: Table, +} + +impl Config { + /// `BCConfig.get_profile`, with the same remediation text. + pub fn get_profile(&self, name: Option<&str>) -> Result<&Profile> { + let name = name.unwrap_or(&self.defaults.profile); + if let Some(profile) = self.profiles.get(name) { + return Ok(profile); + } + if self.profiles.is_empty() { + return Err(BcliError::config( + "No profiles configured. Run 'bcli config init' to create your first profile.", + )); + } + let available = self.profiles.keys().cloned().collect::>().join(", "); + Err(BcliError::config(format!( + "Profile '{name}' not found. Available: {available}. \ + Run 'bcli config init --profile {name}' to create it, \ + or 'bcli config use ' to switch." + ))) + } +} + +/// A non-fatal message the loader wants surfaced on stderr. +pub type Warning = String; + +pub struct Loaded { + pub config: Config, + pub warnings: Vec, +} + +pub fn load( + paths: &Paths, + cwd: Option<&Path>, + env: &dyn Fn(&str) -> Option, +) -> Result { + let mut warnings = Vec::new(); + let global = load_toml(&paths.config_file())?; + let project = match cwd.and_then(find_project_config) { + Some(path) => sanitise_project_config(load_toml(&path)?, &path, &mut warnings), + None => Table::new(), + }; + + let mut merged = deep_merge(global, project); + apply_env_overrides(&mut merged, env)?; + + let config: Config = Value::Table(merged) + .try_into() + .map_err(|e| BcliError::config(format!("Invalid bcli configuration: {e}")))?; + Ok(Loaded { config, warnings }) +} + +fn find_project_config(cwd: &Path) -> Option { + cwd.ancestors() + .map(|dir| dir.join(PROJECT_CONFIG_FILE)) + .find(|candidate| candidate.is_file()) +} + +fn load_toml(path: &Path) -> Result { + if !path.is_file() { + return Ok(Table::new()); + } + let text = std::fs::read_to_string(path) + .map_err(|e| BcliError::config(format!("Cannot read {}: {e}", path.display())))?; + text.parse::
() + .map_err(|e| BcliError::config(format!("Invalid TOML in {}: {e}", path.display()))) +} + +fn deep_merge(mut base: Table, overlay: Table) -> Table { + for (key, value) in overlay { + match (base.get_mut(&key), value) { + (Some(Value::Table(existing)), Value::Table(incoming)) => { + let merged = deep_merge(std::mem::take(existing), incoming); + *existing = merged; + } + (_, value) => { + base.insert(key, value); + } + } + } + base +} + +fn sanitise_project_config(mut data: Table, source: &Path, warnings: &mut Vec) -> Table { + let Some(Value::Table(telemetry)) = data.get("telemetry") else { + return data; + }; + let mut rejected: Vec<&String> = telemetry + .keys() + .filter(|k| k.as_str() != "enabled") + .collect(); + rejected.sort(); + if !rejected.is_empty() { + let list = rejected + .iter() + .map(|k| format!("'{k}'")) + .collect::>() + .join(", "); + warnings.push(format!( + "Ignoring [telemetry] keys [{list}] from project config {}: only 'enabled' is \ + honoured at the project layer (custom backends and connection strings must live \ + in the global config to prevent arbitrary code execution from a checked-out repo).", + source.display() + )); + } + match telemetry.get("enabled").cloned() { + Some(enabled) => { + let mut cleaned = Table::new(); + cleaned.insert("enabled".into(), enabled); + data.insert("telemetry".into(), Value::Table(cleaned)); + } + None => { + data.remove("telemetry"); + } + } + data +} + +fn apply_env_overrides(data: &mut Table, env: &dyn Fn(&str) -> Option) -> Result<()> { + for (var, key) in [ + ("BCLI_PROFILE", "profile"), + ("BCLI_FORMAT", "format"), + ("BCLI_TIMEOUT", "timeout"), + ] { + let Some(raw) = env(var) else { continue }; + let value = if key == "timeout" { + let secs = raw + .trim() + .parse::() + .map_err(|_| BcliError::config(format!("{var} must be an integer, got '{raw}'")))?; + Value::Integer(secs) + } else { + Value::String(raw) + }; + let section = data + .entry("defaults") + .or_insert_with(|| Value::Table(Table::new())); + if let Value::Table(section) = section { + section.insert(key.into(), value); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const GLOBAL: &str = r#" +[defaults] +profile = "prod" + +[profiles.prod] +tenant_id = "t" +environment = "Production" +company_id = "11111111-1111-1111-1111-111111111111" +auth_method = "device_code" +client_id = "c" +disable_writes = true +future_key = "kept" + +[profiles.prod.companies.LLC] +id = "22222222-2222-2222-2222-222222222222" +name = "Contoso LLC" + +[profiles.sandbox] +tenant_id = "t" +environment = "Sandbox" + +[telemetry] +enabled = true +backend = "console" +"#; + + fn setup(global: &str) -> (tempfile::TempDir, Paths) { + let home = tempfile::tempdir().unwrap(); + let paths = Paths::from_home(home.path()); + std::fs::create_dir_all(&paths.config_dir).unwrap(); + std::fs::write(paths.config_file(), global).unwrap(); + (home, paths) + } + + fn no_env(_: &str) -> Option { + None + } + + #[test] + fn loads_profiles_in_file_order_and_keeps_unknown_keys() { + let (_home, paths) = setup(GLOBAL); + let cfg = load(&paths, None, &no_env).unwrap().config; + assert_eq!(cfg.profiles.keys().collect::>(), ["prod", "sandbox"]); + let prod = cfg.get_profile(None).unwrap(); + assert!(prod.disable_writes); + assert_eq!( + prod.extra.get("future_key").and_then(Value::as_str), + Some("kept") + ); + assert!(cfg.extra.contains_key("telemetry")); + assert_eq!(cfg.profiles["sandbox"].auth_method, "client_credentials"); + } + + #[test] + fn missing_profile_error_lists_available() { + let (_home, paths) = setup(GLOBAL); + let cfg = load(&paths, None, &no_env).unwrap().config; + let err = cfg.get_profile(Some("nope")).unwrap_err(); + assert!(err + .to_string() + .starts_with("Profile 'nope' not found. Available: prod, sandbox.")); + } + + #[test] + fn no_config_means_no_profiles() { + let home = tempfile::tempdir().unwrap(); + let cfg = load(&Paths::from_home(home.path()), None, &no_env) + .unwrap() + .config; + let err = cfg.get_profile(None).unwrap_err(); + assert_eq!( + err.to_string(), + "No profiles configured. Run 'bcli config init' to create your first profile." + ); + } + + #[test] + fn env_overrides_win() { + let (_home, paths) = setup(GLOBAL); + let env = |k: &str| match k { + "BCLI_PROFILE" => Some("sandbox".to_string()), + "BCLI_TIMEOUT" => Some("5".to_string()), + _ => None, + }; + let cfg = load(&paths, None, &env).unwrap().config; + assert_eq!(cfg.defaults.profile, "sandbox"); + assert_eq!(cfg.defaults.timeout, 5); + } + + #[test] + fn project_config_cannot_select_a_telemetry_backend() { + let (home, paths) = setup(GLOBAL); + let project = home.path().join("repo").join("nested"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write( + home.path().join("repo").join(PROJECT_CONFIG_FILE), + "[telemetry]\nenabled = false\nbackend = \"evil:Sink\"\n", + ) + .unwrap(); + let loaded = load(&paths, Some(&project), &no_env).unwrap(); + let telemetry = loaded.config.extra["telemetry"].as_table().unwrap(); + assert_eq!(telemetry["enabled"].as_bool(), Some(false)); + assert_eq!(telemetry["backend"].as_str(), Some("console")); + assert_eq!(loaded.warnings.len(), 1); + assert!(loaded.warnings[0].contains("'backend'")); + } + + #[test] + fn resolve_company_matches_python_rules() { + let (_home, paths) = setup(GLOBAL); + let cfg = load(&paths, None, &no_env).unwrap().config; + let prod = cfg.get_profile(None).unwrap(); + let llc = CompanySelection::One { + id: "22222222-2222-2222-2222-222222222222".into(), + name: Some("Contoso LLC".into()), + }; + assert_eq!(prod.resolve_company(Some("LLC")).unwrap(), llc); + assert_eq!(prod.resolve_company(Some("llc")).unwrap(), llc); + assert_eq!( + prod.resolve_company(Some("ALL")).unwrap(), + CompanySelection::All + ); + assert_eq!( + prod.resolve_company(Some("33333333-3333")).unwrap(), + CompanySelection::One { + id: "33333333-3333".into(), + name: None + } + ); + let err = prod.resolve_company(Some("XYZ")).unwrap_err(); + assert!(err + .to_string() + .starts_with("Company alias 'XYZ' not found. Available: LLC.")); + } + + #[test] + fn overrides_replace_env_and_company() { + let (_home, paths) = setup(GLOBAL); + let cfg = load(&paths, None, &no_env).unwrap().config; + let p = cfg + .get_profile(None) + .unwrap() + .with_overrides(Some("Sandbox"), Some("LLC")) + .unwrap(); + assert_eq!(p.environment, "Sandbox"); + assert_eq!(p.company_name.as_deref(), Some("Contoso LLC")); + let unchanged = cfg + .get_profile(None) + .unwrap() + .with_overrides(None, Some("all")) + .unwrap(); + assert_eq!( + unchanged.company_id.as_deref(), + Some("11111111-1111-1111-1111-111111111111") + ); + } +} diff --git a/rust/crates/bcli-core/src/error.rs b/rust/crates/bcli-core/src/error.rs new file mode 100644 index 0000000..5077516 --- /dev/null +++ b/rust/crates/bcli-core/src/error.rs @@ -0,0 +1,218 @@ +//! Error type and exit-code taxonomy (`bcli.errors` + `bcli.exit_codes`). + +use std::fmt; + +pub type Result = std::result::Result; + +pub const EXIT_OK: i32 = 0; +pub const EXIT_GENERIC_ERROR: i32 = 1; +pub const EXIT_USAGE: i32 = 2; +pub const EXIT_AUTH: i32 = 3; +pub const EXIT_NOT_FOUND: i32 = 4; +pub const EXIT_VALIDATION: i32 = 5; +pub const EXIT_REMOTE_4XX: i32 = 6; +pub const EXIT_REMOTE_5XX: i32 = 7; +pub const EXIT_POLICY: i32 = 8; + +/// Ordered taxonomy consumed by `bcli describe`. Labels are a public contract. +pub const EXIT_CODES: [(i32, &str); 9] = [ + (EXIT_OK, "success"), + (EXIT_GENERIC_ERROR, "uncategorised error"), + (EXIT_USAGE, "usage error"), + (EXIT_AUTH, "authentication failure"), + (EXIT_NOT_FOUND, "not found"), + (EXIT_VALIDATION, "input validation"), + (EXIT_REMOTE_4XX, "remote 4xx"), + (EXIT_REMOTE_5XX, "remote 5xx"), + (EXIT_POLICY, "policy violation"), +]; + +pub fn exit_code_for_status(status: Option) -> i32 { + match status { + Some(400..=499) => EXIT_REMOTE_4XX, + Some(500..=599) => EXIT_REMOTE_5XX, + _ => EXIT_GENERIC_ERROR, + } +} + +/// One variant per Python exception subclass of `BCLIError`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorKind { + Generic, + Auth, + Forbidden, + NotFound, + Validation, + Throttled, + Server, + Config, + Registry, + Safety, + /// Command exists in the Python CLI but has no Rust handler yet. + NotPorted, +} + +#[derive(Debug, Clone)] +pub struct BcliError { + pub kind: ErrorKind, + pub message: String, + pub status_code: Option, + pub bc_message: Option, + pub correlation_id: Option, + pub retry_after: Option, +} + +impl BcliError { + pub fn new(kind: ErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + status_code: None, + bc_message: None, + correlation_id: None, + retry_after: None, + } + } + + pub fn config(message: impl Into) -> Self { + Self::new(ErrorKind::Config, message) + } + + pub fn auth(message: impl Into) -> Self { + Self::new(ErrorKind::Auth, message) + } + + pub fn registry(message: impl Into) -> Self { + Self::new(ErrorKind::Registry, message) + } + + pub fn server(message: impl Into) -> Self { + Self::new(ErrorKind::Server, message) + } + + pub fn validation(message: impl Into) -> Self { + Self::new(ErrorKind::Validation, message) + } + + pub fn not_ported(command: &str) -> Self { + Self::new( + ErrorKind::NotPorted, + format!( + "'bcli {command}' is not yet ported to the Rust build. \ + Use the Python bcli (`uv tool install bc-cli`) for this command." + ), + ) + } + + /// Error class for an HTTP status, matching `_ERROR_MAP` in the transport. + pub fn kind_for_status(status: u16) -> ErrorKind { + match status { + 400 => ErrorKind::Validation, + 401 => ErrorKind::Auth, + 403 => ErrorKind::Forbidden, + 404 => ErrorKind::NotFound, + 429 => ErrorKind::Throttled, + 500 | 502 | 503 | 504 => ErrorKind::Server, + _ => ErrorKind::Generic, + } + } + + pub fn with_status(mut self, status: u16) -> Self { + self.status_code = Some(status); + self + } + + pub fn with_bc_message(mut self, bc_message: Option) -> Self { + self.bc_message = bc_message; + self + } + + pub fn with_correlation_id(mut self, correlation_id: Option) -> Self { + self.correlation_id = correlation_id; + self + } + + /// Documented CLI exit code (`map_error_to_exit_code`). + pub fn exit_code(&self) -> i32 { + match self.kind { + ErrorKind::Auth | ErrorKind::Forbidden => EXIT_AUTH, + ErrorKind::NotFound | ErrorKind::Registry => EXIT_NOT_FOUND, + ErrorKind::Validation => EXIT_VALIDATION, + ErrorKind::Config => EXIT_USAGE, + ErrorKind::Safety => EXIT_POLICY, + ErrorKind::NotPorted => EXIT_GENERIC_ERROR, + ErrorKind::Generic | ErrorKind::Throttled | ErrorKind::Server => { + exit_code_for_status(self.status_code) + } + } + } +} + +impl fmt::Display for BcliError { + /// `message | BC says: … | Correlation ID: …`, as `BCLIError.__init__` renders it. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message)?; + if let Some(bc) = self.bc_message.as_deref() { + if !bc.is_empty() && bc != self.message { + write!(f, " | BC says: {bc}")?; + } + } + if let Some(cid) = self.correlation_id.as_deref() { + if !cid.is_empty() { + write!(f, " | Correlation ID: {cid}")?; + } + } + Ok(()) + } +} + +impl std::error::Error for BcliError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exit_codes_follow_the_python_taxonomy() { + assert_eq!(BcliError::auth("x").exit_code(), EXIT_AUTH); + assert_eq!( + BcliError::new(ErrorKind::Forbidden, "x").exit_code(), + EXIT_AUTH + ); + assert_eq!(BcliError::registry("x").exit_code(), EXIT_NOT_FOUND); + assert_eq!(BcliError::config("x").exit_code(), EXIT_USAGE); + assert_eq!(BcliError::validation("x").exit_code(), EXIT_VALIDATION); + assert_eq!( + BcliError::new(ErrorKind::Safety, "x").exit_code(), + EXIT_POLICY + ); + assert_eq!( + BcliError::server("x").with_status(503).exit_code(), + EXIT_REMOTE_5XX + ); + assert_eq!(BcliError::server("x").exit_code(), EXIT_GENERIC_ERROR); + assert_eq!( + BcliError::new(ErrorKind::Generic, "x") + .with_status(409) + .exit_code(), + EXIT_REMOTE_4XX + ); + } + + #[test] + fn display_appends_bc_message_and_correlation_id() { + let err = BcliError::validation("HTTP 400 Bad Request: GET u") + .with_bc_message(Some("bad filter".into())) + .with_correlation_id(Some("abc".into())); + assert_eq!( + err.to_string(), + "HTTP 400 Bad Request: GET u | BC says: bad filter | Correlation ID: abc" + ); + } + + #[test] + fn display_skips_bc_message_identical_to_message() { + let err = BcliError::validation("same").with_bc_message(Some("same".into())); + assert_eq!(err.to_string(), "same"); + } +} diff --git a/rust/crates/bcli-core/src/http.rs b/rust/crates/bcli-core/src/http.rs new file mode 100644 index 0000000..b022169 --- /dev/null +++ b/rust/crates/bcli-core/src/http.rs @@ -0,0 +1,223 @@ +//! HTTP transport (`bcli.client._transport`): bearer injection, retry on +//! 429/503/504 and network errors for idempotent methods, and BC error +//! parsing into the typed error taxonomy. + +use std::time::Duration; + +use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, CONTENT_TYPE}; +use reqwest::{Method, Response, StatusCode}; +use serde_json::Value; + +use crate::auth::Authenticator; +use crate::error::{BcliError, ErrorKind, Result}; + +pub const DEFAULT_MAX_RETRIES: u32 = 3; +const RETRYABLE: [u16; 3] = [429, 503, 504]; +const CORRELATION_HEADER: &str = "x-ms-correlation-request-id"; + +pub struct Transport<'a> { + client: reqwest::Client, + auth: Authenticator<'a>, + pub max_retries: u32, + pub initial_backoff: Duration, +} + +impl<'a> Transport<'a> { + pub fn new(auth: Authenticator<'a>, timeout: Duration) -> Result { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + headers.insert(ACCEPT, HeaderValue::from_static("application/json")); + headers.insert("OData-Version", HeaderValue::from_static("4.0")); + let client = reqwest::Client::builder() + .default_headers(headers) + .timeout(timeout) + .user_agent(concat!("bcli/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|e| { + BcliError::new(ErrorKind::Generic, format!("Cannot build HTTP client: {e}")) + })?; + Ok(Self { + client, + auth, + max_retries: DEFAULT_MAX_RETRIES, + initial_backoff: Duration::from_secs(1), + }) + } + + pub fn take_warnings(&mut self) -> Vec { + self.auth.take_warnings() + } + + pub async fn get_json(&mut self, url: &str) -> Result { + self.request(Method::GET, url).await + } + + async fn request(&mut self, method: Method, url: &str) -> Result { + let retry_safe = method == Method::GET || method == Method::HEAD; + let mut backoff = self.initial_backoff; + let mut attempt = 0; + loop { + let token = self.auth.access_token(&self.client).await?; + let sent = self + .client + .request(method.clone(), url) + .bearer_auth(token) + .send() + .await; + + let response = match sent { + Ok(response) => response, + Err(e) if is_network_error(&e) && attempt < self.max_retries && retry_safe => { + tokio::time::sleep(backoff).await; + backoff *= 2; + attempt += 1; + continue; + } + Err(e) => { + return Err(BcliError::server(format!( + "Network error after {} attempts: {e}", + attempt + 1 + ))) + } + }; + + let status = response.status(); + if status.is_success() { + let bytes = response.bytes().await.map_err(|e| { + BcliError::server(format!("Failed reading response from {url}: {e}")) + })?; + if status == StatusCode::NO_CONTENT || bytes.is_empty() { + return Ok(Value::Object(Default::default())); + } + return serde_json::from_slice(&bytes) + .map_err(|e| BcliError::server(format!("Invalid JSON from {url}: {e}"))); + } + + if RETRYABLE.contains(&status.as_u16()) && attempt < self.max_retries && retry_safe { + let wait = retry_after(&response).unwrap_or(backoff); + tokio::time::sleep(wait).await; + backoff *= 2; + attempt += 1; + continue; + } + + return Err(error_from_response(&method, url, response).await); + } + } +} + +fn is_network_error(e: &reqwest::Error) -> bool { + e.is_connect() || e.is_timeout() || e.is_request() +} + +fn retry_after(response: &Response) -> Option { + let secs: f64 = response + .headers() + .get("Retry-After")? + .to_str() + .ok()? + .trim() + .parse() + .ok()?; + (secs.is_finite() && secs >= 0.0).then(|| Duration::from_secs_f64(secs)) +} + +async fn error_from_response(method: &Method, url: &str, response: Response) -> BcliError { + let status = response.status(); + let correlation_id = response + .headers() + .get(CORRELATION_HEADER) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let retry = retry_after(&response); + let bc_message = response.json::().await.ok().and_then(|body| { + body.get("error")? + .get("message")? + .as_str() + .map(str::to_owned) + }); + + let reason = status.canonical_reason().unwrap_or(""); + let mut message = format!("HTTP {} {reason}: {method} {url}", status.as_u16()); + if let Some(hint) = hint_for_bc_error(status.as_u16(), bc_message.as_deref(), url) { + message.push_str(&format!("\n Hint: {hint}")); + } + let mut err = BcliError::new(BcliError::kind_for_status(status.as_u16()), message) + .with_status(status.as_u16()) + .with_bc_message(bc_message) + .with_correlation_id(correlation_id); + err.retry_after = retry.map(|d| d.as_secs_f64()); + err +} + +/// Matches `Could not find a property named '' on type 'Microsoft.NAV.'`. +fn is_property_not_found(msg: &str) -> bool { + const PREFIX: &str = "Could not find a property named '"; + const TYPE_PREFIX: &str = "' on type 'Microsoft.NAV."; + msg.match_indices(PREFIX).any(|(i, _)| { + let rest = &msg[i + PREFIX.len()..]; + let Some(field_end) = rest.find('\'') else { + return false; + }; + let Some(type_rest) = rest[field_end..].strip_prefix(TYPE_PREFIX) else { + return false; + }; + let word_len = type_rest + .find(|c: char| !(c.is_alphanumeric() || c == '_')) + .unwrap_or(type_rest.len()); + field_end > 0 && word_len > 0 && type_rest[word_len..].starts_with('\'') + }) +} + +/// Next-command hint for BC's "property not found" 400 (`_hint_for_bc_error`). +fn hint_for_bc_error(status: u16, bc_message: Option<&str>, url: &str) -> Option { + let msg = bc_message?; + if status != 400 || !is_property_not_found(msg) { + return None; + } + match entity_from_url(url) { + Some(entity) => Some(format!( + "Run 'bcli endpoint fields {entity}' to discover the actual field names on this \ + endpoint. Don't guess them — BC custom APIs don't always follow obvious naming." + )), + None => Some( + "Run 'bcli endpoint fields ' to discover the actual field names on this endpoint." + .into(), + ), + } +} + +/// The entity-set segment after `/companies()/`. +fn entity_from_url(url: &str) -> Option<&str> { + let start = url.find("/companies(")?; + let rest = &url[start + "/companies(".len()..]; + let after = &rest[rest.find(")/")? + 2..]; + let end = after + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .unwrap_or(after.len()); + (end > 0).then(|| &after[..end]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn property_hint_names_the_entity() { + let url = "https://x/v2.0/P/api/v2.0/companies(abc)/vendors?$filter=x"; + let msg = "Could not find a property named 'serialNumber' on type 'Microsoft.NAV.vendor'."; + let hint = hint_for_bc_error(400, Some(msg), url).unwrap(); + assert!(hint.starts_with("Run 'bcli endpoint fields vendors'")); + assert!(hint_for_bc_error(404, Some(msg), url).is_none()); + assert!(hint_for_bc_error(400, Some("other"), url).is_none()); + } + + #[test] + fn entity_from_url_handles_keys() { + assert_eq!( + entity_from_url("https://x/companies(1)/items(2)"), + Some("items") + ); + assert_eq!(entity_from_url("https://x/companies"), None); + } +} diff --git a/rust/crates/bcli-core/src/lib.rs b/rust/crates/bcli-core/src/lib.rs new file mode 100644 index 0000000..0c6046b --- /dev/null +++ b/rust/crates/bcli-core/src/lib.rs @@ -0,0 +1,22 @@ +//! Core library for the Rust port of bcli. +//! +//! Mirrors the Python `bcli` package (the SDK half of the project): config +//! loading, the endpoint registry, URL construction, auth, and the HTTP +//! transport. Everything that touches the process environment (home +//! directory, env vars, cwd) takes it as an explicit argument so the CLI and +//! the tests can inject it. + +pub mod auth; +pub mod client; +pub mod config; +pub mod error; +pub mod http; +pub mod paths; +pub mod pyjson; +pub mod registry; +pub mod url; + +pub use error::{BcliError, ErrorKind, Result}; + +/// Version reported by `bcli --version`; kept in lock-step with `pyproject.toml`. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/rust/crates/bcli-core/src/paths.rs b/rust/crates/bcli-core/src/paths.rs new file mode 100644 index 0000000..189121e --- /dev/null +++ b/rust/crates/bcli-core/src/paths.rs @@ -0,0 +1,45 @@ +//! On-disk locations (`bcli.config._defaults`). +//! +//! The Python CLI uses `~/.config/bcli` on every platform, Windows included +//! (`Path.home() / ".config" / "bcli"`), and the Beautech installer stages +//! files there. The Rust build must not switch to platform-native dirs +//! (`%APPDATA%`, `~/Library/Application Support`) or existing installs break. + +use std::path::{Path, PathBuf}; + +pub const PROJECT_CONFIG_FILE: &str = ".bcli.toml"; + +#[derive(Debug, Clone)] +pub struct Paths { + pub config_dir: PathBuf, +} + +impl Paths { + pub fn from_home(home: &Path) -> Self { + Self { + config_dir: home.join(".config").join("bcli"), + } + } + + pub fn config_file(&self) -> PathBuf { + self.config_dir.join("config.toml") + } + + /// bcli's own access-token cache (short-lived bearer tokens). + pub fn token_cache_file(&self) -> PathBuf { + self.config_dir.join("tokens.json") + } + + /// MSAL's serialized cache (refresh tokens). Not read by the Rust build yet. + pub fn msal_cache_file(&self) -> PathBuf { + self.config_dir.join("msal_cache.json") + } + + pub fn registries_dir(&self) -> PathBuf { + self.config_dir.join("registries") + } + + pub fn custom_registry_file(&self, profile: &str) -> PathBuf { + self.registries_dir().join(format!("{profile}.json")) + } +} diff --git a/rust/crates/bcli-core/src/pyjson.rs b/rust/crates/bcli-core/src/pyjson.rs new file mode 100644 index 0000000..4aa6d97 --- /dev/null +++ b/rust/crates/bcli-core/src/pyjson.rs @@ -0,0 +1,321 @@ +//! JSON serialization byte-compatible with Python's `json.dumps`. +//! +//! Agents and scripts diff bcli output, and `tokens.json` is shared with the +//! Python build, so the Rust port reproduces `json.dumps` defaults exactly: +//! `ensure_ascii=True` (non-ASCII escaped as `\uXXXX`), `", "` / `": "` +//! separators in compact mode, and `repr()`-style floats (`1e+16`, `1e-05`). +//! Key order is preserved (`serde_json/preserve_order`). + +use std::io; + +use serde::Serialize; +use serde_json::ser::{CharEscape, Formatter, Serializer}; +use serde_json::Value; + +/// `json.dumps(value)` (compact) or `json.dumps(value, indent=n)`. +pub fn dumps(value: &Value, indent: Option) -> String { + let mut buf = Vec::new(); + let formatter = PyFormatter::new(indent); + let mut ser = Serializer::with_formatter(&mut buf, formatter); + value + .serialize(&mut ser) + .expect("serializing a serde_json::Value into memory cannot fail"); + String::from_utf8(buf).expect("formatter only emits ASCII") +} + +/// Python `repr()` of a JSON number (`str(1.0) == "1.0"`, `str(1e16) == "1e+16"`). +pub fn number_repr(n: &serde_json::Number) -> String { + if let Some(f) = n.as_f64().filter(|_| n.is_f64()) { + float_repr(f) + } else { + n.to_string() + } +} + +/// Python `repr()` of a JSON-decoded value (`['GET', 'PATCH']`, `{'a': None}`), +/// which is what `csv` writes for nested values. +pub fn py_repr(value: &Value) -> String { + match value { + Value::Null => "None".into(), + Value::Bool(true) => "True".into(), + Value::Bool(false) => "False".into(), + Value::Number(n) => number_repr(n), + Value::String(s) => str_repr(s), + Value::Array(items) => { + let inner: Vec = items.iter().map(py_repr).collect(); + format!("[{}]", inner.join(", ")) + } + Value::Object(map) => { + let inner: Vec = map + .iter() + .map(|(k, v)| format!("{}: {}", str_repr(k), py_repr(v))) + .collect(); + format!("{{{}}}", inner.join(", ")) + } + } +} + +fn str_repr(s: &str) -> String { + let quote = if s.contains('\'') && !s.contains('"') { + '"' + } else { + '\'' + }; + let mut out = String::with_capacity(s.len() + 2); + out.push(quote); + for ch in s.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c == quote => { + out.push('\\'); + out.push(c); + } + c if c.is_control() || (c.is_whitespace() && c != ' ') => { + let cp = c as u32; + if cp <= 0xff { + out.push_str(&format!("\\x{cp:02x}")); + } else if cp <= 0xffff { + out.push_str(&format!("\\u{cp:04x}")); + } else { + out.push_str(&format!("\\U{cp:08x}")); + } + } + c => out.push(c), + } + } + out.push(quote); + out +} + +pub fn float_repr(value: f64) -> String { + if value.is_nan() { + return "NaN".into(); + } + if value.is_infinite() { + return if value > 0.0 { + "Infinity".into() + } else { + "-Infinity".into() + }; + } + // Rust's `{:?}` already picks the shortest round-trip digits and switches + // to exponent form at the same thresholds as Python (<1e-4, >=1e16); only + // the exponent spelling differs. + let s = format!("{value:?}"); + match s.split_once('e') { + Some((mantissa, exp)) => { + let (sign, digits) = match exp.strip_prefix('-') { + Some(d) => ('-', d), + None => ('+', exp), + }; + format!("{mantissa}e{sign}{digits:0>2}") + } + None => s, + } +} + +struct PyFormatter { + indent: Option, + depth: usize, + has_value: bool, +} + +impl PyFormatter { + fn new(indent: Option) -> Self { + Self { + indent, + depth: 0, + has_value: false, + } + } + + fn newline_indent(&self, w: &mut W) -> io::Result<()> { + if let Some(n) = self.indent { + w.write_all(b"\n")?; + for _ in 0..self.depth * n { + w.write_all(b" ")?; + } + } + Ok(()) + } + + fn separator(&self, w: &mut W, first: bool) -> io::Result<()> { + match (self.indent, first) { + (Some(_), true) => self.newline_indent(w), + (Some(_), false) => { + w.write_all(b",")?; + self.newline_indent(w) + } + (None, true) => Ok(()), + (None, false) => w.write_all(b", "), + } + } + + fn open(&mut self, w: &mut W, bracket: &[u8]) -> io::Result<()> { + self.depth += 1; + self.has_value = false; + w.write_all(bracket) + } + + fn close(&mut self, w: &mut W, bracket: &[u8]) -> io::Result<()> { + self.depth -= 1; + if self.has_value { + self.newline_indent(w)?; + } + w.write_all(bracket) + } +} + +impl Formatter for PyFormatter { + fn write_f64(&mut self, w: &mut W, value: f64) -> io::Result<()> { + w.write_all(float_repr(value).as_bytes()) + } + + fn write_f32(&mut self, w: &mut W, value: f32) -> io::Result<()> { + self.write_f64(w, f64::from(value)) + } + + fn write_string_fragment( + &mut self, + w: &mut W, + fragment: &str, + ) -> io::Result<()> { + for ch in fragment.chars() { + let cp = ch as u32; + if (0x20..0x7f).contains(&cp) { + w.write_all(&[cp as u8])?; + } else { + let mut units = [0u16; 2]; + for unit in ch.encode_utf16(&mut units) { + write!(w, "\\u{unit:04x}")?; + } + } + } + Ok(()) + } + + fn write_char_escape( + &mut self, + w: &mut W, + escape: CharEscape, + ) -> io::Result<()> { + let s: &[u8] = match escape { + CharEscape::Quote => b"\\\"", + CharEscape::ReverseSolidus => b"\\\\", + CharEscape::Solidus => b"/", + CharEscape::Backspace => b"\\b", + CharEscape::FormFeed => b"\\f", + CharEscape::LineFeed => b"\\n", + CharEscape::CarriageReturn => b"\\r", + CharEscape::Tab => b"\\t", + CharEscape::AsciiControl(byte) => { + return write!(w, "\\u{:04x}", byte); + } + }; + w.write_all(s) + } + + fn begin_array(&mut self, w: &mut W) -> io::Result<()> { + self.open(w, b"[") + } + + fn end_array(&mut self, w: &mut W) -> io::Result<()> { + self.close(w, b"]") + } + + fn begin_array_value( + &mut self, + w: &mut W, + first: bool, + ) -> io::Result<()> { + self.separator(w, first) + } + + fn end_array_value(&mut self, _w: &mut W) -> io::Result<()> { + self.has_value = true; + Ok(()) + } + + fn begin_object(&mut self, w: &mut W) -> io::Result<()> { + self.open(w, b"{") + } + + fn end_object(&mut self, w: &mut W) -> io::Result<()> { + self.close(w, b"}") + } + + fn begin_object_key( + &mut self, + w: &mut W, + first: bool, + ) -> io::Result<()> { + self.separator(w, first) + } + + fn begin_object_value(&mut self, w: &mut W) -> io::Result<()> { + w.write_all(b": ") + } + + fn end_object_value(&mut self, _w: &mut W) -> io::Result<()> { + self.has_value = true; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn compact_uses_python_separators() { + assert_eq!( + dumps(&json!({"a": 1, "b": [1, 2]}), None), + r#"{"a": 1, "b": [1, 2]}"# + ); + } + + #[test] + fn pretty_matches_indent_2() { + let v = json!({"a": [], "b": {}, "c": [1, {"d": null}]}); + let expected = "{\n \"a\": [],\n \"b\": {},\n \"c\": [\n 1,\n {\n \"d\": null\n }\n ]\n}"; + assert_eq!(dumps(&v, Some(2)), expected); + } + + #[test] + fn ensure_ascii_escapes_non_ascii_and_del() { + assert_eq!( + dumps(&json!("Müller ✈ 😀\u{7f}"), None), + r#""M\u00fcller \u2708 \ud83d\ude00\u007f""# + ); + assert_eq!( + dumps(&json!("a\"b\\c\n\u{1}/"), None), + r#""a\"b\\c\n\u0001/""# + ); + } + + #[test] + fn repr_matches_python() { + let v = json!({"a": [1, 2.5, null, true], "b": "it's", "c": "x\ty\u{a0}é"}); + assert_eq!( + py_repr(&v), + r#"{'a': [1, 2.5, None, True], 'b': "it's", 'c': 'x\ty\xa0é'}"# + ); + assert_eq!(py_repr(&json!("both ' and \"")), r#"'both \' and "'"#); + } + + #[test] + fn floats_use_python_repr() { + assert_eq!(float_repr(1.0), "1.0"); + assert_eq!(float_repr(0.1), "0.1"); + assert_eq!(float_repr(1e16), "1e+16"); + assert_eq!(float_repr(1e15), "1000000000000000.0"); + assert_eq!(float_repr(0.0001), "0.0001"); + assert_eq!(float_repr(0.00001), "1e-05"); + assert_eq!(float_repr(1.5e300), "1.5e+300"); + assert_eq!(float_repr(-2.5e-7), "-2.5e-07"); + } +} diff --git a/rust/crates/bcli-core/src/registry.rs b/rust/crates/bcli-core/src/registry.rs new file mode 100644 index 0000000..7970d07 --- /dev/null +++ b/rust/crates/bcli-core/src/registry.rs @@ -0,0 +1,330 @@ +//! Endpoint registry (`bcli.registry`): name → API route resolution. +//! +//! Custom per-profile entries (`~/.config/bcli/registries/.json`) +//! take priority over the built-in standard v2.0 catalog. Lookups are +//! case-insensitive; allowlists from the profile filter every read. + +use std::path::Path; + +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; + +use crate::error::{BcliError, Result}; + +/// The standard v2.0 catalog is shared with the Python package rather than +/// copied, so both builds always ship the same list. +const STANDARD_V2_JSON: &str = include_str!("../../../../src/bcli/registry/standard_v2.json"); + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Caution { + #[default] + Low, + Medium, + High, +} + +impl Caution { + pub fn as_str(self) -> &'static str { + match self { + Caution::Low => "low", + Caution::Medium => "medium", + Caution::High => "high", + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EndpointMetadata { + pub entity_set_name: String, + #[serde(default)] + pub entity_name: String, + #[serde(default)] + pub description: String, + #[serde(default = "default_supports")] + pub supports: Vec, + #[serde(default = "default_key_field")] + pub key_field: String, + #[serde(default)] + pub category: String, + #[serde(default)] + pub api_publisher: Option, + #[serde(default)] + pub api_group: Option, + #[serde(default)] + pub api_version: Option, + #[serde(default = "default_domain")] + pub domain: String, + #[serde(default)] + pub caution: Caution, + #[serde(default)] + pub source_table: String, + #[serde(default)] + pub page_number: String, + #[serde(default)] + pub editable: bool, + #[serde(default)] + pub field_names: Vec, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +fn default_supports() -> Vec { + vec!["GET".into()] +} +fn default_key_field() -> String { + "id".into() +} +fn default_domain() -> String { + "standard".into() +} + +impl EndpointMetadata { + pub fn is_custom(&self) -> bool { + self.api_publisher.is_some() + } + + pub fn route_display(&self) -> String { + if self.is_custom() { + format!( + "{}/{}/{}", + self.api_publisher.as_deref().unwrap_or("None"), + self.api_group.as_deref().unwrap_or("None"), + self.api_version.as_deref().unwrap_or("None"), + ) + } else { + "v2.0 (standard)".into() + } + } +} + +#[derive(Deserialize)] +struct StandardFile { + entities: Vec, +} + +#[derive(Deserialize)] +struct CustomFile { + #[serde(default)] + endpoints: Vec, +} + +#[derive(Debug, Default, Clone)] +pub struct RegistryOptions { + pub disable_standard: bool, + pub allowed_categories: Vec, + pub allowed_endpoints: Vec, +} + +#[derive(Debug, Default)] +pub struct Registry { + standard: IndexMap, + custom: IndexMap, + allowed_categories: Option>, + allowed_endpoints: Option>, +} + +impl Registry { + pub fn load(custom_file: Option<&Path>, opts: &RegistryOptions) -> Result { + let lower = |v: &[String]| -> Option> { + (!v.is_empty()).then(|| v.iter().map(|s| s.to_lowercase()).collect()) + }; + let mut registry = Registry { + allowed_categories: lower(&opts.allowed_categories), + allowed_endpoints: lower(&opts.allowed_endpoints), + ..Default::default() + }; + if !opts.disable_standard { + let parsed: StandardFile = serde_json::from_str(STANDARD_V2_JSON).map_err(|e| { + BcliError::registry(format!("Bundled standard registry is invalid: {e}")) + })?; + registry.standard = index(parsed.entities); + } + if let Some(path) = custom_file.filter(|p| p.is_file()) { + let text = std::fs::read_to_string(path) + .map_err(|e| BcliError::registry(format!("Cannot read {}: {e}", path.display())))?; + let parsed: CustomFile = serde_json::from_str(&text).map_err(|e| { + BcliError::registry(format!("Invalid registry file {}: {e}", path.display())) + })?; + registry.custom = index(parsed.endpoints); + } + Ok(registry) + } + + pub fn standard_count(&self) -> usize { + self.standard.len() + } + + pub fn custom_count(&self) -> usize { + self.custom.len() + } + + fn is_allowed(&self, meta: &EndpointMetadata) -> bool { + if let Some(allowed) = &self.allowed_endpoints { + if allowed.contains(&meta.entity_set_name.to_lowercase()) { + return true; + } + } + match &self.allowed_categories { + Some(categories) => categories.contains(&meta.category.to_lowercase()), + None => true, + } + } + + pub fn get(&self, name: &str) -> Option<&EndpointMetadata> { + let key = name.to_lowercase(); + self.custom + .get(&key) + .or_else(|| self.standard.get(&key)) + .filter(|meta| self.is_allowed(meta)) + } + + pub fn resolve(&self, name: &str) -> Result<&EndpointMetadata> { + self.get(name).ok_or_else(|| { + let suggestions: Vec<&str> = self + .search(name) + .into_iter() + .take(3) + .map(|m| m.entity_set_name.as_str()) + .collect(); + let hint = if suggestions.is_empty() { + String::new() + } else { + format!(" Did you mean: {}?", suggestions.join(", ")) + }; + BcliError::registry(format!( + "Endpoint '{name}' not found in any registry.{hint} Run 'bcli registry import' to \ + add custom APIs, or pass --publisher/--group/--version to target a custom API \ + route (this does NOT reach Microsoft's standard v2.0 entities)." + )) + }) + } + + /// Scored substring search: exact name 100, name 80, description 40, category 30. + pub fn search(&self, query: &str) -> Vec<&EndpointMetadata> { + let q = query.to_lowercase(); + let mut scored: Vec<(u8, &EndpointMetadata)> = self + .custom + .values() + .chain(self.standard.values()) + .filter(|meta| self.is_allowed(meta)) + .filter_map(|meta| { + let name = meta.entity_set_name.to_lowercase(); + let score = if q == name { + 100 + } else if name.contains(&q) { + 80 + } else if meta.description.to_lowercase().contains(&q) { + 40 + } else if meta.category.to_lowercase().contains(&q) { + 30 + } else { + 0 + }; + (score > 0).then_some((score, meta)) + }) + .collect(); + scored.sort_by(|a, b| { + b.0.cmp(&a.0) + .then_with(|| a.1.entity_set_name.cmp(&b.1.entity_set_name)) + }); + scored.into_iter().map(|(_, meta)| meta).collect() + } + + pub fn list_all(&self, custom_only: bool, standard_only: bool) -> Vec<&EndpointMetadata> { + let mut out = Vec::new(); + if !standard_only { + out.extend(self.sorted_allowed(&self.custom)); + } + if !custom_only { + out.extend(self.sorted_allowed(&self.standard)); + } + out + } + + fn sorted_allowed<'a>( + &self, + map: &'a IndexMap, + ) -> Vec<&'a EndpointMetadata> { + let mut v: Vec<&EndpointMetadata> = map.values().filter(|m| self.is_allowed(m)).collect(); + v.sort_by(|a, b| a.entity_set_name.cmp(&b.entity_set_name)); + v + } +} + +fn index(entries: Vec) -> IndexMap { + entries + .into_iter() + .map(|meta| (meta.entity_set_name.to_lowercase(), meta)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + const CUSTOM: &str = r#"{"endpoints": [ + {"entity_set_name": "widgetLines", "description": "Widget lines", "category": "ops", + "api_publisher": "acme", "api_group": "ops", "api_version": "v1.0", "supports": ["GET", "PATCH"]}, + {"entity_set_name": "customers", "description": "Custom customers", "category": "sales", + "api_publisher": "acme", "api_group": "sales", "api_version": "v2.0", "caution": "high"} + ]}"#; + + fn with_custom(opts: RegistryOptions) -> (tempfile::TempDir, Registry) { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("p.json"); + std::fs::write(&file, CUSTOM).unwrap(); + let reg = Registry::load(Some(&file), &opts).unwrap(); + (dir, reg) + } + + #[test] + fn standard_catalog_loads() { + let reg = Registry::load(None, &RegistryOptions::default()).unwrap(); + assert!(reg.standard_count() > 50); + assert_eq!(reg.get("CUSTOMERS").unwrap().entity_set_name, "customers"); + assert!(!reg.get("customers").unwrap().is_custom()); + } + + #[test] + fn custom_shadows_standard() { + let (_d, reg) = with_custom(RegistryOptions::default()); + let c = reg.get("customers").unwrap(); + assert!(c.is_custom()); + assert_eq!(c.caution, Caution::High); + assert_eq!(c.route_display(), "acme/sales/v2.0"); + } + + #[test] + fn allowlists_filter_reads() { + let (_d, reg) = with_custom(RegistryOptions { + disable_standard: true, + allowed_categories: vec!["OPS".into()], + allowed_endpoints: vec![], + }); + assert!(reg.get("customers").is_none()); + assert!(reg.get("widgetlines").is_some()); + assert_eq!(reg.list_all(false, false).len(), 1); + } + + #[test] + fn resolve_suggests_close_names() { + let (_d, reg) = with_custom(RegistryOptions::default()); + let err = reg.resolve("widget").unwrap_err(); + assert!( + err.to_string().contains("Did you mean: widgetLines?"), + "{err}" + ); + } + + #[test] + fn search_orders_by_score_then_name() { + let (_d, reg) = with_custom(RegistryOptions::default()); + let names: Vec<_> = reg + .search("customers") + .iter() + .map(|m| m.entity_set_name.clone()) + .collect(); + assert_eq!(names[0], "customers"); + } +} diff --git a/rust/crates/bcli-core/src/url.rs b/rust/crates/bcli-core/src/url.rs new file mode 100644 index 0000000..1483a5f --- /dev/null +++ b/rust/crates/bcli-core/src/url.rs @@ -0,0 +1,209 @@ +//! URL construction and origin checks (`bcli._url`). + +use crate::error::{BcliError, Result}; + +pub const BC_BASE_URL: &str = "https://api.businesscentral.dynamics.com/v2.0"; +pub const BC_ADMIN_BASE_URL: &str = "https://api.businesscentral.dynamics.com/admin/v2.1"; +pub const BC_STANDARD_API_PATH: &str = "api/v2.0"; +pub const BC_SCOPE: &str = "https://api.businesscentral.dynamics.com/.default"; +pub const ENTRA_AUTHORITY_BASE: &str = "https://login.microsoftonline.com"; + +const ALLOWED_HOST_SUFFIXES: [&str; 2] = ["businesscentral.dynamics.com", "bc.dynamics.com"]; + +/// Service base URLs. Production values by default; tests point them at a +/// local mock server. There is deliberately no env var or flag to change +/// them in a shipped binary: a bearer token follows these URLs. +#[derive(Debug, Clone)] +pub struct ServiceUrls { + pub bc_base: String, + pub bc_admin_base: String, + pub authority_base: String, +} + +impl Default for ServiceUrls { + fn default() -> Self { + Self { + bc_base: BC_BASE_URL.into(), + bc_admin_base: BC_ADMIN_BASE_URL.into(), + authority_base: ENTRA_AUTHORITY_BASE.into(), + } + } +} + +/// Custom API route: `api/{publisher}/{group}/{version}`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApiRoute { + pub publisher: String, + pub group: String, + pub version: String, +} + +fn validate_route_segment(name: &str, value: &str) -> Result<()> { + if value.trim().is_empty() { + return Err(BcliError::validation(format!( + "Invalid custom-API route segment '{name}': must not be empty." + ))); + } + if value.contains('/') || value.contains('\\') { + return Err(BcliError::validation(format!( + "Invalid custom-API route segment '{name}' = '{value}': must not contain '/' or '\\'." + ))); + } + if value == "." || value == ".." { + return Err(BcliError::validation(format!( + "Invalid custom-API route segment '{name}' = '{value}': \ + must not contain '.' or '..' path-traversal sequences." + ))); + } + Ok(()) +} + +/// A key or entity-set name must stay a single path component: `/`, `\`, +/// `?` and `#` would let it address a different resource than the one the +/// registry and `disable_standard_api` checks looked at. +pub fn validate_record_key(name: &str, value: &str) -> Result<()> { + if value.trim().is_empty() { + return Err(BcliError::validation(format!( + "Invalid {name}: must not be empty." + ))); + } + if let Some(ch) = ['/', '\\', '?', '#'] + .into_iter() + .find(|c| value.contains(*c)) + { + return Err(BcliError::validation(format!( + "Invalid {name} '{value}': must not contain '{ch}'. A key is a single URL path \ + component — percent-encode the character if it is genuinely part of the key." + ))); + } + if matches!(value.trim(), "." | "..") { + return Err(BcliError::validation(format!( + "Invalid {name} '{value}': '.' and '..' are path-traversal segments." + ))); + } + Ok(()) +} + +fn api_path(route: Option<&ApiRoute>) -> Result { + match route { + Some(r) => { + validate_route_segment("publisher", &r.publisher)?; + validate_route_segment("group", &r.group)?; + validate_route_segment("version", &r.version)?; + Ok(format!("api/{}/{}/{}", r.publisher, r.group, r.version)) + } + None => Ok(BC_STANDARD_API_PATH.into()), + } +} + +pub fn build_url( + urls: &ServiceUrls, + environment: &str, + company_id: &str, + entity_set_name: &str, + record_id: Option<&str>, + route: Option<&ApiRoute>, +) -> Result { + validate_record_key("entity_set_name", entity_set_name)?; + if let Some(id) = record_id { + validate_record_key("record_id", id)?; + } + let mut url = format!( + "{}/{environment}/{}/companies({company_id})/{entity_set_name}", + urls.bc_base, + api_path(route)? + ); + if let Some(id) = record_id { + url.push_str(&format!("({id})")); + } + Ok(url) +} + +pub fn build_companies_url(urls: &ServiceUrls, environment: &str) -> String { + format!( + "{}/{environment}/{BC_STANDARD_API_PATH}/companies", + urls.bc_base + ) +} + +pub fn build_environments_url(urls: &ServiceUrls) -> String { + format!( + "{}/applications/businesscentral/environments", + urls.bc_admin_base + ) +} + +/// True for relative URLs and `https` URLs on a Business Central host. +pub fn is_bc_origin(url: &str) -> bool { + let Some((scheme, rest)) = url.split_once("://") else { + return true; + }; + if !scheme.eq_ignore_ascii_case("https") { + return false; + } + let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); + let host_port = authority.rsplit('@').next().unwrap_or(""); + let host = host_port.split(':').next().unwrap_or("").to_lowercase(); + if host.is_empty() { + return false; + } + ALLOWED_HOST_SUFFIXES + .iter() + .any(|suffix| host == *suffix || host.ends_with(&format!(".{suffix}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_standard_and_custom_urls() { + let urls = ServiceUrls::default(); + assert_eq!( + build_url(&urls, "Production", "c1", "customers", None, None).unwrap(), + "https://api.businesscentral.dynamics.com/v2.0/Production/api/v2.0/companies(c1)/customers" + ); + let route = ApiRoute { + publisher: "acme".into(), + group: "ops".into(), + version: "v1.0".into(), + }; + assert_eq!( + build_url(&urls, "Sandbox", "c1", "widgets", Some("'W 1'"), Some(&route)).unwrap(), + "https://api.businesscentral.dynamics.com/v2.0/Sandbox/api/acme/ops/v1.0/companies(c1)/widgets('W 1')" + ); + } + + #[test] + fn rejects_path_injection() { + let urls = ServiceUrls::default(); + assert!(build_url(&urls, "P", "c", "foo(1)/../bar", None, None).is_err()); + assert!(build_url(&urls, "P", "c", "foo", Some(""), None).is_err()); + assert!(build_url(&urls, "P", "c", "foo", Some("a?b"), None).is_err()); + let bad = ApiRoute { + publisher: "..".into(), + group: "g".into(), + version: "v".into(), + }; + assert!(build_url(&urls, "P", "c", "foo", None, Some(&bad)).is_err()); + } + + #[test] + fn origin_allowlist() { + assert!(is_bc_origin("/relative?x=1")); + assert!(is_bc_origin( + "https://api.businesscentral.dynamics.com/v2.0/x" + )); + assert!(is_bc_origin("https://eu.api.bc.dynamics.com/x")); + assert!(!is_bc_origin("http://api.businesscentral.dynamics.com/x")); + assert!(!is_bc_origin( + "https://evilbusinesscentral.dynamics.com.attacker.example/x" + )); + assert!(!is_bc_origin( + "https://attacker.example/?h=api.businesscentral.dynamics.com" + )); + assert!(!is_bc_origin( + "https://api.businesscentral.dynamics.com@attacker.example/" + )); + } +} diff --git a/rust/crates/bcli-core/tests/transport.rs b/rust/crates/bcli-core/tests/transport.rs new file mode 100644 index 0000000..167372e --- /dev/null +++ b/rust/crates/bcli-core/tests/transport.rs @@ -0,0 +1,191 @@ +//! Transport + client-credentials behaviour against a local mock server. + +use std::time::Duration; + +use bcli_core::auth::SecretStore; +use bcli_core::client::BcClient; +use bcli_core::config::Profile; +use bcli_core::error::{ErrorKind, EXIT_AUTH, EXIT_REMOTE_5XX}; +use bcli_core::paths::Paths; +use bcli_core::url::ServiceUrls; +use serde_json::json; +use wiremock::matchers::{body_string_contains, header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +struct FakeKeyring(Option<&'static str>); + +impl SecretStore for FakeKeyring { + fn get(&self, service: &str, user: &str) -> Option { + assert_eq!(service, "bcli"); + assert_eq!(user, "tenant-1:client-1"); + self.0.map(str::to_owned) + } +} + +fn profile() -> Profile { + toml::from_str( + r#" +tenant_id = "tenant-1" +environment = "Production" +client_id = "client-1" +client_secret_env = "MY_SECRET" +"#, + ) + .unwrap() +} + +fn urls(server: &MockServer) -> ServiceUrls { + ServiceUrls { + bc_base: format!("{}/v2.0", server.uri()), + bc_admin_base: format!("{}/admin/v2.1", server.uri()), + authority_base: server.uri(), + } +} + +async fn mount_token(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/tenant-1/oauth2/v2.0/token")) + .and(body_string_contains("grant_type=client_credentials")) + .and(body_string_contains("client_secret=from-keyring")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "tok-123", "expires_in": 3599, "token_type": "Bearer" + }))) + .expect(1) + .mount(server) + .await; +} + +#[tokio::test] +async fn client_credentials_then_cached_token() { + let server = MockServer::start().await; + mount_token(&server).await; + Mock::given(method("GET")) + .and(path("/v2.0/Production/api/v2.0/companies")) + .and(header("authorization", "Bearer tok-123")) + .and(header("odata-version", "4.0")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "value": [{"id": "c1", "name": "Contoso"}] + }))) + .expect(2) + .mount(&server) + .await; + + let home = tempfile::tempdir().unwrap(); + let paths = Paths::from_home(home.path()); + let urls = urls(&server); + let keyring = FakeKeyring(Some("from-keyring")); + let env = |_: &str| None; + + for _ in 0..2 { + let mut client = BcClient::new( + profile(), + &paths, + &urls, + &keyring, + &env, + Duration::from_secs(5), + ) + .unwrap(); + let companies = client.list_companies().await.unwrap(); + assert_eq!(companies[0]["name"], "Contoso"); + } + let cache = std::fs::read_to_string(paths.token_cache_file()).unwrap(); + assert!(cache.contains("\"tenant-1:client-1\"")); +} + +#[tokio::test] +async fn missing_secret_is_a_config_error() { + let server = MockServer::start().await; + let home = tempfile::tempdir().unwrap(); + let paths = Paths::from_home(home.path()); + let urls = urls(&server); + let env = |_: &str| None; + let mut client = BcClient::new( + profile(), + &paths, + &urls, + &FakeKeyring(None), + &env, + Duration::from_secs(5), + ) + .unwrap(); + let err = client.list_companies().await.unwrap_err(); + assert_eq!(err.kind, ErrorKind::Config); + assert!(err.to_string().contains("export MY_SECRET=")); +} + +#[tokio::test] +async fn token_endpoint_error_maps_to_auth() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": "invalid_client", "error_description": "AADSTS7000215: Invalid client secret" + }))) + .mount(&server) + .await; + let home = tempfile::tempdir().unwrap(); + let paths = Paths::from_home(home.path()); + let urls = urls(&server); + let env = |k: &str| (k == "MY_SECRET").then(|| "s".to_string()); + let mut client = BcClient::new( + profile(), + &paths, + &urls, + &FakeKeyring(None), + &env, + Duration::from_secs(5), + ) + .unwrap(); + let err = client.list_companies().await.unwrap_err(); + assert_eq!(err.exit_code(), EXIT_AUTH); + assert_eq!( + err.to_string(), + "Failed to acquire token: AADSTS7000215: Invalid client secret" + ); +} + +#[tokio::test] +async fn retries_503_then_surfaces_bc_error() { + let server = MockServer::start().await; + mount_token(&server).await; + Mock::given(method("GET")) + .respond_with( + ResponseTemplate::new(503) + .insert_header("x-ms-correlation-request-id", "corr-9") + .set_body_json( + json!({"error": {"code": "Unavailable", "message": "Service down"}}), + ), + ) + .expect(3) + .mount(&server) + .await; + let home = tempfile::tempdir().unwrap(); + let paths = Paths::from_home(home.path()); + let urls = urls(&server); + let keyring = FakeKeyring(Some("from-keyring")); + let env = |_: &str| None; + let mut client = BcClient::new( + profile(), + &paths, + &urls, + &keyring, + &env, + Duration::from_secs(5), + ) + .unwrap(); + client.transport.max_retries = 2; + client.transport.initial_backoff = Duration::from_millis(1); + + let err = client.list_companies().await.unwrap_err(); + assert_eq!(err.kind, ErrorKind::Server); + assert_eq!(err.exit_code(), EXIT_REMOTE_5XX); + let text = err.to_string(); + assert!( + text.starts_with("HTTP 503 Service Unavailable: GET "), + "{text}" + ); + assert!( + text.ends_with(" | BC says: Service down | Correlation ID: corr-9"), + "{text}" + ); +} diff --git a/rust/rust-toolchain.toml b/rust/rust-toolchain.toml new file mode 100644 index 0000000..b0cd3b6 --- /dev/null +++ b/rust/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.98.1" +components = ["clippy", "rustfmt"] +profile = "minimal" diff --git a/rust/scripts/dump_python_surface.py b/rust/scripts/dump_python_surface.py new file mode 100644 index 0000000..73bdd41 --- /dev/null +++ b/rust/scripts/dump_python_surface.py @@ -0,0 +1,36 @@ +"""Dump the Python bcli Typer/click surface as JSON (fixture for the Rust parity test).""" +import json, sys +import typer +from bcli_cli.app import app + +def walk(cmd, path): + out = [] + if hasattr(cmd, 'commands'): + if path: + out.append({"path": path, "group": True, "options": [], "positionals": []}) + for name, sub in sorted(cmd.commands.items()): + out.extend(walk(sub, path + [name])) + if not path: + out.insert(0, {"path": [], "group": True, "options": opts(cmd), "positionals": []}) + return out + out.append({"path": path, "group": False, "options": opts(cmd), "positionals": pos(cmd)}) + return out + +def opts(cmd): + r = [] + for p in cmd.params: + if p.param_type_name == 'option': + if p.name == "help": + continue + r.append({"long": sorted(o for o in p.opts if o.startswith("--")), + "short": sorted(o for o in p.opts if not o.startswith("--")), + "flag": bool(p.is_flag), "hidden": bool(p.hidden), + "multiple": bool(p.multiple), "required": bool(p.required)}) + return r + +def pos(cmd): + return [{"name": p.name, "required": bool(p.required), "nargs": p.nargs} + for p in cmd.params if p.param_type_name == 'argument'] + +root = typer.main.get_command(app) +json.dump(walk(root, []), sys.stdout, indent=1) diff --git a/rust/scripts/parity.sh b/rust/scripts/parity.sh new file mode 100755 index 0000000..381e1d3 --- /dev/null +++ b/rust/scripts/parity.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Regenerate the golden outputs from the Python bcli, then diff the Rust +# binary against them. +# +# PY_BCLI=/path/to/python/bcli rust/scripts/parity.sh +# +# PY_BCLI defaults to `bcli` on PATH (the Python install). The Rust binary is +# built from this workspace. Exit status is non-zero on any difference. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +fixtures="$here/crates/bcli-cli/tests/fixtures" +golden="$fixtures/golden" +py="${PY_BCLI:-bcli}" + +cargo build --quiet --manifest-path "$here/Cargo.toml" -p bcli-cli +rs="$here/target/debug/bcli" + +run() { # run -> writes $out.{out,err,code} + local bin="$1" out="$2"; shift 2 + set +e + env -i PATH="$PATH" HOME="$fixtures/home" "$bin" "$@" >"$out.out" 2>"$out.err" + echo $? >"$out.code" + set -e +} + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +status=0 +while IFS='|' read -r name args; do + name="$(echo "$name" | xargs)" + [[ -z "$name" || "$name" == \#* ]] && continue + read -r -a argv <<<"$args" + run "$py" "$golden/$name" "${argv[@]}" + run "$rs" "$tmp/$name" "${argv[@]}" + for ext in out err code; do + if ! cmp -s "$golden/$name.$ext" "$tmp/$name.$ext"; then + echo "DIFF $name.$ext" + diff "$golden/$name.$ext" "$tmp/$name.$ext" | head -20 || true + status=1 + fi + done +done <"$golden/cases.txt" + +# Refresh the CLI-surface fixture used by tests/surface_parity.rs. +py_python="$(dirname "$(command -v "$py")")/python" +if [[ -x "$py_python" ]]; then + env -i PATH="$PATH" HOME="$tmp" "$py_python" "$here/scripts/dump_python_surface.py" \ + >"$fixtures/python-cli-surface.json" +fi + +[[ $status -eq 0 ]] && echo "parity: all cases match" +exit $status