From 301ccad3aaa3c3a307c79899a8ae1cb26335d500 Mon Sep 17 00:00:00 2001 From: Colin Patrick McCabe Date: Thu, 29 Oct 2026 12:39:46 -0700 Subject: [PATCH] feat: add a LanceDB command line (#4600) Add a LanceDB command line with the following functionality: - `lancedb auth`: log in with OAuth, check cached OAuth session status, or log out. - `lancedb profile`: Create, edit, list, show, select, and delete connection profiles. - When no subcommand is given, we will attempt to create a SQL shell. Update ci/check_cargo_lock.py and its regression tests to allow pruning dependency edges while their target packages remain in Cargo.lock. Removing the CLI from the Python source distribution can disable optional features such as Axum tracing, even though other SDK dependencies still require the tracing package. --- .github/workflows/cli.yml | 45 + .github/workflows/pypi-publish.yml | 8 +- .github/workflows/rust.yml | 16 +- Cargo.lock | 2673 ++++++++++++--------- Cargo.toml | 8 + ci/check_cargo_lock.py | 7 +- ci/tests/test_check_cargo_lock.py | 34 +- docs/web/basics/sql.mdx | 3 + docs/web/docs.json | 6 + docs/web/tools/cli.mdx | 217 ++ rust/lancedb/Cargo.toml | 15 +- rust/lancedb/cli/Cargo.toml | 52 + rust/lancedb/cli/src/auth.rs | 419 ++++ rust/lancedb/cli/src/auth/tests.rs | 417 ++++ rust/lancedb/cli/src/cli.rs | 166 ++ rust/lancedb/cli/src/client.rs | 2110 ++++++++++++++++ rust/lancedb/cli/src/dml_result.rs | 137 ++ rust/lancedb/cli/src/formatter.rs | 2516 +++++++++++++++++++ rust/lancedb/cli/src/lib.rs | 55 + rust/lancedb/cli/src/main.rs | 10 + rust/lancedb/cli/src/mocks.rs | 119 + rust/lancedb/cli/src/profile.rs | 494 ++++ rust/lancedb/cli/src/server_config.rs | 1310 ++++++++++ rust/lancedb/cli/src/sql_executor.rs | 140 ++ rust/lancedb/cli/src/table_writer.rs | 380 +++ rust/lancedb/cli/tests/command_surface.rs | 339 +++ rust/lancedb/cli/tests/flight_sql_tls.rs | 643 +++++ rust/lancedb/src/connection.rs | 6 + rust/lancedb/src/database.rs | 6 + rust/lancedb/src/job.rs | 2 + rust/lancedb/src/remote/db.rs | 10 + rust/lancedb/src/remote/sql.rs | 20 +- 32 files changed, 11206 insertions(+), 1177 deletions(-) create mode 100644 .github/workflows/cli.yml create mode 100644 docs/web/tools/cli.mdx create mode 100644 rust/lancedb/cli/Cargo.toml create mode 100644 rust/lancedb/cli/src/auth.rs create mode 100644 rust/lancedb/cli/src/auth/tests.rs create mode 100644 rust/lancedb/cli/src/cli.rs create mode 100644 rust/lancedb/cli/src/client.rs create mode 100644 rust/lancedb/cli/src/dml_result.rs create mode 100644 rust/lancedb/cli/src/formatter.rs create mode 100644 rust/lancedb/cli/src/lib.rs create mode 100644 rust/lancedb/cli/src/main.rs create mode 100644 rust/lancedb/cli/src/mocks.rs create mode 100644 rust/lancedb/cli/src/profile.rs create mode 100644 rust/lancedb/cli/src/server_config.rs create mode 100644 rust/lancedb/cli/src/sql_executor.rs create mode 100644 rust/lancedb/cli/src/table_writer.rs create mode 100644 rust/lancedb/cli/tests/command_surface.rs create mode 100644 rust/lancedb/cli/tests/flight_sql_tls.rs diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml new file mode 100644 index 000000000..eb57a3804 --- /dev/null +++ b/.github/workflows/cli.yml @@ -0,0 +1,45 @@ +name: CLI +on: + push: + branches: [main] + pull_request: + paths: + - 'rust/lancedb/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/cli.yml' +permissions: + contents: read +jobs: + test: + strategy: + matrix: + os: [ubuntu-24.04, macos-14] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 + with: + components: clippy + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2 + - name: Install Linux prerequisites + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler libssl-dev clang + - name: Install macOS prerequisites + if: runner.os == 'macOS' + run: brew install protobuf + - name: Build standalone CLI binary + run: cargo build --locked --profile ci -p lancedb-cli --bin lancedb + - name: Test standalone CLI + run: cargo test --locked --profile ci -p lancedb-cli + - name: Test SDK SQL + run: cargo test --locked --profile ci -p lancedb --features remote --lib remote::sql + - name: Lint CLI + run: cargo clippy --locked --profile ci -p lancedb-cli --all-targets --all-features -- -D warnings + - name: Check SDK without remote support + # Feature-disabled SDK code has existing unused-code warnings; this is a compile check. + env: + RUSTFLAGS: "" + run: cargo check --locked --profile ci -p lancedb --no-default-features --tests diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 1784c4c8a..d45833def 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -187,12 +187,14 @@ jobs: command: sdist working-directory: python args: --out dist - # maturin ships the nodejs-only pins in Cargo.lock. Remove this step when - # the pinned maturin fixes https://github.com/PyO3/maturin/issues/2609. + # maturin ships pins used only by other workspace members in Cargo.lock. + # Remove this step when the pinned maturin fixes + # https://github.com/PyO3/maturin/issues/2609. - name: Sync Cargo.lock to the packaged workspace shell: bash run: | - # A repo lock in sync with every manifest means the prune only drops pins. + # A synced repo lock lets the prune drop packages and optional edges + # without changing the remaining pins or adding dependency edges. cargo metadata --locked --format-version 1 > /dev/null mkdir "$RUNNER_TEMP/sdist-src" tar -xzf python/dist/lancedb-*.tar.gz -C "$RUNNER_TEMP/sdist-src" diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 96b0f2bfb..9b24a1f77 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -404,10 +404,18 @@ jobs: # unreadable outside their own branch anyway, since GitHub scopes # caches to the creating ref. save-if: ${{ github.ref == 'refs/heads/main' }} - - name: Downgrade dependencies - # aws-smithy-types 1.6.1+ requires rustc 1.94.1 - run: cargo update -p aws-smithy-types --precise 1.5.0 + - name: Resolve MSRV-compatible dependencies + # Resolve the AWS SDK and Smithy crates together: newer SDK releases + # require newer Smithy versions, so downgrading only Smithy can fail. + run: cargo +${{ matrix.msrv }} update --config 'resolver.incompatible-rust-versions="fallback"' + - name: Keep Xet dependencies compatible with the MSRV + # Xet 1.7 does not declare its MSRV, but requires sysinfo 0.39 (Rust 1.95). + # Downgrade parents before their dependencies to satisfy their constraints. + run: | + for crate in hf-xet xet-data xet-client xet-core-structures xet-runtime; do + cargo +${{ matrix.msrv }} update -p "$crate" --precise 1.6.0 + done - name: cargo +${{ matrix.msrv }} check env: RUSTUP_TOOLCHAIN: ${{ matrix.msrv }} - run: cargo check --profile ci --workspace --tests --benches --all-features + run: cargo check --locked --profile ci --workspace --tests --benches --all-features diff --git a/Cargo.lock b/Cargo.lock index 40811ab1d..a58fa195a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,7 +23,7 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cipher", "cpufeatures 0.2.17", ] @@ -34,7 +34,7 @@ version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "const-random", "getrandom 0.3.4", "once_cell", @@ -44,9 +44,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -68,9 +68,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -92,9 +92,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -141,7 +141,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -152,7 +152,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -172,11 +172,11 @@ dependencies = [ [[package]] name = "ar_archive_writer" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" dependencies = [ - "object", + "object 0.39.1", ] [[package]] @@ -211,9 +211,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "arrow" @@ -390,7 +390,7 @@ dependencies = [ "arrow-select", "chrono", "half", - "indexmap 2.14.0", + "indexmap 2.14.2", "itoa", "lexical-core", "memchr", @@ -445,7 +445,7 @@ version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21ca356ad6425cecb6eb7b28e4f659f1ee7880fbb1a16127de7dd62901efee9e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "serde_core", "serde_json", ] @@ -495,9 +495,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970" dependencies = [ "compression-codecs", "compression-core", @@ -537,7 +537,7 @@ dependencies = [ "bytes", "derive_builder", "futures", - "rand 0.8.6", + "rand 0.8.8", "reqwest 0.12.28", "reqwest-eventsource", "secrecy", @@ -552,13 +552,13 @@ dependencies = [ [[package]] name = "async-recursion" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -569,7 +569,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.9", ] [[package]] @@ -583,9 +583,12 @@ dependencies = [ [[package]] name = "asyncband" -version = "0.7.2" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2d85fd3d291fabcc40c7232c92c280ec1754fd7b5d7ea769f143222143e179a" +checksum = "5d2050892d4691cf110586070786712b504985a4d37ed64a9cd8a402718e1706" +dependencies = [ + "hashbrown 0.17.1", +] [[package]] name = "atoi" @@ -610,15 +613,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-config" -version = "1.8.16" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50f156acdd2cf55f5aa53ee416c4ac851cf1222694506c0b1f78c85695e9ca9d" +checksum = "b8d7b388a9fc3a6db15a5ec778c38b354eff1364882c94d08e0252f7a47dcaa4" dependencies = [ "aws-credential-types", "aws-runtime", @@ -630,13 +633,14 @@ dependencies = [ "aws-smithy-json", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-types", "bytes", "fastrand", "hex", "http 1.5.0", - "sha1 0.10.6", + "sha1 0.10.7", "time", "tokio", "tracing", @@ -646,9 +650,9 @@ dependencies = [ [[package]] name = "aws-credential-types" -version = "1.2.14" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -682,9 +686,9 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.7.3" +version = "1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dcd93c82209ac7413532388067dce79be5a8780c1786e5fae3df22e4dee2864" +checksum = "25b43ad47adc2517efe3d706559d94b97e50e80e0321b3cadbc9f77cee88adcd" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -710,10 +714,11 @@ dependencies = [ [[package]] name = "aws-sdk-bedrockruntime" -version = "1.130.0" +version = "1.145.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e2f7bca252e3c5c8f0ed12c5501bf8b0fbadb937cd9fdd71a0ebd9d7526540f" +checksum = "aed4de4a98241f9dd95ddad4223755b7afc3327d0a148a2a29286c95ef64db5e" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "aws-sigv4", @@ -724,6 +729,7 @@ dependencies = [ "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-types", "bytes", @@ -737,10 +743,11 @@ dependencies = [ [[package]] name = "aws-sdk-dynamodb" -version = "1.111.0" +version = "1.126.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc418346e3cb248c7d59e642acbcb06488b7c7cd2ba6ebc79e8003f618c60099" +checksum = "84916ddc26951545be16e11fe45277e2d2561b9d0f2d0538274fb750c09b3eae" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", @@ -749,6 +756,7 @@ dependencies = [ "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-types", "bytes", @@ -757,14 +765,16 @@ dependencies = [ "http 1.5.0", "regex-lite", "tracing", + "url", ] [[package]] name = "aws-sdk-kms" -version = "1.106.0" +version = "1.120.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2336350f96efcf9c2552b7fdb4dd07a0c1fef11f22b28fb020a9e33e7925cf9d" +checksum = "d6b0fe38fee2ba5b6cd24d32d08365b314ae1adea123649e061a7eb6300b6f5b" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", @@ -773,6 +783,7 @@ dependencies = [ "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-types", "bytes", @@ -785,10 +796,11 @@ dependencies = [ [[package]] name = "aws-sdk-s3" -version = "1.132.0" +version = "1.148.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5575840a3a6b11f6011463ebe359320dfe5b67babb5e9b06fed6ddf809a9ab40" +checksum = "8a61c1db3987ab6c8740fb87f248864687d50549401115244a84f02c3047b2d5" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "aws-sigv4", @@ -800,6 +812,7 @@ dependencies = [ "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-smithy-xml", "aws-types", @@ -810,20 +823,21 @@ dependencies = [ "http 0.2.12", "http 1.5.0", "http-body 1.1.0", - "lru", + "lru 0.18.5", "percent-encoding", "regex-lite", - "sha2 0.11.0", + "sha2 0.11.1", "tracing", "url", ] [[package]] name = "aws-sdk-sso" -version = "1.98.0" +version = "1.111.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d69c77aafa20460c68b6b3213c84f6423b6e76dbf89accd3e1789a686ffd9489" +checksum = "65700d75f6da78c89a61a49f1539979400a4609d3329376b1dc2133d7a1b2b90" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", @@ -832,6 +846,7 @@ dependencies = [ "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-types", "bytes", @@ -844,10 +859,11 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.100.0" +version = "1.113.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7e7b09346d5ca22a2a08267555843a6a0127fb20d8964cb6ecfb8fdb190225" +checksum = "eb1147c254af7b6cee3ace90ae7da6f21366ce9a9b899035fa8a0fdf586eb75a" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", @@ -856,6 +872,7 @@ dependencies = [ "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-types", "bytes", @@ -868,10 +885,11 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.103.0" +version = "1.116.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2249b81a2e73a8027c41c378463a81ec39b8510f184f2caab87de912af0f49b" +checksum = "035c7392e751d20bf1060d625d023587eb14099646110a9cff7c697bfe4b253a" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", @@ -881,6 +899,7 @@ dependencies = [ "aws-smithy-query", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-smithy-xml", "aws-types", @@ -893,9 +912,9 @@ dependencies = [ [[package]] name = "aws-sigv4" -version = "1.4.3" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68dc0b907359b120170613b5c09ccc61304eac3998ff6274b97d93ee6490115a" +checksum = "31d955e76ff96acd555bf06fa0fa6d5bf9335fa84ae7c64481b20ae61d231f70" dependencies = [ "aws-credential-types", "aws-smithy-eventstream", @@ -903,7 +922,7 @@ dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", "bytes", - "crypto-bigint 0.5.5", + "crypto-bigint", "form_urlencoded", "hex", "hmac 0.13.0", @@ -911,8 +930,7 @@ dependencies = [ "http 1.5.0", "p256", "percent-encoding", - "ring", - "sha2 0.11.0", + "sha2 0.11.1", "subtle", "time", "tracing", @@ -921,9 +939,9 @@ dependencies = [ [[package]] name = "aws-smithy-async" -version = "1.2.14" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +checksum = "de681a9df7ff79823b759ee17061076ec049cd61a8fa28ece4293d89c9f6964e" dependencies = [ "futures-util", "pin-project-lite", @@ -932,9 +950,9 @@ dependencies = [ [[package]] name = "aws-smithy-checksums" -version = "0.64.7" +version = "0.65.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10efbbcec1e044b81600e2fc562a391951d291152d95b482d5b7e7132299d762" +checksum = "b67ecd999972b58e67cab052f5129906c08c25883bd0788ceefc55ef97d61307" dependencies = [ "aws-smithy-http", "aws-smithy-types", @@ -947,15 +965,15 @@ dependencies = [ "md-5", "pin-project-lite", "sha1 0.11.0", - "sha2 0.11.0", + "sha2 0.11.1", "tracing", ] [[package]] name = "aws-smithy-eventstream" -version = "0.60.20" +version = "0.61.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf09d74e5e32f76b8762da505a3cd59303e367a664ca67295387baa8c1d7548" +checksum = "80c2051c2f1016fb8e6548dd07b8bc2ac9c3fe583721444b92f515e856d31609" dependencies = [ "aws-smithy-types", "bytes", @@ -964,9 +982,9 @@ dependencies = [ [[package]] name = "aws-smithy-http" -version = "0.63.6" +version = "0.64.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d" dependencies = [ "aws-smithy-eventstream", "aws-smithy-runtime-api", @@ -986,9 +1004,9 @@ dependencies = [ [[package]] name = "aws-smithy-http-client" -version = "1.1.13" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c3ef8931ad1c98aa6a55b4256f847f3116090819844e0dd41ea682cac5dd2d3" +checksum = "7bd25384a4e437aa8d8f339afad4b69e786b936a7cb10db668a7aaf66717b1a8" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -1010,9 +1028,9 @@ dependencies = [ [[package]] name = "aws-smithy-json" -version = "0.62.7" +version = "0.63.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "701a947f4797e52a911e114a898667c746c39feea467bbd1abd7b3721f702ffa" +checksum = "3385d469edbe8b60cc72002784652b5efca39178192aa9cc4b44c9875c6bdc18" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-schema", @@ -1021,28 +1039,31 @@ dependencies = [ [[package]] name = "aws-smithy-observability" -version = "0.2.6" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +checksum = "8e86338c869539a581bf161247762a6e87f92c5c075060057b5ed6d06632ed0c" dependencies = [ "aws-smithy-runtime-api", ] [[package]] name = "aws-smithy-query" -version = "0.60.15" +version = "0.62.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" +checksum = "f1d1d71f6562be974caa85442ecd90194c40fdb5df045f182a6c2e872ce95056" dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", + "aws-smithy-xml", "urlencoding", ] [[package]] name = "aws-smithy-runtime" -version = "1.11.3" +version = "1.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e6f5caf6fea86f8c2206541ab5857cfcda9013426cdbe8fa0098b9e2d32182" +checksum = "3296253d3a91b3f938a3f2bcce4daebadcb4aa4228153fcec90f9d23532b4484" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -1066,9 +1087,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.12.3" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9db177daa6ba8afb9ee1aefcf548c907abcf52065e394ee11a92780057fe0e8c" +checksum = "6d881a7b7ad179fd6611680c9de89f716fb00ab40299a9a7b8c6913e8f7511a8" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api-macros", @@ -1084,20 +1105,20 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api-macros" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "aws-smithy-schema" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7442cb268338f0eb8278140a107c046756aa01093d8ef5e99628d34ae09c94f5" +checksum = "e8f395d93304280b64b7632fea798d177e74897fe7f063416ce627cd6fa24829" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", @@ -1132,22 +1153,26 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.60.15" +version = "0.62.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +checksum = "0b932c8d6dc127fc980eecd78f8694ae9b9551b69a93a7def2a199c1c0033daf" dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", "xmlparser", ] [[package]] name = "aws-types" -version = "1.3.15" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bbcaa9304ea40902d3d5f42a0428d1bd895a2b0f6999436fb279ffddc58ac" +checksum = "209f3a6d82a6e9e5f94abbed94c7a26e1c052341002bf57a5fb5481f625896fc" dependencies = [ "aws-credential-types", "aws-smithy-async", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "rustc_version", "tracing", @@ -1195,10 +1220,13 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core 0.5.6", "bytes", + "form_urlencoded", "futures-util", "http 1.5.0", "http-body 1.1.0", "http-body-util", + "hyper", + "hyper-util", "itoa", "matchit 0.8.4", "memchr", @@ -1206,10 +1234,15 @@ dependencies = [ "percent-encoding", "pin-project-lite", "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", "sync_wrapper", + "tokio", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1249,6 +1282,7 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1261,7 +1295,7 @@ dependencies = [ "getrandom 0.2.17", "instant", "pin-project-lite", - "rand 0.8.6", + "rand 0.8.8", "tokio", ] @@ -1283,19 +1317,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", - "cfg-if 1.0.4", + "cfg-if", "libc", - "miniz_oxide", - "object", + "miniz_oxide 0.8.9", + "object 0.37.3", "rustc-demangle", "windows-link", ] [[package]] name = "base16ct" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] name = "base64" @@ -1339,9 +1373,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bigdecimal" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653" dependencies = [ "autocfg", "libm", @@ -1373,15 +1407,15 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -1400,16 +1434,15 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.5" +version = "1.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" dependencies = [ - "arrayref", "arrayvec", "cc", - "cfg-if 1.0.4", + "cfg-if", "constant_time_eq", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] @@ -1423,9 +1456,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -1471,26 +1504,26 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", - "serde", + "serde_core", ] [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytecheck" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0caa33a2c0edca0419d15ac723dff03f1956f7978329b1e3b5fdaaaed9d3ca8b" +checksum = "26333eeac754f0ad8a6bcd0eb0ac012156302e4e16b852b72ee399aea4f12c29" dependencies = [ "bytecheck_derive", "ptr_meta", @@ -1500,13 +1533,13 @@ dependencies = [ [[package]] name = "bytecheck_derive" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" +checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -1526,13 +1559,13 @@ dependencies = [ [[package]] name = "bytemuck_derive" -version = "1.10.2" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -1568,14 +1601,14 @@ dependencies = [ "gemm", "half", "libm", - "memmap2 0.9.10", + "memmap2 0.9.12", "num-traits", "num_cpus", "rand 0.9.5", "rand_distr 0.5.1", "rayon", "safetensors", - "thiserror 2.0.18", + "thiserror 2.0.21", "yoke", "zip", ] @@ -1593,7 +1626,7 @@ dependencies = [ "rayon", "safetensors", "serde", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -1632,9 +1665,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" dependencies = [ "find-msvc-tools", "jobserver", @@ -1653,21 +1686,21 @@ dependencies = [ [[package]] name = "cfg-if" -version = "0.1.10" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -1675,8 +1708,8 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ - "cfg-if 1.0.4", - "cpufeatures 0.3.0", + "cfg-if", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -1765,28 +1798,52 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.9", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "clipboard-win" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" +dependencies = [ + "error-code", +] [[package]] name = "cmake" @@ -1815,14 +1872,14 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "combine" -version = "4.6.7" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", "memchr", @@ -1836,14 +1893,14 @@ checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" dependencies = [ "crossterm", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] name = "compression-codecs" -version = "0.4.38" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe" dependencies = [ "compression-core", "flate2", @@ -1852,9 +1909,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.32" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a" [[package]] name = "concurrent-queue" @@ -1874,7 +1931,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.2", "windows-sys 0.59.0", ] @@ -1918,9 +1975,9 @@ checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" [[package]] name = "const_panic" -version = "0.2.15" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652" +checksum = "9603f79528ece8163c496f8932121cb36cfe46259e9c907bb3d8205139d7caa3" dependencies = [ "typewit", ] @@ -1966,6 +2023,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "countio" version = "0.3.0" @@ -1981,7 +2044,7 @@ version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", ] [[package]] @@ -1995,47 +2058,30 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] -[[package]] -name = "crc" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" - [[package]] name = "crc-fast" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" dependencies = [ - "crc", "digest 0.10.7", - "rustversion", "spin 0.10.1", ] [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", ] [[package]] @@ -2076,18 +2122,18 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -2095,18 +2141,18 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.20" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" dependencies = [ "crossbeam-utils", ] @@ -2123,9 +2169,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crossterm" @@ -2133,11 +2179,11 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "crossterm_winapi", "document-features", "parking_lot", - "rustix", + "rustix 1.1.5", "winapi", ] @@ -2158,9 +2204,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-bigint" -version = "0.4.9" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", "rand_core 0.6.4", @@ -2168,16 +2214,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "crypto-common" version = "0.1.7" @@ -2190,9 +2226,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", ] @@ -2220,9 +2256,9 @@ dependencies = [ [[package]] name = "ctor" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d83cb7e7a873830708d6b02a78cd36a592c6fa14bf267b68725103b85c0d77f" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" dependencies = [ "link-section", "linktime-proc-macro", @@ -2230,9 +2266,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.4" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12218662ccac37d9714f2191252553d67585b97df8c839cbe49e0bde278dc865" +checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a" dependencies = [ "cmov", ] @@ -2274,7 +2310,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2287,7 +2323,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 3.0.3", + "syn 3.0.9", ] [[package]] @@ -2298,7 +2334,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2309,16 +2345,16 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core 0.24.1", "quote", - "syn 3.0.3", + "syn 3.0.9", ] [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "crossbeam-utils", "hashbrown 0.14.5", "lock_api", @@ -2361,7 +2397,7 @@ dependencies = [ "datafusion-session", "datafusion-sql", "futures", - "indexmap 2.14.0", + "indexmap 2.14.2", "itertools 0.14.0", "log", "object_store 0.13.2", @@ -2434,7 +2470,7 @@ dependencies = [ "foldhash 0.2.0", "half", "hashbrown 0.17.1", - "indexmap 2.14.0", + "indexmap 2.14.2", "itertools 0.14.0", "libc", "log", @@ -2600,7 +2636,7 @@ dependencies = [ "datafusion-functions-aggregate-common", "datafusion-functions-window-common", "datafusion-physical-expr-common", - "indexmap 2.14.0", + "indexmap 2.14.2", "itertools 0.14.0", "serde_json", "sqlparser 0.62.0", @@ -2614,7 +2650,7 @@ checksum = "b7b19a8c95522bee8cbb313d74263b85e355d2b52f42e67ef5694bf5de9e9356" dependencies = [ "arrow", "datafusion-common", - "indexmap 2.14.0", + "indexmap 2.14.2", "itertools 0.14.0", ] @@ -2646,7 +2682,7 @@ dependencies = [ "num-traits", "rand 0.9.5", "regex", - "sha2 0.11.0", + "sha2 0.11.1", "uuid", ] @@ -2759,7 +2795,7 @@ checksum = "1a3614234dd93578c92428cb4f408e020874f0d2b7e6c90c928d9d28b5df2ceb" dependencies = [ "datafusion-doc", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2774,7 +2810,7 @@ dependencies = [ "datafusion-expr", "datafusion-expr-common", "datafusion-physical-expr", - "indexmap 2.14.0", + "indexmap 2.14.2", "itertools 0.14.0", "log", "regex", @@ -2795,7 +2831,7 @@ dependencies = [ "datafusion-physical-expr-common", "half", "hashbrown 0.17.1", - "indexmap 2.14.0", + "indexmap 2.14.2", "itertools 0.14.0", "parking_lot", "petgraph", @@ -2828,7 +2864,7 @@ dependencies = [ "datafusion-common", "datafusion-expr-common", "hashbrown 0.17.1", - "indexmap 2.14.0", + "indexmap 2.14.2", "itertools 0.14.0", "parking_lot", "pin-project", @@ -2876,7 +2912,7 @@ dependencies = [ "futures", "half", "hashbrown 0.17.1", - "indexmap 2.14.0", + "indexmap 2.14.2", "itertools 0.14.0", "log", "num-traits", @@ -2927,7 +2963,7 @@ dependencies = [ "datafusion-common", "datafusion-expr", "datafusion-functions-nested", - "indexmap 2.14.0", + "indexmap 2.14.2", "log", "regex", "sqlparser 0.62.0", @@ -2950,9 +2986,9 @@ dependencies = [ [[package]] name = "defmt" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" dependencies = [ "bitflags 1.3.2", "defmt-macros", @@ -2960,33 +2996,23 @@ dependencies = [ [[package]] name = "defmt-macros" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] name = "defmt-parser" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" dependencies = [ - "thiserror 2.0.18", -] - -[[package]] -name = "der" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" -dependencies = [ - "const-oid 0.9.6", - "zeroize", + "thiserror 2.0.21", ] [[package]] @@ -3002,11 +3028,10 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" dependencies = [ - "powerfmt", "serde_core", ] @@ -3028,7 +3053,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3038,7 +3063,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3059,19 +3084,40 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid 0.10.2", - "crypto-common 0.2.1", + "crypto-common 0.2.2", "ctutils", ] +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -3082,8 +3128,8 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", - "windows-sys 0.59.0", + "redox_users 0.5.3", + "windows-sys 0.61.2", ] [[package]] @@ -3092,19 +3138,19 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "objc2", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -3165,36 +3211,38 @@ dependencies = [ [[package]] name = "ecdsa" -version = "0.14.8" +version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der 0.6.1", + "der", + "digest 0.10.7", "elliptic-curve", "rfc6979", - "signature 1.6.4", + "signature", + "spki", ] [[package]] name = "either" -version = "1.15.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "elliptic-curve" -version = "0.12.3" +version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", - "crypto-bigint 0.4.9", - "der 0.6.1", + "crypto-bigint", "digest 0.10.7", "ff", "generic-array", "group", - "pkcs8 0.9.0", + "pem-rfc7468", + "pkcs8", "rand_core 0.6.4", "sec1", "subtle", @@ -3209,18 +3257,23 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] name = "encoding_rs_io" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" dependencies = [ "encoding_rs", ] @@ -3240,7 +3293,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3252,7 +3305,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3295,14 +3348,14 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -3311,9 +3364,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + [[package]] name = "esaxx-rs" version = "0.1.10" @@ -3331,11 +3390,10 @@ checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -3386,21 +3444,32 @@ checksum = "95765f67b4b18863968b4a1bd5bb576f732b29a4a28c7cd84c09fa3e2875f33c" [[package]] name = "fast-float2" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" +checksum = "c6e8948ce679d00a02a94739ea185595dca7118ed04feb991127e443bd3d761f" [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.5", + "windows-sys 0.59.0", +] [[package]] name = "ff" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ "rand_core 0.6.4", "subtle", @@ -3408,9 +3477,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "findshlibs" @@ -3436,18 +3505,19 @@ version = "25.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "rustc_version", ] [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", ] [[package]] @@ -3486,6 +3556,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[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 = "foreign_vec" version = "0.1.0" @@ -3509,7 +3594,7 @@ checksum = "643b47d510032a01e5af70dca288b0c5ec531646c1a8392e793fb5b0c13bef30" dependencies = [ "anyhow", "bytes", - "cfg-if 1.0.4", + "cfg-if", "foyer-tokio", "mixtrics", "parking_lot", @@ -3534,7 +3619,7 @@ checksum = "ae51f5089f3d9025ae77f17ea66d2489ac0f82fbb1d91462807bea174d486d82" dependencies = [ "anyhow", "asyncband", - "bitflags 2.11.1", + "bitflags 2.13.2", "datasketches", "equivalent", "foyer-common", @@ -3562,9 +3647,9 @@ dependencies = [ [[package]] name = "frostem" -version = "1.20260804.1" +version = "1.20260821.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82eb03a32a1d50555353c85a7b9d3279a6f1e91af9890b789acdf544ed57c8d7" +checksum = "9e2488a10218a17366ce6bd38b6d081b4387a9548d350029db9feddc9bb44d6d" [[package]] name = "fs4" @@ -3572,7 +3657,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" dependencies = [ - "rustix", + "rustix 1.1.5", "windows-sys 0.59.0", ] @@ -3608,9 +3693,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -3623,9 +3708,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -3633,15 +3718,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -3650,44 +3735,44 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-timer" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -3702,12 +3787,9 @@ dependencies = [ [[package]] name = "gearhash" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8cf82cf76cd16485e56295a1377c775ce708c9f1a0be6b029076d60a245d213" -dependencies = [ - "cfg-if 0.1.10", -] +checksum = "616e8f476a586f1b078d9eece21f7a071f27997e709e2b471f5697deea53bda9" [[package]] name = "gemm" @@ -3830,12 +3912,12 @@ dependencies = [ [[package]] name = "generator" -version = "0.8.8" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +checksum = "1be81b31c9ce4caf7f3178eeb90662bb1864d3ad363ec6bdada3201adff19c37" dependencies = [ "cc", - "cfg-if 1.0.4", + "cfg-if", "libc", "log", "rustversion", @@ -3851,6 +3933,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -3882,15 +3965,16 @@ dependencies = [ [[package]] name = "geo-types" -version = "0.7.19" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5" +checksum = "777d18aa0f12f8b285331cd867133ee14422b3f023f6d388034c47d43e28786a" dependencies = [ "approx", "num-traits", "rayon", "rstar", "serde", + "thiserror 2.0.21", ] [[package]] @@ -3982,7 +4066,7 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -3995,7 +4079,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "libc", "r-efi 5.3.0", "wasip2", @@ -4003,17 +4087,15 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", "wasm-bindgen", ] @@ -4040,14 +4122,14 @@ checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "gloo-timers" @@ -4079,11 +4161,11 @@ dependencies = [ "io-uring", "itoa", "libc", - "lru", + "lru 0.16.4", "prost", "rand 0.9.5", "serde", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tokio-stream", "tonic", @@ -4095,9 +4177,9 @@ dependencies = [ [[package]] name = "group" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", "rand_core 0.6.4", @@ -4106,9 +4188,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.16" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" dependencies = [ "atomic-waker", "bytes", @@ -4116,7 +4198,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.5.0", - "indexmap 2.14.0", + "indexmap 2.14.2", "slab", "tokio", "tokio-util", @@ -4130,7 +4212,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "bytemuck", - "cfg-if 1.0.4", + "cfg-if", "crunchy", "num-traits", "rand 0.9.5", @@ -4227,9 +4309,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -4254,7 +4336,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "ureq", "windows-sys 0.60.2", @@ -4262,9 +4344,9 @@ dependencies = [ [[package]] name = "hf-xet" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c237ef4fb0ce1962a5117f8bd8c74454b41629826a9df17d14a1840ca18f0754" +checksum = "f24754aef4f6c3d57bc4a0dc2648ca74487050eda01c361b3eefa11feeafd662" dependencies = [ "anyhow", "async-trait", @@ -4273,7 +4355,7 @@ dependencies = [ "more-asserts", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tokio-util", "tokio_with_wasm", @@ -4318,7 +4400,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "libc", "windows-link", ] @@ -4367,9 +4449,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -4392,24 +4474,24 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.9.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", @@ -4429,9 +4511,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http 1.5.0", "hyper", @@ -4441,7 +4523,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.10", + "webpki-roots 1.0.11", ] [[package]] @@ -4458,17 +4540,34 @@ dependencies = [ ] [[package]] -name = "hyper-util" -version = "0.1.20" +name = "hyper-tls" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ - "base64 0.22.1", + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[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 1.5.0", "http-body 1.1.0", + "httparse", "hyper", "ipnet", "libc", @@ -4560,9 +4659,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", @@ -4572,26 +4671,11 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_locale" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_locale_data", - "icu_provider", - "potential_utf", - "tinystr", - "zerovec", -] - [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", @@ -4602,16 +4686,30 @@ dependencies = [ ] [[package]] -name = "icu_locale_data" -version = "2.2.0" +name = "icu_locale_fallback" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" +checksum = "406e9ae5b95d8aa37db6293405931b766a6172d1848daee22d70eb44aa78a43c" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3632ebbd0c5c3637ba1a40bdf8288f2cb9d2268e73441a69d5fc73d7cfbaa87c" [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -4623,16 +4721,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -4643,15 +4742,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", @@ -4666,30 +4765,26 @@ dependencies = [ [[package]] name = "icu_segmenter" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c0794db0b1a86193ac9c48768d0e6c52c54448e0870ad87907d456ee0dac964" +checksum = "61b195dc605f81aa181bec9b3bd14e26cc71b903e4644f6af578ca9772c7a710" dependencies = [ "icu_collections", - "icu_locale", + "icu_locale_fallback", "icu_provider", "icu_segmenter_data", "potential_utf", + "smallvec", "utf8_iter", + "zerotrie", "zerovec", ] [[package]] name = "icu_segmenter_data" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a2c462a4d927d512f5f882a033ddd62f33a05bb9f230d98f736ac3dc85938f" - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +checksum = "913a7d294ad056474749152107309fdee3ec17d2482d1ab70fdc61a81c64b9a3" [[package]] name = "ident_case" @@ -4731,9 +4826,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -4750,7 +4845,7 @@ dependencies = [ "console", "number_prefix", "portable-atomic", - "unicode-width", + "unicode-width 0.2.2", "web-time", ] @@ -4761,7 +4856,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "232929e1d75fe899576a3d5c7416ad0d88dbfbb3c3d6aa00873a7408a50ddb88" dependencies = [ "ahash", - "indexmap 2.14.0", + "indexmap 2.14.2", "is-terminal", "itoa", "log", @@ -4788,25 +4883,25 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", ] [[package]] name = "io-uring" -version = "0.7.12" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d09b98f7eace8982db770e4408e7470b028ce513ac28fecdc6bf4c30fe92b62" +checksum = "ed3bd0ecfbb87805f538bb7b32e5239ca0763890c623e349860ecba69469f2bb" dependencies = [ - "bitflags 2.11.1", - "cfg-if 1.0.4", + "bitflags 2.13.2", + "cfg-if", "libc", ] [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "is-terminal" @@ -4816,7 +4911,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4884,32 +4979,31 @@ checksum = "9028f49264629065d057f340a86acb84867925865f73bbf8d47b4d149a7e88b8" [[package]] name = "jieba-macros" -version = "0.10.1" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46adade69b634535a8f495cf87710ed893cff53e1dbc9dd750c2ab81c5defb82" +checksum = "455f837e9d0255b68a712200db247c68fdad4941b72471b76bfa61c3b0c1f79f" dependencies = [ "phf_codegen 0.13.1", ] [[package]] name = "jieba-rs" -version = "0.10.1" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11b53580aaa8ec8b713da271da434f8947409242c537a9ab3f7b76bdbb19e8a9" +checksum = "b6a8bbb0f77ee810f0689a30b7cec56b875751ef4ec2e74fd995613dc52b3ae1" dependencies = [ "bytecount", "cedarwood", "jieba-macros", "phf 0.13.1", - "regex", "rustc-hash", ] [[package]] name = "jiff" -version = "0.2.35" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" dependencies = [ "defmt", "jiff-core", @@ -4926,30 +5020,31 @@ dependencies = [ [[package]] name = "jiff-core" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" dependencies = [ "defmt", + "log", ] [[package]] name = "jiff-static" -version = "0.2.35" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" dependencies = [ "jiff-core", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "jiff-tzdb" -version = "0.1.6" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" [[package]] name = "jiff-tzdb-platform" @@ -4966,13 +5061,13 @@ version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "combine", "jni-macros", "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.21", "walkdir", "windows-link", ] @@ -4987,7 +5082,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5006,28 +5101,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -5124,7 +5218,7 @@ dependencies = [ "semver", "serde", "serde_json", - "snafu 0.9.0", + "snafu 0.9.2", "tokio", "tokio-stream", "tokio-util", @@ -5218,7 +5312,7 @@ dependencies = [ "rand 0.9.5", "roaring", "serde_json", - "snafu 0.9.0", + "snafu 0.9.2", "tempfile", "tokio", "tokio-util", @@ -5311,7 +5405,7 @@ source = "git+https://github.com/lance-format/lance.git?tag=v15.0.0-beta.3#2e22b dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5552,7 +5646,7 @@ dependencies = [ "jiff", "nom 8.0.0", "num-traits", - "ordered-float 5.3.0", + "ordered-float 5.5.1", "serde", "serde_json", "zmij", @@ -5585,7 +5679,7 @@ dependencies = [ "lance-namespace-reqwest-client", "serde", "serde_json", - "snafu 0.9.0", + "snafu 0.9.2", ] [[package]] @@ -5692,7 +5786,7 @@ dependencies = [ "semver", "serde", "serde_json", - "snafu 0.9.0", + "snafu 0.9.2", "tokio", "tracing", "url", @@ -5838,6 +5932,49 @@ dependencies = [ "tokio", ] +[[package]] +name = "lancedb-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-flight", + "arrow-json", + "arrow-schema", + "async-trait", + "axum 0.8.9", + "base64 0.22.1", + "clap", + "csv", + "directories", + "futures", + "half", + "http 1.5.0", + "lancedb", + "prost", + "rcgen", + "reqwest 0.12.28", + "rustls", + "rustyline", + "serde", + "serde_ignored", + "serde_json", + "sha2 0.10.9", + "sqlparser 0.62.0", + "tempfile", + "termsize", + "tokio", + "tokio-stream", + "toml", + "tonic", + "tracing", + "unicode-width 0.2.2", + "url", + "which", +] + [[package]] name = "lancedb-nodejs" version = "0.41.0-beta.17" @@ -5896,19 +6033,13 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" dependencies = [ "spin 0.9.9", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "lexical-core" version = "1.0.6" @@ -5978,7 +6109,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "windows-link", ] @@ -5990,18 +6121,18 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ "libc", ] [[package]] name = "libsais-rs" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40fe164dbd47ea0c20e78a121c980ef673326905f1d4fba55e3645a20ef6717f" +checksum = "00777f770949ecbe5524eb6630699a501c9c27e4d18493705a7dad49c9bdfbd1" dependencies = [ "rayon", ] @@ -6048,7 +6179,7 @@ dependencies = [ "encoding_rs_io", "glob", "log", - "memmap2 0.9.10", + "memmap2 0.9.12", "num_cpus", "once_cell", "regex", @@ -6057,20 +6188,26 @@ dependencies = [ "serde_json", "strum", "strum_macros 0.28.0", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] name = "link-section" -version = "0.19.2" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee1a0d6e252afe82e7bc2db42fba60e02ddf3b1accaf8cb21d96e34ba61f3d4" +checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9" [[package]] name = "linktime-proc-macro" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "348d0075b1fc163b26d72a7f75fc5141daf2fd1bdf128d873cbaf6785d495bdf" +checksum = "7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" @@ -6080,15 +6217,15 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" [[package]] name = "lock_api" @@ -6111,7 +6248,7 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "generator", "scoped-tls", "tracing", @@ -6128,10 +6265,19 @@ dependencies = [ ] [[package]] -name = "lru-slab" -version = "0.1.2" +name = "lru" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "lz4" @@ -6174,19 +6320,19 @@ dependencies = [ [[package]] name = "macro_rules_attribute" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" +checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c" dependencies = [ "macro_rules_attribute-proc_macro", - "paste", + "pastey", ] [[package]] name = "macro_rules_attribute-proc_macro" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" +checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c" [[package]] name = "matchers" @@ -6211,9 +6357,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "matrixmultiply" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" dependencies = [ "autocfg", "num_cpus", @@ -6228,25 +6374,15 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "digest 0.11.3", ] -[[package]] -name = "mea" -version = "0.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c709842c4ce65cb91e2666ad5319dfc1efc3af0d34f02075eddca9000d9f8afb" -dependencies = [ - "hashbrown 0.17.1", - "slab", -] - [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" @@ -6259,9 +6395,9 @@ dependencies = [ [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" dependencies = [ "libc", "stable_deref_trait", @@ -6296,7 +6432,7 @@ dependencies = [ "crossbeam-epoch", "crossbeam-utils", "hashbrown 0.15.5", - "indexmap 2.14.0", + "indexmap 2.14.2", "metrics", "ordered-float 4.6.0", "quanta", @@ -6333,6 +6469,15 @@ name = "miniz_oxide" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" dependencies = [ "adler2", "simd-adler32", @@ -6340,9 +6485,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -6351,9 +6496,9 @@ dependencies = [ [[package]] name = "mixtrics" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c46b5adfb7a3ae4996d327a5bdc90e78fec025806dd312bdbe6f07a755e0ec9" +checksum = "1dd94ebec15e61a36cd63b1e8816c83434e42926962cd21ee6e4db898d4d4f05" dependencies = [ "itertools 0.15.0", "parking_lot", @@ -6398,7 +6543,7 @@ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6435,6 +6580,12 @@ dependencies = [ "target-features", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "munge" version = "0.4.7" @@ -6452,7 +6603,7 @@ checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6476,7 +6627,7 @@ version = "3.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a746ad50efbfb632ade1de4025519a477decbbd798cea1a6ac41d7c44f80083" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "chrono", "ctor", "futures", @@ -6507,7 +6658,7 @@ dependencies = [ "napi-derive-backend", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.9", ] [[package]] @@ -6520,7 +6671,7 @@ dependencies = [ "proc-macro2", "quote", "semver", - "syn 3.0.3", + "syn 3.0.9", ] [[package]] @@ -6532,6 +6683,23 @@ dependencies = [ "libloading", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndarray" version = "0.16.1" @@ -6569,7 +6737,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" dependencies = [ "bitflags 1.3.2", - "cfg-if 1.0.4", + "cfg-if", + "libc", +] + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases 0.1.1", "libc", ] @@ -6579,9 +6759,9 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.1", - "cfg-if 1.0.4", - "cfg_aliases", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases 0.2.2", "libc", ] @@ -6634,14 +6814,14 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -6658,7 +6838,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.6", + "rand 0.8.8", "smallvec", "zeroize", ] @@ -6675,9 +6855,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-format" @@ -6691,20 +6871,19 @@ dependencies = [ [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -6759,7 +6938,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6778,7 +6957,7 @@ dependencies = [ "chrono", "getrandom 0.2.17", "http 1.5.0", - "rand 0.8.6", + "rand 0.8.8", "serde", "serde_json", "serde_path_to_error", @@ -6789,9 +6968,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -6802,7 +6981,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "objc2", "objc2-foundation", ] @@ -6813,7 +6992,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "dispatch2", "objc2", ] @@ -6830,7 +7009,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", ] @@ -6845,6 +7024,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-open-directory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + [[package]] name = "objc2-system-configuration" version = "0.3.2" @@ -6863,6 +7053,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + [[package]] name = "object_store" version = "0.13.2" @@ -6880,7 +7079,7 @@ dependencies = [ "itertools 0.14.0", "parking_lot", "percent-encoding", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tracing", "url", @@ -6915,13 +7114,13 @@ dependencies = [ "parking_lot", "percent-encoding", "quick-xml 0.41.0", - "rand 0.10.1", + "rand 0.10.3", "reqwest 0.13.5", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tracing", "url", @@ -6933,9 +7132,9 @@ dependencies = [ [[package]] name = "object_store_opendal" -version = "0.60.2" +version = "0.60.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c479bcc317f0ed98972b7184e0f9cc1bea83a07f60cdade96d7207a48ebfb779" +checksum = "0bd9da6291f93eb19b7987dac17617de0131760a6ba97a4cd8d6b1a07fa89783" dependencies = [ "async-trait", "asyncband", @@ -6972,7 +7171,7 @@ version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "libc", "once_cell", "onig_sys", @@ -6996,9 +7195,9 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "opendal" -version = "0.59.2" +version = "0.59.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fe43e16d96bed57937eb7c4a28559bf4c5fc4a3951656505d1fdd4f856349b4" +checksum = "46081824c74bac9f68f0bc4408077f77b0e315c006514719c97af1c2227ac2d2" dependencies = [ "ctor", "opendal-core", @@ -7019,9 +7218,9 @@ dependencies = [ [[package]] name = "opendal-core" -version = "0.59.2" +version = "0.59.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de4566a412776e3f65d53dd9fceced5b432a0a9c9a1e8d50ccf58823795e5c5b" +checksum = "33d5a635dfea36a7a0c3bbb2ca34c97af17f98dd69b0cdf31a1d436a346e0a12" dependencies = [ "anyhow", "asyncband", @@ -7033,7 +7232,7 @@ dependencies = [ "log", "md-5", "percent-encoding", - "quick-xml 0.41.0", + "quick-xml 0.42.0", "reqsign-core", "serde", "serde_json", @@ -7045,9 +7244,9 @@ dependencies = [ [[package]] name = "opendal-http-transport-reqwest" -version = "0.59.2" +version = "0.59.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f772ce6c137ab43647726116d505a649a88d41928fcd813438eb30b02312bcbb" +checksum = "fc025e163c43993e30cf1829a30b791934e5b0f7cd4d7e03acc902a9efb7e8f5" dependencies = [ "bytes", "futures", @@ -7059,9 +7258,9 @@ dependencies = [ [[package]] name = "opendal-layer-concurrent-limit" -version = "0.59.2" +version = "0.59.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3734c648617e5ad724d49175dde8323550624f9614f50e9e81b7aafae30b1734" +checksum = "06b44bdf694cccb7d90515b3eac6623975a2813abf4f5c438b4949a329d6a45b" dependencies = [ "asyncband", "futures", @@ -7071,9 +7270,9 @@ dependencies = [ [[package]] name = "opendal-layer-logging" -version = "0.59.2" +version = "0.59.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a8daab5a91eac76f94516e7c71e677195c9221840ce8f5e71815e3fc8207742" +checksum = "520d3de5d246c82a246c431ef0f52b8d2f091c7c49a3fd7a6f0ff519b1e7725f" dependencies = [ "log", "opendal-core", @@ -7081,9 +7280,9 @@ dependencies = [ [[package]] name = "opendal-layer-retry" -version = "0.59.2" +version = "0.59.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb8e22cb1f86ef9af771a8947d3ccf380c8f5b76236f628314dd41c9ba73331a" +checksum = "2a02c59479b047878f4a0f269f23c847f24d67d1ff19f2b20fdd5a3bceefd451" dependencies = [ "backon", "log", @@ -7092,9 +7291,9 @@ dependencies = [ [[package]] name = "opendal-layer-timeout" -version = "0.59.2" +version = "0.59.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08ac6c77fe0d1b18e0064a02c19b98dbebcab78bf5e7753ad1f3abbc18850ff4" +checksum = "afffa0cf8a6690b3b44cc510816ba675638eb236699a7d449e44ac3344bafd36" dependencies = [ "opendal-core", "tokio", @@ -7102,9 +7301,9 @@ dependencies = [ [[package]] name = "opendal-service-azblob" -version = "0.59.2" +version = "0.59.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6cf31b02e7b44191d97c07254eea109b5da9c17210a29e478c8d0dc26dffb1f" +checksum = "b674507cd2f764f6b40d5f88d284bb293c519d76e8aa0103987f970da412eeac" dependencies = [ "base64 0.23.1", "bytes", @@ -7112,20 +7311,20 @@ dependencies = [ "log", "opendal-core", "opendal-service-azure-common", - "quick-xml 0.41.0", + "quick-xml 0.42.0", "reqsign-azure-storage", "reqsign-core", "reqsign-file-read-tokio", "serde", - "sha2 0.11.0", + "sha2 0.11.1", "uuid", ] [[package]] name = "opendal-service-azdls" -version = "0.59.2" +version = "0.59.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "354f4a8d26f0fb7639f5c4ad3a924d7c75625830f69550b8c807c080ea0df8bc" +checksum = "afeb287cf2b03ab8d99d77a8e9687e908eb7898e2da1af6ccd00590c7095cae6" dependencies = [ "asyncband", "base64 0.23.1", @@ -7134,7 +7333,7 @@ dependencies = [ "log", "opendal-core", "opendal-service-azure-common", - "quick-xml 0.41.0", + "quick-xml 0.42.0", "reqsign-azure-storage", "reqsign-core", "reqsign-file-read-tokio", @@ -7144,9 +7343,9 @@ dependencies = [ [[package]] name = "opendal-service-azure-common" -version = "0.59.2" +version = "0.59.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8107324c3f3cb9970fe20526c2c341b7c5769acbd4699bab73bd525f5fd63d62" +checksum = "bdf9c9d0bfd1fa3638eec485c7f3448252cbb5a81774a2aa7abadc3e4f1ea43e" dependencies = [ "http 1.5.0", "opendal-core", @@ -7154,15 +7353,15 @@ dependencies = [ [[package]] name = "opendal-service-cos" -version = "0.59.2" +version = "0.59.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "feb737e6609b4fd42e4773b7b2d8d05031b63cd0df46c7ba68ddd64bae1dd3f3" +checksum = "423127c9e81b1ef4253883743ea6696c06dfad0fa6dcb1247887039919e53772" dependencies = [ "bytes", "http 1.5.0", "log", "opendal-core", - "quick-xml 0.41.0", + "quick-xml 0.42.0", "reqsign-core", "reqsign-file-read-tokio", "reqsign-tencent-cos", @@ -7171,9 +7370,9 @@ dependencies = [ [[package]] name = "opendal-service-gcs" -version = "0.59.2" +version = "0.59.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d80eca6f9227bdf65d43e5b8d5fe75dca8cfab1da07571442a0420400847bea" +checksum = "f9e5197fce0cd5929db9129a7e71e503841aa20bea4f1dcc54ab097a5f3652e9" dependencies = [ "async-trait", "bytes", @@ -7181,7 +7380,7 @@ dependencies = [ "log", "opendal-core", "percent-encoding", - "quick-xml 0.41.0", + "quick-xml 0.42.0", "reqsign-core", "reqsign-file-read-tokio", "reqsign-google", @@ -7193,9 +7392,9 @@ dependencies = [ [[package]] name = "opendal-service-goosefs" -version = "0.59.2" +version = "0.59.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2134aca0ef64dbff844cb1e608d28833f2a1b34b884e7d6b307416e966694e9f" +checksum = "6ca356f23424fa0129367c3eca426eb3ec0c3cfaf628bb2972fa597f8bd10805" dependencies = [ "asyncband", "bytes", @@ -7207,9 +7406,9 @@ dependencies = [ [[package]] name = "opendal-service-hf" -version = "0.59.2" +version = "0.59.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a0cba627efaee637746804b74bc2c2b2b207fe214307e1e2ba31b49aad708a3" +checksum = "32c1a4076d933180b66b57a0f305e48f2de1a72b50648114cb9943a8bc9e28b8" dependencies = [ "asyncband", "bytes", @@ -7224,15 +7423,15 @@ dependencies = [ [[package]] name = "opendal-service-oss" -version = "0.59.2" +version = "0.59.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ebfa355b1d4edb0e5b2a5b86d8de9f811fae1d7baca6d65246b230b2afc0bd" +checksum = "30e973fcebfa413432c024c41d65fad8034b22c065250c587e0c3a70f1a27eb6" dependencies = [ "bytes", "http 1.5.0", "log", "opendal-core", - "quick-xml 0.41.0", + "quick-xml 0.42.0", "reqsign-aliyun-oss", "reqsign-core", "reqsign-file-read-tokio", @@ -7241,9 +7440,9 @@ dependencies = [ [[package]] name = "opendal-service-s3" -version = "0.59.2" +version = "0.59.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2942f2d8d3d4953c0e8d07cd1244879628d084948e48ce6b6d808c5938f7858d" +checksum = "0932321e27c712a30ef72db56b12f31e41e9f0d2d3841d63a91bc8075cd8f0ef" dependencies = [ "base64 0.23.1", "bytes", @@ -7252,7 +7451,7 @@ dependencies = [ "log", "md-5", "opendal-core", - "quick-xml 0.41.0", + "quick-xml 0.42.0", "reqsign-aws-v4", "reqsign-core", "reqsign-file-read-tokio", @@ -7261,10 +7460,47 @@ dependencies = [ ] [[package]] -name = "openssl-probe" -version = "0.2.1" +name = "openssl" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.2", + "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-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] [[package]] name = "option-ext" @@ -7283,9 +7519,9 @@ dependencies = [ [[package]] name = "ordered-float" -version = "5.3.0" +version = "5.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab" dependencies = [ "num-traits", ] @@ -7317,12 +7553,13 @@ checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" [[package]] name = "p256" -version = "0.11.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ "ecdsa", "elliptic-curve", + "primeorder", "sha2 0.10.9", ] @@ -7358,7 +7595,7 @@ version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "libc", "redox_syscall", "smallvec", @@ -7386,6 +7623,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "path_abs" version = "0.5.1" @@ -7418,6 +7661,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -7447,7 +7700,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset", "hashbrown 0.15.5", - "indexmap 2.14.0", + "indexmap 2.14.2", "serde", ] @@ -7506,7 +7759,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.6", + "rand 0.8.8", ] [[package]] @@ -7563,7 +7816,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -7574,9 +7827,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113" [[package]] name = "pkcs1" @@ -7584,9 +7837,9 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der 0.7.10", - "pkcs8 0.10.2", - "spki 0.7.3", + "der", + "pkcs8", + "spki", ] [[package]] @@ -7597,21 +7850,11 @@ checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" dependencies = [ "aes", "cbc", - "der 0.7.10", + "der", "pbkdf2", "scrypt", "sha2 0.10.9", - "spki 0.7.3", -] - -[[package]] -name = "pkcs8" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" -dependencies = [ - "der 0.6.1", - "spki 0.6.0", + "spki", ] [[package]] @@ -7620,17 +7863,17 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der 0.7.10", + "der", "pkcs5", "rand_core 0.6.4", - "spki 0.7.3", + "spki", ] [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "planus" @@ -7757,14 +8000,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "465f70d3e96b6d0b1a43c358ba451286b8c8bd56696feff020d65702aa33e35c" dependencies = [ "ahash", - "bitflags 2.11.1", + "bitflags 2.13.2", "bytemuck", "chrono", "chrono-tz 0.8.6", "comfy-table", "either", "hashbrown 0.14.5", - "indexmap 2.14.0", + "indexmap 2.14.2", "num-traits", "once_cell", "polars-arrow", @@ -7772,7 +8015,7 @@ dependencies = [ "polars-error", "polars-row", "polars-utils", - "rand 0.8.6", + "rand 0.8.8", "rand_distr 0.4.3", "rayon", "regex", @@ -7831,7 +8074,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89b2632b1af668e2058d5f8f916d8fbde3cac63d03ae29a705f598e41dcfeb7f" dependencies = [ "ahash", - "bitflags 2.11.1", + "bitflags 2.13.2", "glob", "once_cell", "polars-arrow", @@ -7862,7 +8105,7 @@ dependencies = [ "either", "hashbrown 0.14.5", "hex", - "indexmap 2.14.0", + "indexmap 2.14.2", "memchr", "num-traits", "polars-arrow", @@ -7971,7 +8214,7 @@ dependencies = [ "polars-error", "polars-lazy", "polars-plan", - "rand 0.8.6", + "rand 0.8.8", "serde", "serde_json", "sqlparser 0.39.0", @@ -8006,7 +8249,7 @@ dependencies = [ "ahash", "bytemuck", "hashbrown 0.14.5", - "indexmap 2.14.0", + "indexmap 2.14.2", "num-traits", "once_cell", "polars-error", @@ -8020,24 +8263,24 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" dependencies = [ "portable-atomic", ] [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "serde_core", "writeable", @@ -8046,9 +8289,9 @@ dependencies = [ [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "pprof" @@ -8058,7 +8301,7 @@ checksum = "afad4d4df7b31280028245f152d5a575083e2abb822d05736f5e47653e77689f" dependencies = [ "aligned-vec", "backtrace", - "cfg-if 1.0.4", + "cfg-if", "findshlibs", "inferno", "libc", @@ -8080,7 +8323,7 @@ checksum = "38a01da47675efa7673b032bf8efd8214f1917d89685e07e395ab125ea42b187" dependencies = [ "aligned-vec", "backtrace", - "cfg-if 1.0.4", + "cfg-if", "findshlibs", "inferno", "libc", @@ -8091,7 +8334,7 @@ dependencies = [ "spin 0.10.1", "symbolic-demangle", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -8110,7 +8353,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn 2.0.119", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", ] [[package]] @@ -8119,14 +8371,14 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.17+spec-1.1.0", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -8143,9 +8395,9 @@ dependencies = [ [[package]] name = "prost-build" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck 0.5.0", "itertools 0.14.0", @@ -8156,7 +8408,7 @@ dependencies = [ "prost", "prost-types", "regex", - "syn 2.0.117", + "syn 2.0.119", "tempfile", ] @@ -8170,23 +8422,23 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "prost-types" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ "prost", ] [[package]] name = "psm" -version = "0.1.31" +version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" dependencies = [ "ar_archive_writer", "cc", @@ -8194,32 +8446,32 @@ dependencies = [ [[package]] name = "ptr_meta" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" +checksum = "743da816b98c921cdbe8628ef7381b76f25ecf4da599fc80aca90eae7ef70cc0" dependencies = [ "ptr_meta_derive", ] [[package]] name = "ptr_meta_derive" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] name = "pulp" -version = "0.22.2" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e205bb30d5b916c55e584c22201771bcf2bad9aabd5d4127f38387140c38632" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" dependencies = [ "bytemuck", - "cfg-if 1.0.4", + "cfg-if", "libm", "num-complex", "paste", @@ -8231,9 +8483,9 @@ dependencies = [ [[package]] name = "pulp-wasm-simd-flag" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40e24eee682d89fb193496edf918a7f407d30175b2e785fe057e4392dfd182e0" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" [[package]] name = "pyo3" @@ -8274,7 +8526,7 @@ checksum = "c23399970eea9c31d0ac84cee4a9d8dd05f89b1da2f4dd5bb44b32a3f66db4f8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -8305,7 +8557,7 @@ dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -8318,7 +8570,7 @@ dependencies = [ "proc-macro2", "pyo3-build-config", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -8364,6 +8616,16 @@ dependencies = [ "serde", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quick_cache" version = "0.6.24" @@ -8378,19 +8640,19 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.2", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -8404,16 +8666,16 @@ checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.4.2", + "getrandom 0.4.3", "lru-slab", - "rand 0.10.1", + "rand 0.10.3", "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -8421,23 +8683,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.2", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -8472,18 +8734,18 @@ dependencies = [ [[package]] name = "rancor" -version = "0.1.1" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a063ea72381527c2a0561da9c80000ef822bdd7c3241b1cc1b12100e3df081ee" +checksum = "9b534442d0fcdb55d66f373d9cac6d33b6293a2335bc2136dbd06ce0e87d2572" dependencies = [ "ptr_meta", ] [[package]] name = "rand" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -8502,12 +8764,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", - "getrandom 0.4.2", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -8562,7 +8824,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" dependencies = [ "num-traits", - "rand 0.8.6", + "rand 0.8.8", ] [[package]] @@ -8603,15 +8865,15 @@ dependencies = [ "brotli", "once_cell", "paste", - "rand 0.8.6", + "rand 0.8.8", "unicase", ] [[package]] name = "rangemap" -version = "1.7.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" [[package]] name = "rapidhash" @@ -8628,7 +8890,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", ] [[package]] @@ -8668,6 +8930,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48406db8ac1f3cbc7dcdb56ec355343817958a356ff430259bb07baf7607e1e1" +dependencies = [ + "pem 3.0.6", + "ring", + "time", + "yasna", +] + [[package]] name = "reborrow" version = "0.5.5" @@ -8691,7 +8965,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -8709,38 +8983,48 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", ] [[package]] name = "redox_users" -version = "0.5.2" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", ] [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -8757,9 +9041,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -8786,18 +9070,18 @@ checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" [[package]] name = "rend" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" +checksum = "663ba70707f96e871406fe10d68128412e619b06d1d47cb91c3a4c6501176240" dependencies = [ "bytecheck", ] [[package]] name = "reqsign-aliyun-oss" -version = "3.1.3" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e6d659fcdbca6fe2d7ef109c2e28499b7be80501f1bb86c10caf5ec8ac1219" +checksum = "28cf3d65d37ac910073d921c1cc58c3e4e6c963795c6062eee196e6d19054dde" dependencies = [ "anyhow", "form_urlencoded", @@ -8812,9 +9096,9 @@ dependencies = [ [[package]] name = "reqsign-aws-core" -version = "3.1.1" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bac4749b7dfa7bfaccd01eb03e9dc795ed37e3f20d6f0f38e2c67ee85ad6bc86" +checksum = "d38c902d8ec745bd9eadd1abbcbe227f4c1fc2926b2ec8929f89234af3114500" dependencies = [ "bytes", "form_urlencoded", @@ -8822,7 +9106,7 @@ dependencies = [ "http 1.5.0", "log", "percent-encoding", - "quick-xml 0.41.0", + "quick-xml 0.42.0", "reqsign-core", "rust-ini", "serde", @@ -8833,14 +9117,14 @@ dependencies = [ [[package]] name = "reqsign-aws-v4" -version = "3.3.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff250f0fd0b913fbd565e405acc553da0f13bde30bfb5403178c9d0313cdc15f" +checksum = "906bfd028f96bfe73fdb892ce26f3b3f99b4f30feb9bd024be58bd6657f68b2b" dependencies = [ "bytes", "http 1.5.0", "log", - "quick-xml 0.41.0", + "quick-xml 0.42.0", "reqsign-aws-core", "reqsign-core", "serde", @@ -8848,17 +9132,18 @@ dependencies = [ [[package]] name = "reqsign-azure-storage" -version = "3.1.2" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2824e7da3c2cc42ac3406c674eb57c89127fdcd97f3a73c608cfc680505ea134" +checksum = "7260e6c1d89422ba62d624a32c491b561386fb364ac4bf0959c740e34b251a2e" dependencies = [ "anyhow", + "asyncband", "base64 0.23.1", "bytes", "form_urlencoded", "http 1.5.0", "log", - "pem", + "pem 4.0.0", "percent-encoding", "reqsign-core", "rsa", @@ -8869,34 +9154,33 @@ dependencies = [ [[package]] name = "reqsign-core" -version = "3.3.1" +version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff052daffb0599681c50f85c59e7236438976efe991ab864edd9f3b235501a0f" +checksum = "85fed85c436b6b920e4b36310f699a77771a91e38ae483df48447bd7d162671c" dependencies = [ "anyhow", + "asyncband", "base64 0.23.1", "bytes", - "futures", "hex", "hmac 0.13.0", "http 1.5.0", "jiff", "log", - "mea", "percent-encoding", "rsa", "serde", "serde_json", "sha1 0.11.0", - "sha2 0.11.0", + "sha2 0.11.1", "windows-sys 0.61.2", ] [[package]] name = "reqsign-file-read-tokio" -version = "3.0.6" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3235df90a6bca681aa47dd86f2393d122a6d77042aa8a7c81e218cd45c5bfc0" +checksum = "586a8e198057773f9616901fd723a4bc186812b9765bc0f20368661d6169d906" dependencies = [ "anyhow", "reqsign-core", @@ -8905,9 +9189,9 @@ dependencies = [ [[package]] name = "reqsign-google" -version = "3.1.1" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "272a5813571885c1455ffe106f0c768577e6ce69313446d4476e8d41dcf6ac5a" +checksum = "719e55931cc81b8dcd69f95f0a2def55617cb76cb3aa2f8c439e10682e46fa10" dependencies = [ "bytes", "form_urlencoded", @@ -8924,9 +9208,9 @@ dependencies = [ [[package]] name = "reqsign-tencent-cos" -version = "3.0.4" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764629c90f7c3566a6d4e4641ebab9acd604ce02e16eda4d37c7d7e79e16ed90" +checksum = "9a98625e0f3eab846348b7524f1f5b5c97ffc348002845463520be371b7ab7cd" dependencies = [ "anyhow", "http 1.5.0", @@ -8954,11 +9238,13 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", "mime", "mime_guess", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -8970,17 +9256,18 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", - "tower-http 0.6.10", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "wasm-streams 0.4.2", "web-sys", - "webpki-roots 1.0.10", + "webpki-roots 1.0.11", ] [[package]] @@ -9018,7 +9305,7 @@ dependencies = [ "tokio-rustls", "tokio-util", "tower", - "tower-http 0.6.10", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", @@ -9045,27 +9332,26 @@ dependencies = [ [[package]] name = "reqwest-middleware" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "199dda04a536b532d0cc04d7979e39b1c763ea749bf91507017069c00b96056f" +checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" dependencies = [ "anyhow", "async-trait", "http 1.5.0", "reqwest 0.13.5", - "thiserror 2.0.18", + "thiserror 2.0.21", "tower-service", ] [[package]] name = "rfc6979" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "crypto-bigint 0.4.9", "hmac 0.12.1", - "zeroize", + "subtle", ] [[package]] @@ -9084,7 +9370,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", - "cfg-if 1.0.4", + "cfg-if", "getrandom 0.2.17", "libc", "untrusted 0.9.0", @@ -9093,14 +9379,14 @@ dependencies = [ [[package]] name = "rkyv" -version = "0.8.17" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "815cc8a37159a463064825246cadb07961e25cd9885908606f6d08a98d8f8874" +checksum = "d9776093b7ca170454ab1406954f7b7d97a57c51dc6c0642957fb2ef25c2d399" dependencies = [ "bytecheck", "bytes", "hashbrown 0.17.1", - "indexmap 2.14.0", + "indexmap 2.14.2", "munge", "ptr_meta", "rancor", @@ -9112,13 +9398,13 @@ dependencies = [ [[package]] name = "rkyv_derive" -version = "0.8.17" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ed1a78a1b19d184b0daa629dd9a024573173ec7d485b287cb369fb3607cc1c" +checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -9149,11 +9435,11 @@ dependencies = [ "num-integer", "num-traits", "pkcs1", - "pkcs8 0.10.2", + "pkcs8", "rand_core 0.6.4", "sha2 0.10.9", - "signature 2.2.0", - "spki 0.7.3", + "signature", + "spki", "subtle", "zeroize", ] @@ -9187,7 +9473,7 @@ version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "825ea780781b15345a146be27eaefb05085e337e869bff01b4306a4fd4a9ad5a" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "glob", "proc-macro-crate", "proc-macro2", @@ -9195,7 +9481,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.117", + "syn 2.0.119", "unicode-ident", ] @@ -9205,21 +9491,21 @@ version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "ordered-multimap", ] [[package]] name = "rustc-demangle" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -9232,15 +9518,28 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "errno", "libc", - "linux-raw-sys", - "windows-sys 0.52.0", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", ] [[package]] @@ -9261,9 +9560,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -9273,9 +9572,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -9283,9 +9582,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", @@ -9299,14 +9598,14 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "rustls-platform-verifier-android" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" @@ -9322,9 +9621,31 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rustyline" +version = "14.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix 0.28.0", + "radix_trie", + "unicode-segmentation", + "unicode-width 0.1.14", + "utf8parse", + "windows-sys 0.52.0", +] [[package]] name = "ryu" @@ -9378,9 +9699,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" dependencies = [ "windows-sys 0.61.2", ] @@ -9399,9 +9720,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" dependencies = [ "dyn-clone", "ref-cast", @@ -9434,14 +9755,14 @@ dependencies = [ [[package]] name = "sec1" -version = "0.3.0" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", - "der 0.6.1", + "der", "generic-array", - "pkcs8 0.9.0", + "pkcs8", "subtle", "zeroize", ] @@ -9462,7 +9783,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -9518,7 +9839,17 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.9", +] + +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", ] [[package]] @@ -9528,7 +9859,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "foldhash 0.2.0", - "indexmap 2.14.0", + "indexmap 2.14.2", "itoa", "memchr", "serde", @@ -9558,13 +9889,22 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", ] [[package]] @@ -9590,10 +9930,10 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.14.0", + "indexmap 2.14.2", "jiff", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.3", "serde_core", "serde_json", "serde_with_macros", @@ -9609,7 +9949,7 @@ dependencies = [ "darling 0.24.1", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.9", ] [[package]] @@ -9618,7 +9958,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "itoa", "ryu", "serde", @@ -9647,16 +9987,16 @@ checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -9667,8 +10007,8 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ - "cfg-if 1.0.4", - "cpufeatures 0.3.0", + "cfg-if", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -9684,19 +10024,19 @@ version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cpufeatures 0.2.17", "digest 0.10.7", ] [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ - "cfg-if 1.0.4", - "cpufeatures 0.3.0", + "cfg-if", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -9722,9 +10062,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -9736,16 +10076,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "1.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" -dependencies = [ - "digest 0.10.7", - "rand_core 0.6.4", -] - [[package]] name = "signature" version = "2.2.0" @@ -9771,15 +10101,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -9793,15 +10123,15 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "sketches-ddsketch" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" +checksum = "45f82fa313d625dd72aaa65ec0c9f8c95407add0510c1bef3c18ea7257ec34ce" [[package]] name = "slab" @@ -9811,9 +10141,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" [[package]] name = "smartstring" @@ -9837,11 +10167,11 @@ dependencies = [ [[package]] name = "snafu" -version = "0.9.0" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1d4bced6a69f90b2056c03dcff2c4737f98d6fb9e0853493996e1d253ca29c6" +checksum = "e45cb604038abb7b926b679887b3226d8d0f23874b66623625a0454be425a4b7" dependencies = [ - "snafu-derive 0.9.0", + "snafu-derive 0.9.2", ] [[package]] @@ -9853,29 +10183,29 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "snafu-derive" -version = "0.9.0" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54254b8531cafa275c5e096f62d48c81435d1015405a91198ddb11e967301d40" +checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9916,16 +10246,6 @@ dependencies = [ "lock_api", ] -[[package]] -name = "spki" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" -dependencies = [ - "base64ct", - "der 0.6.1", -] - [[package]] name = "spki" version = "0.7.3" @@ -9933,7 +10253,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der 0.7.10", + "der", ] [[package]] @@ -9975,7 +10295,7 @@ checksum = "a6dd45d8fc1c79299bfbb7190e42ccbbdf6a5f52e4a6ad98d92357ea965bd289" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -9986,15 +10306,15 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stacker" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" dependencies = [ "cc", - "cfg-if 1.0.4", + "cfg-if", "libc", "psm", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10027,9 +10347,9 @@ checksum = "e51f1e89f093f99e7432c491c382b88a6860a5adbe6bf02574bf0a08efff1978" [[package]] name = "stop-words" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d68df56303396bcfb639455b3c166804aeb7994005010aab5e9e8a1277b8871d" +checksum = "c1ed527ae13a020ac2404e32746ecee62d632aafe64f8e0df2bb3545ae96f6d1" dependencies = [ "serde_json", ] @@ -10086,7 +10406,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -10098,7 +10418,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -10114,7 +10434,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "332615d90111d8eeaf86a84dc9bbe9f65d0d8c5cf11b4caccedc37754eb0dcfd" dependencies = [ "debugid", - "memmap2 0.9.10", + "memmap2 0.9.12", "stable_deref_trait", "uuid", ] @@ -10149,9 +10469,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -10160,9 +10480,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -10180,13 +10500,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -10195,7 +10515,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "byteorder", "enum-as-inner", "libc", @@ -10209,7 +10529,7 @@ version = "0.30.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a5b4ddaee55fb2bea2bf0e5000747e5f5c0de765e5a5ff87f4cd106439f4bb3" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "core-foundation-sys", "libc", "ntapi", @@ -10219,15 +10539,16 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.38.4" +version = "0.39.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" dependencies = [ "libc", "memchr", "ntapi", "objc2-core-foundation", "objc2-io-kit", + "objc2-open-directory", "windows 0.62.2", ] @@ -10237,7 +10558,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -10283,10 +10604,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", - "rustix", - "windows-sys 0.52.0", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "termsize" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f11ff5c25c172608d5b85e2fb43ee9a6d683a7f4ab7f96ae07b3d8b590368fd" +dependencies = [ + "libc", + "winapi", ] [[package]] @@ -10308,7 +10639,7 @@ checksum = "c26ef8b00e4d382e59f6a8ddb3cd790b3a5bb29f21a358a9a69ea2f29f13f27b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -10317,7 +10648,7 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "944ad38adcbb71eaa682c56bceeb079e4ca82b4b3edc2a0fde5cb297b77dac8d" dependencies = [ - "syn 2.0.117", + "syn 2.0.119", "test-log-core", ] @@ -10332,11 +10663,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.21", ] [[package]] @@ -10347,18 +10678,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -10372,21 +10703,20 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -10396,15 +10726,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -10421,9 +10751,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", "serde_core", @@ -10442,18 +10772,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokenizers" @@ -10473,7 +10794,7 @@ dependencies = [ "monostate", "onig", "paste", - "rand 0.8.6", + "rand 0.8.8", "rayon", "rayon-cond", "regex", @@ -10506,31 +10827,41 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", ] [[package]] name = "tokio-retry" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40f644c762e9d396831ae2f8935c954b0d758c4532e924bead0f666d0c1c8640" +checksum = "4a129d95275ebf4c493ec53bf0f8cd95f5ac161bc4f381700809a54f595d4470" dependencies = [ "pin-project-lite", - "rand 0.10.1", + "rand 0.10.3", "tokio", ] [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -10538,9 +10869,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -10550,13 +10881,14 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -10582,39 +10914,80 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d01145a2c788d6aae4cd653afec1e8332534d7d783d01897cefcafe4428de992" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.14.0", - "toml_datetime", + "indexmap 2.14.2", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ - "winnow", + "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 = "tonic" version = "0.14.6" @@ -10665,7 +11038,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.14.0", + "indexmap 2.14.2", "pin-project-lite", "slab", "sync_wrapper", @@ -10682,7 +11055,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "bytes", "http 1.5.0", "http-body 1.1.0", @@ -10695,12 +11068,12 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.10" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "bitflags 2.11.1", + "bitflags 2.13.2", "bytes", "futures-core", "futures-util", @@ -10748,7 +11121,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", "tracing-subscriber", ] @@ -10761,7 +11134,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -10824,24 +11197,24 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "twox-hash" -version = "2.1.2" +version = "2.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" +checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2" dependencies = [ - "rand 0.9.5", + "rand 0.10.3", ] [[package]] name = "typed-path" -version = "0.12.3" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" +checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "typewit" @@ -10851,27 +11224,27 @@ checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" [[package]] name = "unicase" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" [[package]] name = "unicode-blocks" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b12e05d9e06373163a9bb6bb8c263c261b396643a99445fe6b9811fd376581b" +checksum = "328edfd6d0bb91a22e592bed3a3b54c7b1f5c92a517f5a7aca24a2caef10c363" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-normalization" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" dependencies = [ "tinyvec", ] @@ -10896,9 +11269,15 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" @@ -10906,12 +11285,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "unicode_categories" version = "0.1.1" @@ -10998,7 +11371,7 @@ version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "sha1_smol", @@ -11011,6 +11384,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[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" @@ -11035,9 +11414,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -11059,20 +11438,11 @@ dependencies = [ [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -11086,11 +11456,11 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", @@ -11099,19 +11469,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -11119,48 +11490,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.4.2" @@ -11187,23 +11536,11 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -11237,9 +11574,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" dependencies = [ "rustls-pki-types", ] @@ -11250,23 +11587,35 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.10", + "webpki-roots 1.0.11", ] [[package]] name = "webpki-roots" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" dependencies = [ "rustls-pki-types", ] [[package]] -name = "whoami" -version = "2.1.2" +name = "which" +version = "6.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +dependencies = [ + "either", + "home", + "rustix 0.38.44", + "winsafe", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" dependencies = [ "libc", "libredox", @@ -11307,7 +11656,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -11388,7 +11737,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -11399,7 +11748,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -11447,6 +11796,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -11483,6 +11841,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -11525,6 +11898,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -11537,6 +11916,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -11549,6 +11934,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -11573,6 +11964,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -11585,6 +11982,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -11597,6 +12000,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -11609,6 +12018,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -11623,112 +12038,39 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.3" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] [[package]] -name = "wit-bindgen" -version = "0.51.0" +name = "winnow" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ - "wit-bindgen-rust-macro", + "memchr", ] +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.1", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "wkb" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a120b336c7ad17749026d50427c23d838ecb50cd64aaea6254b5030152f890a9" +checksum = "04ab2f2845abe967a213998446345a1a6fc9a8f318cd88f668a8e507861d13bc" dependencies = [ "byteorder", "geo-traits", @@ -11751,9 +12093,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wyz" @@ -11766,20 +12108,21 @@ dependencies = [ [[package]] name = "xet-client" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3b8da8cc70aa2e3c500c0400e012df82c656ab9fca47f9f939fffc5afd89aca" +checksum = "15500e5fc9e17be15224f9e34cb07d41fb2805d9dbbf7bd3507c982fd1eb3efd" dependencies = [ "anyhow", "async-trait", "base64 0.22.1", "bytes", + "chrono", "crc32fast", "futures", "http 1.5.0", "hyper", "more-asserts", - "rand 0.10.1", + "rand 0.10.3", "redb", "reqwest 0.13.5", "reqwest-middleware", @@ -11788,13 +12131,14 @@ dependencies = [ "serde_repr", "statrs", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tokio-retry", "tokio_with_wasm", "tracing", "url", "urlencoding", + "uuid", "web-time", "xet-core-structures", "xet-runtime", @@ -11802,9 +12146,9 @@ dependencies = [ [[package]] name = "xet-core-structures" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73503c223783dccc864abde22115e09d12f190448a0baf58ab2c54bc709e2f99" +checksum = "5080d5803c8bf15fa56ef934050a46b225801bad3ea4cb8bb3c46c5c37976516" dependencies = [ "async-trait", "base64 0.22.1", @@ -11814,17 +12158,17 @@ dependencies = [ "countio", "futures", "futures-util", - "getrandom 0.4.2", + "getrandom 0.4.3", "heapify", "itertools 0.14.0", "lz4_flex", "more-asserts", - "rand 0.10.1", + "rand 0.10.3", "regex", "safe-transmute", "serde", "static_assertions", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tokio-util", "tracing", @@ -11835,9 +12179,9 @@ dependencies = [ [[package]] name = "xet-data" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89052ec5dec2187cad30b86af92cc24fd61c4a57a795f1ff7ff5f38d49184eb" +checksum = "1287a1657fb67de846030f6605a9960dcdad401c467b490657e6e909d2e0d1c0" dependencies = [ "anyhow", "async-trait", @@ -11847,12 +12191,12 @@ dependencies = [ "http 1.5.0", "itertools 0.14.0", "more-asserts", - "rand 0.10.1", + "rand 0.10.3", "serde", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tokio-util", "tokio_with_wasm", @@ -11867,9 +12211,9 @@ dependencies = [ [[package]] name = "xet-runtime" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af5c60d5eed38ab4c576f4421bae835e7bd07631fb381705605529d2015c106b" +checksum = "921953c885ce83e02b21e8d26444b75c78c9e83076699ced08d5c6fc1cdb48ac" dependencies = [ "anyhow", "async-trait", @@ -11887,13 +12231,13 @@ dependencies = [ "more-asserts", "oneshot", "pin-project", - "rand 0.10.1", + "rand 0.10.3", "reqwest 0.13.5", "serde", "serde_json", "shellexpand", - "sysinfo 0.38.4", - "thiserror 2.0.18", + "sysinfo 0.39.6", + "thiserror 2.0.21", "tokio", "tokio-util", "tokio_with_wasm", @@ -11918,10 +12262,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" [[package]] -name = "yoke" -version = "0.8.2" +name = "yasna" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -11930,34 +12283,34 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -11971,27 +12324,27 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -12001,9 +12354,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ "serde", "yoke", @@ -12013,13 +12366,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -12029,16 +12382,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc12baa6db2b15a140161ce53d72209dacea594230798c24774139b54ecaa980" dependencies = [ "crc32fast", - "indexmap 2.14.0", + "indexmap 2.14.2", "memchr", "typed-path", ] [[package]] -name = "zmij" -version = "1.0.21" +name = "zlib-rs" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" @@ -12046,7 +12405,7 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" dependencies = [ - "zstd-safe 7.2.4", + "zstd-safe 7.3.0", ] [[package]] @@ -12060,9 +12419,9 @@ dependencies = [ [[package]] name = "zstd-safe" -version = "7.2.4" +version = "7.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" dependencies = [ "zstd-sys", ] diff --git a/Cargo.toml b/Cargo.toml index 546613ce2..343813b65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "rust/lancedb/cli", "rust/lancedb", "nodejs", "python", @@ -35,6 +36,7 @@ lance-encoding = { "version" = "=15.0.0-beta.3", "tag" = "v15.0.0-beta.3", "git" lance-arrow = { version = "=15.0.0-beta.3", path = "python/vendor/lance-arrow" } lancedb = { path = "rust/lancedb", default-features = false } ahash = "0.8" +anyhow = "1" # Note that this one does not include pyarrow arrow = { version = "58.0.0", optional = false } arrow-array = "58.0.0" @@ -49,6 +51,7 @@ arrow-flight = { version = "58.0.0", features = ["flight-sql-experimental"] } async-trait = "0" # Smithy JSON 0.63 requires the pre-1.7 Document representation; allow the MSRV pin. aws-smithy-types = ">=1.3.6, <1.7" +base64 = "0.22" bytes = "1" datafusion = { version = "54.0.0", default-features = false } datafusion-catalog = "54.0.0" @@ -64,12 +67,15 @@ half = { "version" = "2.7.1", default-features = false, features = [ "num-traits", ] } futures = "0.3" +hf-hub = { version = "0.4.1", default-features = false } +http = "1" log = "0.4" metrics = "0.24" metrics-util = "0.19" moka = { version = "0.12", features = ["future"] } object_store = "0.14.1" pin-project = "1.0.7" +prost = "0.14" rand = "0.9" snafu = "0.8" url = "2" @@ -79,6 +85,8 @@ regex = "1.10" semver = "1.0.25" serde = "1" serde_json = { version = "1", features = ["preserve_order"] } +serial_test = "3" +sha2 = "0.10" tempfile = "3.5.0" tokio = { version = "1.23", features = ["rt-multi-thread", "sync"] } tonic = { version = "0.14", features = ["tls-native-roots", "tls-aws-lc"] } diff --git a/ci/check_cargo_lock.py b/ci/check_cargo_lock.py index 551b76794..94cb10aec 100644 --- a/ci/check_cargo_lock.py +++ b/ci/check_cargo_lock.py @@ -1,4 +1,7 @@ -"""Fail if pruning Cargo.lock did more than remove packages and the edges to them. +"""Fail if pruning Cargo.lock changes pins or adds dependency edges. + +Removing workspace members can disable optional dependency edges even when +their target packages remain in use elsewhere in the packaged workspace. Usage: python ci/check_cargo_lock.py """ @@ -45,7 +48,7 @@ def main() -> None: for package, deps in edges_after.items(): if package not in edges_before: errors.append(f"added or changed {package}") - elif deps != edges_before[package] - removed: + elif not deps <= edges_before[package]: errors.append(f"dependencies of {package[:2]} changed") if errors: sys.exit("error: the prune changed Cargo.lock:\n" + "\n".join(errors)) diff --git a/ci/tests/test_check_cargo_lock.py b/ci/tests/test_check_cargo_lock.py index 82570d087..8ffcebd87 100644 --- a/ci/tests/test_check_cargo_lock.py +++ b/ci/tests/test_check_cargo_lock.py @@ -37,14 +37,15 @@ APP = package("app", "1.0.0", ["libc", "untrusted"], source=None) class CheckCargoLockTest(unittest.TestCase): - def run_check(self, after, header="version = 4\n\n"): + def run_check(self, after, header="version = 4\n\n", before=BEFORE): with tempfile.TemporaryDirectory() as tmp: before_path = Path(tmp, "before.lock") after_path = Path(tmp, "after.lock") - before_path.write_text(BEFORE) + before_path.write_text(before) after_path.write_text(header + after) return subprocess.run( [sys.executable, SCRIPT, before_path, after_path], + check=False, capture_output=True, text=True, ) @@ -81,9 +82,36 @@ class CheckCargoLockTest(unittest.TestCase): self.assertNotEqual(result.returncode, 0) self.assertIn("dependencies of ('libc'", result.stderr) - def test_removed_dependency_fails(self): + def test_removed_dependency_passes(self): app = package("app", "1.0.0", ["untrusted"], source=None) result = self.run_check(app + LIBC + UNTRUSTED) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_disabled_optional_dependency_still_used_elsewhere_passes(self): + # Removing the CLI disables axum's tracing feature, but other SDK + # dependencies still need tracing. Its package therefore stays pinned. + app = package("app", "1.0.0", ["axum", "tracing"], source=None) + tracing = package("tracing", "0.1.44") + before = ( + "version = 4\n\n" + app + package("axum", "0.8.9", ["tracing"]) + tracing + ) + after = app + package("axum", "0.8.9") + tracing + result = self.run_check(after, before=before) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_dependency_redirected_to_an_existing_version_fails(self): + before = ( + "version = 4\n\n" + + package("app", "1.0.0", ["untrusted 0.7.1"], source=None) + + package("untrusted", "0.7.1") + + UNTRUSTED + ) + after = ( + package("app", "1.0.0", ["untrusted 0.9.0"], source=None) + + package("untrusted", "0.7.1") + + UNTRUSTED + ) + result = self.run_check(after, before=before) self.assertNotEqual(result.returncode, 0) self.assertIn("dependencies of ('app'", result.stderr) diff --git a/docs/web/basics/sql.mdx b/docs/web/basics/sql.mdx index 590e3989e..bb4f2cd4a 100644 --- a/docs/web/basics/sql.mdx +++ b/docs/web/basics/sql.mdx @@ -28,6 +28,9 @@ endpoint, returning results as Arrow record batches: any tool with its own Flight SQL integration. A LanceDB-specific SDK is not required to submit SQL. +From the command line, use `lancedb` to open the [SQL shell](/tools/cli) +or execute a statement or file. + The [SQL reference](/sql#connecting) shows a query from Python. See [SDKs](/basics/sdk) for the LanceDB language clients. diff --git a/docs/web/docs.json b/docs/web/docs.json index 5729dd8eb..871e375f8 100644 --- a/docs/web/docs.json +++ b/docs/web/docs.json @@ -288,6 +288,12 @@ "deployment/support" ] }, + { + "group": "Tools", + "pages": [ + "tools/cli" + ] + }, { "group": "Concepts", "pages": [ diff --git a/docs/web/tools/cli.mdx b/docs/web/tools/cli.mdx new file mode 100644 index 000000000..97b058bd9 --- /dev/null +++ b/docs/web/tools/cli.mdx @@ -0,0 +1,217 @@ +--- +title: "LanceDB CLI" +sidebarTitle: "Command line" +description: "Connect to LanceDB, run SQL, and manage connection profiles with the lancedb command-line client." +icon: terminal +keywords: ["cli", "command line", "sql", "profiles", "authentication"] +--- + +Use the `lancedb` command-line client to open an interactive SQL shell, run a +statement, or execute a SQL file. SQL requires a running deployment with an +[Arrow Flight SQL endpoint](/basics/sql#sql-is-remote). + +## Install + +With Rust and Git installed, build the CLI from the LanceDB repository: + +```sh +git clone https://github.com/lancedb/lancedb.git +cd lancedb +cargo install --locked --path rust/lancedb/cli +``` + +Check the available options: + +```sh +lancedb --help +``` + +## Connect to a deployment + +Get the Flight SQL endpoint, HTTP API endpoint, database name, and credentials +from your deployment administrator. Create a named profile to save the +connection settings: + +```sh +lancedb profile create production \ + --sql-server-url grpcs://sql.example.com:10025 \ + --http-server-url https://query.example.com \ + --database analytics +``` + +The SQL endpoint executes statements. The HTTP endpoint supports shell commands +that inspect table and index metadata. Replace both example URLs with the +endpoints for your deployment; they may use different hosts or ports. + +For API-key authentication, set the credential in your environment: + +```sh +export LANCEDB_API_KEY="YOUR_API_KEY" +lancedb --profile production +``` + +Use `grpcs://` or `https://` for a TLS-protected SQL connection. A bare +`host:port` or a `grpc://` address selects a plaintext connection. + +### Set the default profile + +```sh +lancedb profile set production +lancedb +``` + + +Running `lancedb` opens the SQL shell when the default profile has a Flight SQL +endpoint. If no endpoint is configured, it prints usage and connection setup +guidance. Command and file execution also require a configured endpoint. + + +To connect to an existing server on localhost, explicitly select the built-in +local profile: + +```sh +lancedb --profile local +``` + +This profile uses Flight SQL on port `10025` and HTTP on port `10024`. + +## Run SQL + +Execute a statement and exit: + +```sh +lancedb --profile production --command 'SELECT 1' --format json +``` + +To execute a file, separate its statements with semicolons. For example, save +the following as `queries.sql`: + +```sql +SELECT COUNT(*) FROM documents; +SELECT id, text FROM documents LIMIT 10; +``` + +Run the file with: + +```sh +lancedb --profile production --file queries.sql +``` + +Quoted strings and comments in SQL files are preserved when statements are +split. `--command` and `--file` are mutually exclusive. + +### Choose an output format + +For query results, `--format` accepts the following values: + +| Format | Output | +| --- | --- | +| `table` | A display table, the default | +| `csv` | Comma-separated values with column headers | +| `json` | A JSON array of rows | +| `ndjson` | One JSON row per line | + +```sh +lancedb --profile production --command 'SELECT id FROM documents' --format csv +``` + +## Use the SQL shell + +End SQL statements with a semicolon. Enter `\?` to see the shell's commands. + +| Command | Purpose | +| --- | --- | +| `\dt` | List tables | +| `\d documents` | Show a table's columns and indexes | +| `\di` | List indexes | +| `\dj` | List jobs | +| `\c analytics` | Connect to another database | +| `\e` | Edit the query buffer in an external editor | +| `\watch 2` | Repeat the last query or listing every two seconds | +| `\q` | Exit the shell | + +Add `+` to `\dt`, `\d`, or `\di` for more detail. Use Ctrl+C to cancel the +current input or stop watching a query, and Ctrl+D to exit. + +Large interactive results use a pager. Set `NO_PAGER=1` to disable paging. + +### Inspect jobs with SQL + +```sh +lancedb --profile production --command 'SHOW JOBS' +lancedb --profile production --command "SHOW ERRORS ON documents FOR JOB 'JOB_ID'" +lancedb --profile production --command "CANCEL JOB 'JOB_ID'" +``` + +See the [job SQL reference](/sql/ddl/job) for syntax and deployment requirements, +and the [SQL reference](/sql) for other supported statements. + +## Manage profiles + +Profiles are stored in `~/.lancedb/config.toml`. The CLI reuses existing +Enterprise profiles and cached OAuth sessions. + +```sh +lancedb profile list +lancedb profile show production +lancedb profile edit production --database staging +lancedb profile delete production +``` + +Use `lancedb profile create NAME` without connection flags for interactive +setup. For a single SQL invocation, flags such as `--database` and +`--sql-server-url` override the selected profile. + +Put each command's flags after that command. For example, use +`lancedb auth login --profile production` or `lancedb profile edit production +--database staging`. + +## Authenticate with OAuth + +To use OAuth, configure an `oidc` table in the selected profile with your +identity provider's settings. For example, add the following to +`~/.lancedb/config.toml` for a device-code login: + +```toml +[profiles.production.oidc] +flow = "device_code" +issuer_url = "https://identity.example.com" +client_id = "YOUR_CLIENT_ID" +scopes = ["openid", "offline_access"] +``` + +Choose one authentication method per profile. An OAuth profile cannot also +contain `api_key` or `oidc_access_token_file`. Environment credentials override +the profile, so clear API-key overrides before connecting with OAuth: + +```sh +unset LANCEDB_API_KEY +lancedb auth login --profile production +lancedb auth status --profile production +lancedb --profile production +``` + +`auth login` follows the profile's OAuth flow. Browser and device-code sessions +cache refresh tokens for later commands. Use `lancedb auth logout --profile +production` to clear the profile's cached session. + +See [authentication](/enterprise/authentication) for identity provider setup and +available authentication methods. + +## Configure TLS + +To trust a private certificate authority, pass its PEM certificate: + +```sh +lancedb --profile production --ca-cert /path/to/ca.pem +``` + +If the server requires mutual TLS, supply both a client certificate and its +PKCS#8 private key. Save these settings in the profile with: + +```sh +lancedb profile edit production \ + --ca-cert /path/to/ca.pem \ + --client-cert /path/to/client.pem \ + --client-key /path/to/client-key.pem +``` diff --git a/rust/lancedb/Cargo.toml b/rust/lancedb/Cargo.toml index 9655b1160..264c0be4d 100644 --- a/rust/lancedb/Cargo.toml +++ b/rust/lancedb/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true keywords.workspace = true categories.workspace = true rust-version.workspace = true +exclude = ["cli/**"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] @@ -22,7 +23,7 @@ arrow-ord = { workspace = true } arrow-cast = { workspace = true } arrow-ipc.workspace = true arrow-flight = { workspace = true, optional = true } -prost = { version = "0.14", optional = true } +prost = { workspace = true, optional = true } chrono = { workspace = true } datafusion-catalog.workspace = true datafusion-common.workspace = true @@ -86,16 +87,16 @@ reqwest = { version = "0.13.0", default-features = false, features = [ "system-proxy", ], optional = true } tonic = { workspace = true, optional = true } -http = { version = "1", optional = true } # Matching what is in reqwest +http = { workspace = true, optional = true } # Matching what is in reqwest oauth2 = { workspace = true, optional = true } urlencoding = { version = "2", optional = true } -base64 = { version = "0.22", optional = true } +base64 = { workspace = true, optional = true } fs4 = { version = "0.13", optional = true } webbrowser = { version = "1", optional = true } uuid = { workspace = true, features = ["v5"] } polars-arrow = { version = ">=0.37,<0.40.0", optional = true } polars = { version = ">=0.37,<0.40.0", optional = true } -hf-hub = { version = "0.4.1", optional = true, default-features = false, features = [ +hf-hub = { workspace = true, optional = true, features = [ "rustls-tls", "tokio", "ureq", @@ -106,10 +107,10 @@ candle-nn = { version = "0.9.1", optional = true } tokenizers = { version = "0.19.1", optional = true } semver = { workspace = true } roaring = "0.11.4" -sha2 = "0.10" +sha2.workspace = true [dev-dependencies] -anyhow = "1" +anyhow.workspace = true lance-testing = { workspace = true } # RestAdapter is only used by cfg(test) code. Keep rest-adapter off the # production remote feature so Python wheels do not resolve its Axum 0.7 stack. @@ -130,7 +131,7 @@ datafusion.workspace = true http-body = "1" # Matching reqwest rstest = "0.23.0" test-log = "0.2" -serial_test = "3" +serial_test.workspace = true [target.'cfg(unix)'.dev-dependencies] pprof = { version = "0.14", features = ["flamegraph"] } diff --git a/rust/lancedb/cli/Cargo.toml b/rust/lancedb/cli/Cargo.toml new file mode 100644 index 000000000..426df0a1f --- /dev/null +++ b/rust/lancedb/cli/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "lancedb-cli" +version = "0.1.0" +edition = "2021" +license.workspace = true +repository.workspace = true +publish = false + +[[bin]] +name = "lancedb" +path = "src/main.rs" + +[dependencies] +tempfile.workspace = true +which = "6" +anyhow.workspace = true +arrow-array.workspace = true +arrow-buffer.workspace = true +arrow-cast.workspace = true +arrow-json = "58.0.0" +arrow-schema.workspace = true +async-trait.workspace = true +clap = { version = "4.5.13", features = ["derive", "env"] } +directories = "5" +futures.workspace = true +http.workspace = true +lancedb = { workspace = true, features = ["remote"] } +rustyline = "14" +rustls = { version = "0.23", features = ["ring"] } +serde = { workspace = true, features = ["derive"] } +serde_ignored = "0.1.14" +serde_json.workspace = true +sha2.workspace = true +sqlparser = { version = "0.62", default-features = false, features = ["std"] } +termsize = "0.1" +tokio = { workspace = true, features = ["full"] } +toml = "0.8" +tracing.workspace = true +unicode-width = "0.2" + +[dev-dependencies] +arrow-flight.workspace = true +prost.workspace = true +rcgen = "0.12" +tokio-stream = { version = "0.1", features = ["net"] } +half.workspace = true +axum = "0.8" +base64.workspace = true +csv = "1" +reqwest = { version = "0.12", features = ["json"] } +tonic.workspace = true +url.workspace = true diff --git a/rust/lancedb/cli/src/auth.rs b/rust/lancedb/cli/src/auth.rs new file mode 100644 index 000000000..090bfd1d8 --- /dev/null +++ b/rust/lancedb/cli/src/auth.rs @@ -0,0 +1,419 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +//! CLI configuration and transport adapters for LanceDB's OAuth implementation. + +use std::collections::HashMap; +use std::path::PathBuf; + +use anyhow::{anyhow, ensure, Context, Result}; +use clap::{Args, Subcommand}; +use lancedb::remote::{ + AuthorizationCodeOptions, ClientAuthMethod, HeaderProvider, OAuthConfig, OAuthFlow, + OAuthHeaderProvider, OAuthSession, TokenCacheOptions, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tokio::sync::OnceCell; + +use crate::server_config::{Authentication, Config, DEFAULT_PROFILE}; + +const CLIENT_SECRET_ENV: &str = "LANCEDB_OIDC_CLIENT_SECRET"; +pub const OIDC_CREDENTIAL_HEADER: &str = "x-lancedb-credential-type"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OidcFlow { + ClientCredentials, + AuthorizationCode, + DeviceCode, +} + +impl std::fmt::Display for OidcFlow { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::ClientCredentials => "client_credentials", + Self::AuthorizationCode => "authorization_code", + Self::DeviceCode => "device_code", + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OidcClientAuthMethod { + None, + ClientSecretBasic, + ClientSecretPost, +} + +impl From for ClientAuthMethod { + fn from(method: OidcClientAuthMethod) -> Self { + match method { + OidcClientAuthMethod::None => Self::None, + OidcClientAuthMethod::ClientSecretBasic => Self::ClientSecretBasic, + OidcClientAuthMethod::ClientSecretPost => Self::ClientSecretPost, + } + } +} + +/// Nonsecret configuration stored inside a connection profile. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OidcConfig { + pub flow: OidcFlow, + pub issuer_url: String, + pub client_id: String, + pub scopes: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_secret_file: Option, + /// When omitted, the SDK selects authentication based on the client secret. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_auth_method: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub audience: Option, + /// Registered loopback callback URI for authorization-code login. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub redirect_uri: Option, + /// Defaults to the SDK's callback port (8400). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub callback_port: Option, + /// Defaults to the SDK's refresh buffer (300 seconds). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refresh_buffer_secs: Option, +} + +/// One SDK provider is shared by all transports using the resolved profile. +#[derive(Debug)] +pub struct ManagedOidc { + config: OidcConfig, + cache_dir: PathBuf, + provider: OnceCell, +} + +impl PartialEq for ManagedOidc { + fn eq(&self, other: &Self) -> bool { + // Acquired tokens do not change which authentication configuration was selected. + self.config == other.config && self.cache_dir == other.cache_dir + } +} +impl Eq for ManagedOidc {} + +/// A resolved credential, with no provider state attached. +pub enum Credential { + ApiKey(String), + OidcAccessToken(String), +} + +impl std::fmt::Debug for Credential { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::ApiKey(_) => "ApiKey()", + Self::OidcAccessToken(_) => "OidcAccessToken()", + }) + } +} + +impl Credential { + pub fn headers(&self) -> Result { + let mut headers = http::HeaderMap::new(); + let (name, value) = match self { + Self::ApiKey(key) => ("x-api-key", key.clone()), + Self::OidcAccessToken(token) => { + headers.insert( + OIDC_CREDENTIAL_HEADER, + http::HeaderValue::from_static("oidc"), + ); + ("authorization", format!("Bearer {token}")) + } + }; + let mut value = http::HeaderValue::from_str(&value) + .map_err(|_| anyhow!("Invalid authentication credential: not a valid HTTP header"))?; + value + .to_str() + .map_err(|_| anyhow!("Invalid authentication credential: expected an ASCII header"))?; + value.set_sensitive(true); + headers.insert(name, value); + Ok(headers) + } +} + +impl Authentication { + pub async fn headers(&self) -> Result { + match self { + Self::ApiKey(key) => Credential::ApiKey(key.clone()).headers(), + Self::OidcAccessToken(token) => Credential::OidcAccessToken(token.clone()).headers(), + Self::OidcAccessTokenFile(path) => { + let token = tokio::fs::read_to_string(path).await.with_context(|| { + format!("Failed to read OIDC access token file {}", path.display()) + })?; + let token = token.trim(); + ensure!(!token.is_empty(), "OIDC access token file is empty"); + Credential::OidcAccessToken(token.to_owned()).headers() + } + Self::ManagedOidc(session) => session.headers().await, + } + } +} + +#[async_trait::async_trait] +impl HeaderProvider for Authentication { + async fn get_headers(&self) -> lancedb::Result> { + let headers = self + .headers() + .await + .map_err(|error| lancedb::Error::Runtime { + message: format!("Authentication failed: {error:#}"), + })?; + Ok(headers + .iter() + .map(|(key, value)| { + ( + key.as_str().to_owned(), + value + .to_str() + .expect("validated credential header") + .to_owned(), + ) + }) + .collect()) + } +} + +impl ManagedOidc { + pub fn for_profile(profile: &str, config: OidcConfig) -> Result { + let root = Config::default_config_path()?; + // Profile names are arbitrary strings; hashing prevents paths from escaping the cache. + let profile_key = format!("{:x}", Sha256::digest(profile.as_bytes())); + Self::new( + config, + root.parent().unwrap().join("auth").join(profile_key), + ) + } + + fn new(config: OidcConfig, cache_dir: PathBuf) -> Result { + ensure!( + !config.client_id.trim().is_empty(), + "OIDC client_id cannot be empty" + ); + ensure!( + !config.scopes.is_empty(), + "OIDC scopes must contain at least one scope" + ); + ensure!( + config.flow == OidcFlow::AuthorizationCode + || (config.redirect_uri.is_none() && config.callback_port.is_none()), + "redirect_uri and callback_port require flow = authorization_code" + ); + Ok(Self { + config, + cache_dir, + provider: OnceCell::new(), + }) + } + + pub fn flow(&self) -> &OidcFlow { + &self.config.flow + } + + async fn sdk_config(&self) -> Result { + let flow = match self.config.flow { + OidcFlow::ClientCredentials => OAuthFlow::ClientCredentials, + OidcFlow::DeviceCode => OAuthFlow::DeviceCode, + OidcFlow::AuthorizationCode => { + let mut options = AuthorizationCodeOptions::new(); + if let Some(uri) = &self.config.redirect_uri { + options = options.redirect_uri(uri); + } + if let Some(port) = self.config.callback_port { + options = options.callback_port(port); + } + OAuthFlow::AuthorizationCode(options) + } + }; + Ok(OAuthConfig { + issuer_url: self.config.issuer_url.clone(), + client_id: self.config.client_id.clone(), + client_secret: self.client_secret().await?, + scopes: self.config.scopes.clone(), + resource: self.config.resource.clone(), + audience: self.config.audience.clone(), + flow, + client_auth_method: self.config.client_auth_method.map(Into::into), + refresh_buffer_secs: self.config.refresh_buffer_secs, + token_cache: (self.config.flow != OidcFlow::ClientCredentials) + .then(|| TokenCacheOptions::new().cache_dir(&self.cache_dir)), + }) + } + + async fn provider(&self) -> Result<&OAuthHeaderProvider> { + self.provider + .get_or_try_init(|| async { + OAuthHeaderProvider::new(self.sdk_config().await?).map_err(sdk_error) + }) + .await + } + + async fn session(&self) -> Result { + OAuthSession::new(self.sdk_config().await?).map_err(sdk_error) + } + + async fn headers(&self) -> Result { + let values = self + .provider() + .await? + .get_headers() + .await + .map_err(sdk_error)?; + let mut headers = http::HeaderMap::new(); + for (name, value) in values { + let name = http::HeaderName::from_bytes(name.as_bytes()) + .map_err(|_| anyhow!("Invalid authentication header name"))?; + let mut value = http::HeaderValue::from_str(&value).map_err(|_| { + anyhow!("Invalid authentication credential: not a valid HTTP header") + })?; + value.to_str().map_err(|_| { + anyhow!("Invalid authentication credential: expected an ASCII header") + })?; + if name == http::header::AUTHORIZATION || name == "x-api-key" { + value.set_sensitive(true); + } + headers.insert(name, value); + } + Ok(headers) + } + + async fn client_secret(&self) -> Result> { + if let Some(secret) = std::env::var_os(CLIENT_SECRET_ENV) { + let secret = secret + .into_string() + .map_err(|_| anyhow!("{CLIENT_SECRET_ENV} must be UTF-8"))?; + ensure!(!secret.is_empty(), "{CLIENT_SECRET_ENV} cannot be empty"); + return Ok(Some(secret)); + } + let Some(path) = self.config.client_secret_file.as_ref() else { + ensure!( + self.config.flow != OidcFlow::ClientCredentials, + "Client credentials require LANCEDB_OIDC_CLIENT_SECRET or oidc.client_secret_file" + ); + return Ok(None); + }; + let secret = tokio::fs::read_to_string(path) + .await + .context("Failed to read OIDC client secret file")?; + let secret = secret.trim(); + ensure!(!secret.is_empty(), "OIDC client secret file is empty"); + Ok(Some(secret.to_owned())) + } +} + +fn sdk_error(error: lancedb::Error) -> anyhow::Error { + // SDK runtime errors can contain provider response bodies and credentials. + // Configuration errors are local; never attach the original runtime error as a cause. + match error { + lancedb::Error::InvalidInput { message } => anyhow!("Invalid OIDC configuration: {message}"), + _ => anyhow!("OIDC authentication failed; check the issuer, client credentials, scopes, and provider availability. For a cached interactive session, try lancedb auth logout followed by lancedb auth login."), + } +} + +#[derive(Args, Debug)] +pub struct AuthArgs { + #[command(subcommand)] + pub command: AuthCommand, +} + +#[derive(Subcommand, Debug)] +pub enum AuthCommand { + /// Log in using the profile's OAuth flow; browser/device sessions cache refresh tokens. + Login(ProfileArgs), + /// Report whether an interactive session is cached, without contacting the provider. + Status(ProfileArgs), + /// Clear the profile's local interactive session; does not revoke provider credentials. + Logout(ProfileArgs), +} + +#[derive(Args, Debug)] +pub struct ProfileArgs { + #[arg(short = 'p', long, value_name = "PROFILE")] + pub profile: Option, +} + +pub async fn run(args: AuthArgs, parent_profile: Option) -> Result<()> { + let profile_args = match &args.command { + AuthCommand::Login(args) | AuthCommand::Status(args) | AuthCommand::Logout(args) => args, + }; + let config = Config::load_default()?; + let profile_name = profile_args + .profile + .clone() + .or(parent_profile) + .or(config.default_profile.clone()) + .unwrap_or_else(|| DEFAULT_PROFILE.to_owned()); + let profile = config + .profiles + .get(&profile_name) + .context("Profile does not exist")?; + profile.validate_authentication()?; + let oidc = profile + .oidc + .clone() + .context("Profile has no managed OIDC configuration")?; + let managed = ManagedOidc::for_profile(&profile_name, oidc)?; + match args.command { + AuthCommand::Login(_) => { + if managed.config.flow == OidcFlow::ClientCredentials { + managed.headers().await?; + println!("Authenticated service account for profile '{profile_name}'. Access tokens are cached only within each process."); + } else { + let status = managed.session().await?.login().await.map_err(sdk_error)?; + println!( + "Authenticated profile '{profile_name}' using {}", + managed.flow() + ); + if !status.refreshable { + eprintln!("The provider returned no refresh token; later commands will need another login. Check the provider's offline-access scope and consent settings."); + } + } + if [ + "LANCEDB_API_KEY", + "LANCEDB_OIDC_ACCESS_TOKEN", + "LANCEDB_OIDC_ACCESS_TOKEN_FILE", + ] + .iter() + .any(|key| std::env::var_os(key).is_some()) + { + eprintln!("Authentication environment overrides are set; ordinary commands will use those credentials instead of this session."); + } + } + AuthCommand::Status(_) => { + if managed.config.flow == OidcFlow::ClientCredentials { + println!("Profile '{profile_name}' uses client credentials; no persistent session is stored."); + } else { + let status = managed.session().await?.status().await.map_err(sdk_error)?; + println!( + "Profile '{profile_name}' uses {}; cached refresh token: {}", + managed.flow(), + if status.refreshable { + "present" + } else { + "not present" + } + ); + } + } + AuthCommand::Logout(_) => { + if managed.config.flow == OidcFlow::ClientCredentials { + println!("Profile '{profile_name}' uses client credentials; no persistent session to clear. Configured credentials remain usable."); + } else { + managed.session().await?.logout().await.map_err(sdk_error)?; + println!("Cleared the local session for profile '{profile_name}'. Provider credentials remain valid."); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/rust/lancedb/cli/src/auth/tests.rs b/rust/lancedb/cli/src/auth/tests.rs new file mode 100644 index 000000000..5d626f5e6 --- /dev/null +++ b/rust/lancedb/cli/src/auth/tests.rs @@ -0,0 +1,417 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use super::*; +use axum::{ + extract::{Form, Query}, + http::{HeaderMap, StatusCode}, + response::Redirect, + routing::{get, post}, + Json, Router, +}; +use base64::Engine; +use std::fs; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tempfile::TempDir; + +struct Provider { + config: OidcConfig, + requests: Arc>>>, + server: tokio::task::JoinHandle<()>, + dir: TempDir, +} + +impl Drop for Provider { + fn drop(&mut self) { + self.server.abort(); + } +} + +impl Provider { + async fn start(flow: OidcFlow, lifetime: u64) -> Self { + let dir = tempfile::tempdir().unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let issuer = format!("http://{}", listener.local_addr().unwrap()); + let secret_file = dir.path().join("secret"); + fs::write(&secret_file, "test-secret\n").unwrap(); + let config = OidcConfig { + client_auth_method: None, + client_secret_file: (flow == OidcFlow::ClientCredentials).then_some(secret_file), + flow, + issuer_url: issuer.clone(), + client_id: "cli-client".into(), + scopes: vec!["openid".into(), "offline_access".into()], + resource: Some("https://api.example.com/a?x=1&y=two".into()), + audience: Some("lancedb + audience".into()), + redirect_uri: None, + callback_port: None, + refresh_buffer_secs: Some(0), + }; + let metadata = serde_json::json!({ + "issuer": issuer, + "token_endpoint": format!("{issuer}/token"), + "authorization_endpoint": format!("{issuer}/authorize"), + "device_authorization_endpoint": format!("{issuer}/device"), + }); + let device = serde_json::json!({ + "device_code": "device-code", "user_code": "CLI-CODE", + "verification_uri": format!("{issuer}/verify"), "expires_in": 60, "interval": 1, + }); + let requests = Arc::new(Mutex::new(Vec::new())); + let device_requests = requests.clone(); + let auth_requests = requests.clone(); + let token_requests = requests.clone(); + let app = Router::new() + .route("/.well-known/openid-configuration", get(move || { + let metadata = metadata.clone(); + async move { Json(metadata) } + })) + .route("/verify", get(|| async { "verified" })) + .route("/device", post(move |Form(form): Form>| { + let device = device.clone(); + device_requests.lock().unwrap().push(form); + async move { Json(device) } + })) + .route("/authorize", get(move |Query(form): Query>| { + assert_eq!(form["code_challenge_method"], "S256"); + let mut redirect = url::Url::parse(&form["redirect_uri"]).unwrap(); + redirect.query_pairs_mut().append_pair("code", "auth-code").append_pair("state", &form["state"]); + auth_requests.lock().unwrap().push(form); + async move { Redirect::to(redirect.as_str()) } + })) + .route("/token", post(move |headers: HeaderMap, Form(form): Form>| { + let mut requests = token_requests.lock().unwrap(); + requests.push(form.clone()); + let number = requests.iter().filter(|request| request.contains_key("grant_type")).count(); + async move { + if form.get("grant_type").is_some_and(|grant| grant == "client_credentials") + && headers.get("authorization").and_then(|value| value.to_str().ok()) + != Some(format!("Basic {}", base64::engine::general_purpose::STANDARD.encode("cli-client:test-secret")).as_str()) { + return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({ + "error": "invalid_client", "error_description": "private-token test-secret invalid-secret" + }))); + } + (StatusCode::OK, Json(serde_json::json!({ + "access_token": format!("token-{number}"), "token_type": "Bearer", "expires_in": lifetime, + "refresh_token": "private-refresh-token", + }))) + } + })); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + Self { + config, + requests, + server, + dir, + } + } + + fn managed(&self) -> ManagedOidc { + ManagedOidc::new(self.config.clone(), self.dir.path().join("cache")).unwrap() + } + + fn assert_targets(&self) { + for request in self.requests.lock().unwrap().iter() { + assert_eq!(request.get("resource"), self.config.resource.as_ref()); + assert_eq!(request.get("audience"), self.config.audience.as_ref()); + } + } + + async fn child(&self, action: &str) -> std::process::Output { + let executable = std::env::current_exe().unwrap(); + let mut command = tokio::process::Command::new(&executable); + command + .args([ + "--exact", + "auth::tests::session_process_helper", + "--nocapture", + ]) + .env("LANCEDB_TEST_AUTH_ACTION", action) + .env( + "LANCEDB_TEST_AUTH_CONFIG", + serde_json::to_string(&self.config).unwrap(), + ) + .env("LANCEDB_TEST_AUTH_CACHE", self.dir.path().join("cache")) + .env_remove(CLIENT_SECRET_ENV); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let browser = self.dir.path().join("browser"); + fs::write(&browser, "#!/bin/sh\nLANCEDB_TEST_BROWSER_URL=\"$1\" exec \"$LANCEDB_TEST_AUTH_EXE\" --exact auth::tests::browser_process_helper --nocapture\n").unwrap(); + fs::set_permissions(&browser, fs::Permissions::from_mode(0o700)).unwrap(); + command + .env("LANCEDB_OAUTH_BROWSER", browser) + .env("LANCEDB_TEST_AUTH_EXE", executable); + } + let output = command.output().await.unwrap(); + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let output_text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(!output_text.contains("private-refresh-token")); + output + } +} + +#[tokio::test] +async fn sdk_provider_is_shared_and_renews_service_tokens_in_memory() { + let fixture = Provider::start(OidcFlow::ClientCredentials, 1).await; + let authentication = Authentication::ManagedOidc(Arc::new(fixture.managed())); + let cloned = authentication.clone(); + let (first, second) = tokio::join!(authentication.headers(), cloned.headers()); + assert_eq!(first.unwrap()["authorization"], "Bearer token-1"); + assert_eq!(second.unwrap()["authorization"], "Bearer token-1"); + assert_eq!(fixture.requests.lock().unwrap().len(), 1); + tokio::time::sleep(Duration::from_millis(1100)).await; + let headers = authentication.headers().await.unwrap(); + assert_eq!(headers["authorization"], "Bearer token-2"); + assert_eq!(headers[OIDC_CREDENTIAL_HEADER], "oidc"); + assert!(headers["authorization"].is_sensitive()); + assert!(!format!("{headers:?}").contains("token-2")); + assert!(!fixture.dir.path().join("cache").exists()); + fixture.assert_targets(); +} + +#[tokio::test] +async fn sdk_provider_errors_do_not_expose_response_bodies() { + let fixture = Provider::start(OidcFlow::ClientCredentials, 3600).await; + fs::write( + fixture.config.client_secret_file.as_ref().unwrap(), + "invalid-secret", + ) + .unwrap(); + let error = fixture.managed().headers().await.unwrap_err(); + let rendered = format!("{error:#} {error:?}"); + assert!(rendered.contains("OIDC authentication failed")); + for secret in ["private-token", "test-secret", "invalid-secret"] { + assert!(!rendered.contains(secret)); + } +} + +#[tokio::test] +async fn missing_secret_does_not_fall_back() { + let fixture = Provider::start(OidcFlow::ClientCredentials, 3600).await; + fs::remove_file(fixture.config.client_secret_file.as_ref().unwrap()).unwrap(); + assert!(fixture.managed().headers().await.is_err()); + assert!(fixture.requests.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn sdk_provider_reads_updated_external_token_files() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("token"); + fs::write(&path, "first-token\n").unwrap(); + let provider = Authentication::OidcAccessTokenFile(path.clone()); + assert_eq!( + provider.get_headers().await.unwrap()["authorization"], + "Bearer first-token" + ); + fs::write(&path, "second-token\n").unwrap(); + assert_eq!( + provider.get_headers().await.unwrap()["authorization"], + "Bearer second-token" + ); + fs::write(&path, " \n").unwrap(); + assert!(provider.get_headers().await.is_err()); + fs::write(&path, "token\r\ninjected: header").unwrap(); + assert!(provider.get_headers().await.is_err()); +} + +#[tokio::test] +async fn profile_client_auth_method_round_trips_and_reaches_sdk() { + let profile = r#" +flow = "authorization_code" +issuer_url = "http://localhost:18888/realms/lancedb" +client_id = "cli-client" +scopes = ["openid"] +"#; + for (value, expected) in [ + (None, None), + (Some("none"), Some(ClientAuthMethod::None)), + ( + Some("client_secret_basic"), + Some(ClientAuthMethod::ClientSecretBasic), + ), + ( + Some("client_secret_post"), + Some(ClientAuthMethod::ClientSecretPost), + ), + ] { + let text = match value { + Some(value) => format!("{profile}\nclient_auth_method = {value:?}\n"), + None => profile.to_owned(), + }; + let config: OidcConfig = toml::from_str(&text).unwrap(); + let serialized = toml::to_string(&config).unwrap(); + assert_eq!(serialized.contains("client_auth_method"), value.is_some()); + assert_eq!(toml::from_str::(&serialized).unwrap(), config); + let dir = tempfile::tempdir().unwrap(); + let managed = ManagedOidc::new(config, dir.path().join("cache")).unwrap(); + assert_eq!( + managed.sdk_config().await.unwrap().client_auth_method, + expected + ); + } + assert!(toml::from_str::(&format!( + "{profile}\nclient_auth_method = \"unsupported\"\n" + )) + .is_err()); +} + +#[tokio::test] +async fn sdk_rejects_public_client_auth_with_a_secret_before_requesting_tokens() { + let mut fixture = Provider::start(OidcFlow::ClientCredentials, 3600).await; + fixture.config.client_auth_method = Some(OidcClientAuthMethod::None); + assert!(fixture.managed().provider().await.is_err()); + assert!(fixture.requests.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn sdk_config_preserves_profile_options() { + let mut fixture = Provider::start(OidcFlow::AuthorizationCode, 3600).await; + fixture.config.callback_port = Some(9123); + fixture.config.redirect_uri = Some("http://127.0.0.1:9123/cli".into()); + let config = fixture.managed().sdk_config().await.unwrap(); + assert_eq!(config.resource, fixture.config.resource); + assert_eq!(config.audience, fixture.config.audience); + assert_eq!(config.scopes, fixture.config.scopes); + assert_eq!(config.refresh_buffer_secs, Some(0)); + assert_eq!( + config.token_cache.unwrap().cache_dir, + Some(fixture.dir.path().join("cache")) + ); + let OAuthFlow::AuthorizationCode(options) = config.flow else { + panic!("expected browser flow") + }; + assert!(options.use_pkce); + assert_eq!(options.callback_port, Some(9123)); + assert_eq!(options.redirect_uri, fixture.config.redirect_uri); + fixture.config.flow = OidcFlow::DeviceCode; + assert!(ManagedOidc::new(fixture.config.clone(), fixture.dir.path().join("cache")).is_err()); +} + +#[tokio::test] +async fn profile_caches_are_isolated_without_creating_directories() { + let fixture = Provider::start(OidcFlow::DeviceCode, 3600).await; + let first = ManagedOidc::for_profile("a/../b", fixture.config.clone()).unwrap(); + let second = ManagedOidc::for_profile("b", fixture.config.clone()).unwrap(); + assert_ne!(first.cache_dir, second.cache_dir); + assert_eq!(first.cache_dir.parent(), second.cache_dir.parent()); + assert_eq!(first.cache_dir.file_name().unwrap().len(), 64); +} + +#[cfg(unix)] +#[tokio::test] +async fn sdk_interactive_sessions_survive_processes_and_logout() { + for flow in [OidcFlow::AuthorizationCode, OidcFlow::DeviceCode] { + let mut fixture = Provider::start(flow, 3600).await; + if fixture.config.flow == OidcFlow::AuthorizationCode { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + fixture.config.callback_port = Some(listener.local_addr().unwrap().port()); + } + // Ordinary requests trigger acquisition, without an explicit login first. + fixture.child("fetch").await; + let original_requests = fixture.requests.lock().unwrap().len(); + fixture.child("fetch").await; + { + let requests = fixture.requests.lock().unwrap(); + assert_eq!(requests.len(), original_requests + 1); + assert_eq!(requests.last().unwrap()["grant_type"], "refresh_token"); + } + fixture.child("status").await; + fixture.child("logout").await; + assert!( + !fixture + .managed() + .session() + .await + .unwrap() + .status() + .await + .unwrap() + .refreshable + ); + fixture.child("login").await; + fixture.child("status").await; + fixture.assert_targets(); + } +} + +#[tokio::test] +async fn session_process_helper() { + let Ok(action) = std::env::var("LANCEDB_TEST_AUTH_ACTION") else { + return; + }; + let config = serde_json::from_str(&std::env::var("LANCEDB_TEST_AUTH_CONFIG").unwrap()).unwrap(); + let managed = ManagedOidc::new( + config, + std::env::var_os("LANCEDB_TEST_AUTH_CACHE").unwrap().into(), + ) + .unwrap(); + match action.as_str() { + "fetch" => { + managed.headers().await.unwrap(); + } + "login" => { + assert!( + managed + .session() + .await + .unwrap() + .login() + .await + .unwrap() + .refreshable + ); + } + "status" => { + assert!( + managed + .session() + .await + .unwrap() + .status() + .await + .unwrap() + .refreshable + ); + } + "logout" => { + assert!( + managed + .session() + .await + .unwrap() + .logout() + .await + .unwrap() + .removed + ); + } + _ => panic!("unknown action"), + } +} + +#[tokio::test] +async fn browser_process_helper() { + let Ok(url) = std::env::var("LANCEDB_TEST_BROWSER_URL") else { + return; + }; + reqwest::Client::new() + .get(url) + .send() + .await + .unwrap() + .error_for_status() + .unwrap(); +} diff --git a/rust/lancedb/cli/src/cli.rs b/rust/lancedb/cli/src/cli.rs new file mode 100644 index 000000000..aa2b4c5d4 --- /dev/null +++ b/rust/lancedb/cli/src/cli.rs @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use crate::server_config::{ConnectionArgs, ResolvedConnection}; +use anyhow::{bail, Context, Result}; +use clap::{CommandFactory, Parser, Subcommand}; + +/// Public client commands. Runtime and administrative commands live in Enterprise. +#[derive(Parser, Debug)] +#[command( + name = "lancedb", + version, + about = "LanceDB command-line client", + args_conflicts_with_subcommands = true +)] +pub struct Args { + #[command(subcommand)] + pub command: Option, + /// SQL options used when no subcommand is given. These are local to the + /// default SQL invocation, so other subcommands use their own options. + #[command(flatten)] + pub sql: SqlArgs, +} + +#[derive(Subcommand, Debug)] +pub enum Commands { + /// Manage OAuth login and cached interactive sessions + Auth(crate::auth::AuthArgs), + /// Manage connection profiles + Profile(crate::profile::ProfileArgs), + /// Run SQL or open an interactive shell against a remote server + Sql(SqlArgs), +} + +#[derive(clap::Args, Debug)] +pub struct SqlArgs { + #[command(flatten)] + pub connection: ConnectionArgs, + /// Execute SQL and exit + #[arg(short = 'c', long = "command", conflicts_with = "file")] + pub command_string: Option, + /// Execute SQL commands from a file and exit + #[arg(short = 'f', long)] + pub file: Option, + /// Output format: table, csv, json, ndjson + #[arg(long, default_value = "table")] + pub format: String, +} + +const SQL_ENDPOINT_HELP: &str = "No Flight SQL endpoint is configured. Use --sql-server-url, \ + select a profile with sql_server_url, or use --profile local for localhost."; + +pub async fn run_sql(args: SqlArgs) -> Result<()> { + let connection = args.connection.resolve_sql()?.context(SQL_ENDPOINT_HELP)?; + run_sql_with_connection(args, connection).await +} + +async fn run_sql_with_connection(args: SqlArgs, connection: ResolvedConnection) -> Result<()> { + if let Some(file) = args.file { + crate::client::run_client_script_with_writer( + &connection, + std::fs::read_to_string(file)?, + args.format, + None, + ) + .await + } else if let Some(sql) = args.command_string { + crate::client::run_client_command_with_writer(&connection, sql, args.format, None).await + } else { + crate::client::run_client_with_writer(&connection, None).await + } +} + +pub async fn run(args: Args) -> Result<()> { + match args.command { + None => match args.sql.connection.resolve_sql()? { + Some(connection) => run_sql_with_connection(args.sql, connection).await, + None if args.sql.command_string.is_none() + && args.sql.file.is_none() + && args.sql.connection.profile.is_none() => + { + println!("{SQL_ENDPOINT_HELP}\n"); + Args::command().print_help()?; + println!(); + Ok(()) + } + None => bail!(SQL_ENDPOINT_HELP), + }, + Some(Commands::Auth(args)) => crate::auth::run(args, None).await, + Some(Commands::Profile(args)) => crate::profile::run_profile_command(args).await, + Some(Commands::Sql(args)) => run_sql(args).await, + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn sql_is_the_default_command() { + let args = Args::try_parse_from(["lancedb"]).unwrap(); + assert!(args.command.is_none()); + assert_eq!(args.sql.format, "table"); + assert!(args.sql.command_string.is_none()); + assert!(args.sql.file.is_none()); + assert!(matches!( + Args::try_parse_from(["lancedb", "sql"]).unwrap().command, + Some(Commands::Sql(_)) + )); + let args = Args::try_parse_from([ + "lancedb", + "--profile", + "production", + "--command", + "SELECT 1", + "--format", + "json", + ]) + .unwrap(); + assert!(args.command.is_none()); + assert_eq!(args.sql.connection.profile.as_deref(), Some("production")); + assert_eq!(args.sql.command_string.as_deref(), Some("SELECT 1")); + assert_eq!(args.sql.format, "json"); + let args = Args::try_parse_from(["lancedb", "--file", "queries.sql"]).unwrap(); + assert_eq!(args.sql.file.as_deref(), Some("queries.sql")); + } + + #[test] + fn default_sql_options_do_not_apply_to_subcommands() { + for args in [ + vec!["lancedb", "--format", "json", "profile", "list"], + vec!["lancedb", "--profile", "production", "profile", "list"], + vec!["lancedb", "--command", "SELECT 1", "profile", "list"], + vec!["lancedb", "profile", "list", "--command", "SELECT 1"], + vec!["lancedb", "profile", "list", "--file", "queries.sql"], + vec!["lancedb", "-c", "SELECT 1", "-f", "query.sql"], + vec!["lancedb", "sql", "-c", "SELECT 1", "-f", "query.sql"], + ] { + assert!(Args::try_parse_from(&args).is_err(), "{args:?}"); + } + assert!(Args::try_parse_from(["lancedb", "profile", "list"]).is_ok()); + } + + #[test] + fn excluded_commands_are_rejected() { + for command in [ + "server", + "admin", + "bench", + "service", + "pe", + "agent", + "management", + "preflight", + "run", + "function-worker", + "job", + "acl", + "load", + ] { + assert!( + Args::try_parse_from(["lancedb", command]).is_err(), + "{command}" + ); + } + } +} diff --git a/rust/lancedb/cli/src/client.rs b/rust/lancedb/cli/src/client.rs new file mode 100644 index 000000000..34035f3c1 --- /dev/null +++ b/rust/lancedb/cli/src/client.rs @@ -0,0 +1,2110 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use crate::formatter::{OutputFormat, QueryResult, ResultFormatter}; +use crate::server_config::{ + parse_endpoint, parse_grpc_endpoint, Authentication, ResolvedConnection, DEFAULT_HTTP_PORT, + DEFAULT_SQL_PORT, +}; +use crate::sql_executor::SdkSqlExecutor; +use crate::sql_executor::{SqlQueryExecutor, SqlResult}; +use anyhow::{anyhow, Context, Result}; +use arrow_schema::Schema; +use directories::ProjectDirs; +use futures::TryStreamExt; +use lancedb::listing::ListingOptions; +use rustyline::completion::{Completer, Pair}; +use rustyline::error::ReadlineError; +use rustyline::highlight::Highlighter; +use rustyline::hint::Hinter; +use rustyline::validate::Validator; +use rustyline::{Editor, Helper}; +use sqlparser::dialect::{Dialect, GenericDialect}; +use sqlparser::tokenizer::{Location, Token, Tokenizer}; +use std::io::{self, Write}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tempfile::NamedTempFile; +use tokio::signal; + +const LANCEDB_ASCII_ART: &str = r#" _ ____ ____ +| | __ _ _ __ ___ ___| _ \| __ ) +| | / _` | '_ \ / __/ _ \ | | | _ \ +| |__| (_| | | | | (_| __/ |_| | |_) | +|_____\__,_|_| |_|\___\___|____/|____/ + "#; + +/// Information about a database table +#[derive(Debug, Clone)] +pub struct TableInfo { + pub name: String, + pub schema: Arc, + pub row_count: Option, + pub size_bytes: Option, + pub num_indices: Option, + pub num_fragments: Option, +} + +/// Information about an index +#[derive(Debug, Clone, serde::Serialize)] +pub struct IndexInfo { + pub name: String, + pub table_name: String, + pub index_type: String, + pub columns: Vec, +} + +/// Statistics about an index +#[derive(Debug, Clone)] +pub struct IndexStats { + pub num_indexed_rows: u64, + pub num_unindexed_rows: u64, + pub distance_type: Option, +} + +/// Result of executing a metacommand +enum MetaCommandResult { + /// Command was handled normally, continue REPL loop + Handled, + /// Replace the current input line with new content (for \e) + ReplaceInput(String), +} + +/// The main client structure using the new architecture +pub struct LanceDBClient { + editor: Editor, + history_path: PathBuf, + sql_executor: S, + sql_connected: bool, + connection: Option>, + formatter: ResultFormatter, + current_database: Option, + connection_info: Option, + interactive_mode: bool, + authentication: Authentication, + last_query: Option, + /// The last re-runnable command (a SQL query or a `\d*` listing + /// metacommand), used by `\watch`. Excludes `\watch` itself so it can't + /// recurse, and one-shot commands like `\c`/`\e`/`\q`. + last_command: Option, + profile_name: String, +} + +#[derive(Clone)] +struct LanceDBCompleter { + table_names: Arc>>, + index_names: Arc>>, + qualified_index_names: Arc>>, +} + +impl LanceDBCompleter { + fn new() -> Self { + Self { + table_names: Arc::new(std::sync::Mutex::new(Vec::new())), + index_names: Arc::new(std::sync::Mutex::new(Vec::new())), + qualified_index_names: Arc::new(std::sync::Mutex::new(Vec::new())), + } + } + + fn update_table_names(&mut self, tables: Vec) { + if let Ok(mut names) = self.table_names.lock() { + *names = tables; + } + } + + fn update_index_names(&mut self, indices: Vec, qualified: Vec) { + if let Ok(mut names) = self.index_names.lock() { + *names = indices; + } + if let Ok(mut names) = self.qualified_index_names.lock() { + *names = qualified; + } + } +} + +impl Completer for LanceDBCompleter { + type Candidate = Pair; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &rustyline::Context<'_>, + ) -> rustyline::Result<(usize, Vec)> { + let mut candidates = Vec::new(); + let trimmed = line[..pos].trim_start(); + + // For \di commands, only complete index names (including qualified table.index) + if trimmed.starts_with("\\di+ ") || trimmed.starts_with("\\di ") { + let word_start = line[..pos] + .rfind(|c: char| c.is_whitespace()) + .map(|i| i + 1) + .unwrap_or(0); + let partial = &line[word_start..pos]; + let partial_lower = partial.to_lowercase(); + + // Offer both unqualified and qualified index names + if let Ok(indices) = self.index_names.lock() { + for index in indices.iter() { + if index.to_lowercase().starts_with(&partial_lower) { + candidates.push(Pair { + display: index.clone(), + replacement: index.clone(), + }); + } + } + } + if let Ok(qualified) = self.qualified_index_names.lock() { + for name in qualified.iter() { + if name.to_lowercase().starts_with(&partial_lower) { + candidates.push(Pair { + display: name.clone(), + replacement: name.clone(), + }); + } + } + } + return Ok((word_start, candidates)); + } + + // SQL keywords + let keywords = vec![ + "SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES", "UPDATE", "DELETE", "CREATE", + "TABLE", "DROP", "ALTER", "INDEX", "ON", "SHOW", "TABLES", "DESCRIBE", "DESC", "LIMIT", + "ORDER", "BY", "GROUP", "HAVING", "AS", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", + "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "EXISTS", "CASE", "WHEN", "THEN", "ELSE", + "END", + ]; + + // Find the word being completed + let word_start = line[..pos] + .rfind(|c: char| c.is_whitespace() || "(),;".contains(c)) + .map(|i| i + 1) + .unwrap_or(0); + + let partial = &line[word_start..pos]; + let partial_upper = partial.to_uppercase(); + + // Add SQL keywords + for keyword in keywords { + if keyword.starts_with(&partial_upper) { + candidates.push(Pair { + display: keyword.to_string(), + replacement: keyword.to_string(), + }); + } + } + + // Add table names + if let Ok(tables) = self.table_names.lock() { + for table in tables.iter() { + if table.to_lowercase().starts_with(&partial.to_lowercase()) { + candidates.push(Pair { + display: table.clone(), + replacement: table.clone(), + }); + } + } + } + + // Add index names + if let Ok(indices) = self.index_names.lock() { + for index in indices.iter() { + if index.to_lowercase().starts_with(&partial.to_lowercase()) { + candidates.push(Pair { + display: index.clone(), + replacement: index.clone(), + }); + } + } + } + + Ok((word_start, candidates)) + } +} + +impl Hinter for LanceDBCompleter { + type Hint = String; + + fn hint(&self, _line: &str, _pos: usize, _ctx: &rustyline::Context<'_>) -> Option { + None + } +} + +impl Highlighter for LanceDBCompleter {} +impl Validator for LanceDBCompleter {} +impl Helper for LanceDBCompleter {} + +/// Unwrap one of a listing's per-table requests, reporting failure instead of +/// degrading silently. +/// +/// The `\d`-family listings issue several requests per table and render a blank +/// cell for each one that fails, which reads as "this table has no stats" +/// rather than "this request failed". The error carries the request ID the +/// query node logged, so this warning is what makes such a failure traceable. +fn or_warn(result: std::result::Result, subject: &str) -> Option { + match result { + Ok(value) => Some(value), + Err(e) => { + eprintln!("warning: {subject} failed: {e}"); + None + } + } +} + +/// Point `executor` at the Flight SQL address `sql_addr`, preserving its +/// scheme so an `https://`/`grpcs://` address dials TLS. Returns the URL that +/// was dialed along with any connection error, so the caller can report both +/// endpoint failures together. +/// +/// Split out of `connect_internal` so the address-to-transport handoff is +/// reachable without a live REST endpoint. +async fn connect_sql_endpoint( + executor: &mut S, + sql_addr: &str, + database: &str, +) -> Result<(String, Option)> { + let url = parse_grpc_endpoint(sql_addr, DEFAULT_SQL_PORT)?.url(); + executor.set_database(database.to_string()); + let err = executor.connect(&url).await.err(); + Ok((url, err)) +} + +fn connection_failure_message(endpoint: &str, protocol: &str, error: &anyhow::Error) -> String { + // Include the source chain: transport errors often hide the useful cause + // (connection refused, DNS, TLS, etc.) behind a generic outer message. + format!("lancedb: connection failed: {endpoint} ({protocol}): {error:#}") +} + +/// HTTP failures are fatal; SQL-only failures allow metadata commands. +fn report_connection_failures( + sql: Option<(&str, &anyhow::Error)>, + http: Option<(&str, &anyhow::Error)>, +) { + for (protocol, failure) in [("sql", sql), ("http", http)] { + if let Some((endpoint, error)) = failure { + eprintln!("{}", connection_failure_message(endpoint, protocol, error)); + } + } + + if sql.is_none() && http.is_none() { + return; + } + + if http.is_some() { + std::process::exit(1); + } + + eprintln!("lancedb: SQL queries will not work, but metadata commands are available"); +} + +// Helper function to run async operations with Ctrl+C cancellation +async fn run_with_cancellation(operation: F) -> Result> +where + F: std::future::Future>, +{ + tokio::select! { + result = operation => { + match result { + Ok(value) => Ok(Some(value)), + Err(e) => Err(e), + } + } + _ = signal::ctrl_c() => { + println!("\n^C"); + Ok(None) + } + } +} + +/// Default interval for `\watch` when none is given (matches psql). +const DEFAULT_WATCH_INTERVAL_SECS: f64 = 2.0; + +/// Smallest accepted `\watch` interval. A positive-but-tiny value (e.g. +/// `1e-20`, or `1e-6` which converts to a nonzero 1us) passes a bare `> 0.0` +/// check yet leaves effectively no delay between runs, spinning the loop and +/// hammering the server; anything below this floor is rejected. +const MIN_WATCH_INTERVAL_SECS: f64 = 0.1; + +/// Whether a metacommand is something `\watch` should repeat. The `\d*` +/// listing/describe family (`\d`, `\di`, `\dt`, ...) qualifies; `\watch` +/// itself and one-shot commands (`\c`, `\e`, `\q`, `\?`) do not. +fn is_watchable_metacommand(cmd: &str) -> bool { + // Exact-match the recognized `\d`-family verbs, not just a `\d` prefix: + // an unrecognized `\d`-prefixed typo (`\dfoo`) is rejected by + // `handle_metacommand` yet would otherwise clobber `last_command`. + // Only the first token matters, so `\d TABLE` / `\di INDEX` still match. + matches!( + cmd.split_whitespace().next(), + Some("\\d" | "\\d+" | "\\dt" | "\\dt+" | "\\di" | "\\di+" | "\\dj" | "\\dj+") + ) +} + +/// The `SHOW JOBS` statement behind `\dj [TABLE]`. The argument is a literal +/// table name, so it is quoted: a name need not be a bare SQL identifier. +fn show_jobs_statement(table: Option<&str>) -> String { + match table { + Some(table) => format!("SHOW JOBS FOR \"{}\"", table.replace('"', "\"\"")), + None => "SHOW JOBS".to_string(), + } +} + +/// Validate a `\watch` interval in seconds to a `Duration`, or `None` if it is +/// unusable. `try_from_secs_f64` rejects negative, NaN, non-finite ("inf"), and +/// `Duration`-overflowing ("1e300") inputs (so the sleep can't panic); the floor +/// then rejects zero and positive-but-tiny values that would leave no real delay +/// between runs. See [`MIN_WATCH_INTERVAL_SECS`]. +fn watch_interval(secs: f64) -> Option { + let min = Duration::from_secs_f64(MIN_WATCH_INTERVAL_SECS); + Duration::try_from_secs_f64(secs).ok().filter(|d| *d >= min) +} + +/// Parsed `\watch` arguments: how often to repeat, and what to repeat. +struct WatchArgs { + interval: Duration, + /// Inline statement/listing to watch, or `None` to re-run the last command. + target: Option, +} + +/// Parse the argument string after `\watch` into an interval and an optional +/// inline target. A first token that parses as a number is always treated as +/// the interval (real SQL never starts with a bare number), so it is validated +/// as one; anything else means the whole string is the inline target. Thus +/// `\watch` / `\watch 5` re-run the last command, while `\watch SELECT ...` and +/// `\watch 5 SELECT ...` watch that statement (at the default / given interval). +fn parse_watch_args(args: &str) -> Result { + let args = args.trim(); + let default = Duration::from_secs_f64(DEFAULT_WATCH_INTERVAL_SECS); + if args.is_empty() { + return Ok(WatchArgs { + interval: default, + target: None, + }); + } + + let mut it = args.splitn(2, char::is_whitespace); + let first = it.next().unwrap_or(""); + let rest = it.next().unwrap_or("").trim(); + + if let Ok(secs) = first.parse::() { + let interval = watch_interval(secs).ok_or_else(|| { + format!( + "Invalid interval '{first}' (must be a number >= {MIN_WATCH_INTERVAL_SECS}s). \ + Usage: \\watch [seconds] [statement]" + ) + })?; + return Ok(WatchArgs { + interval, + target: (!rest.is_empty()).then(|| rest.to_string()), + }); + } + + Ok(WatchArgs { + interval: default, + target: Some(args.to_string()), + }) +} + +/// Sets `NO_PAGER` for as long as it is alive, restoring the prior value on +/// drop. `should_use_pager` already honours `NO_PAGER`, so this disables +/// paging during a `\watch` loop. Restored even when the loop's future is +/// dropped by Ctrl-C, because the guard is a live local at the await point. +struct NoPagerGuard { + prev: Option, +} + +impl NoPagerGuard { + fn new() -> Self { + let prev = std::env::var("NO_PAGER").ok(); + std::env::set_var("NO_PAGER", "1"); + Self { prev } + } +} + +impl Drop for NoPagerGuard { + fn drop(&mut self) { + match &self.prev { + Some(v) => std::env::set_var("NO_PAGER", v), + None => std::env::remove_var("NO_PAGER"), + } + } +} + +/// Check if output should use a pager +fn should_use_pager(output: &str) -> bool { + if std::env::var("NO_PAGER").is_ok() { + return false; + } + + let term_height = termsize::get().map(|size| size.rows as usize).unwrap_or(24); + let line_count = output.lines().count(); + + line_count > (term_height * 80 / 100) +} + +/// Find an appropriate pager +fn find_pager() -> Option { + if let Ok(pager) = std::env::var("PAGER") { + if !pager.is_empty() { + return Some(pager); + } + } + + let pagers = ["less", "more", "bat"]; + for pager in &pagers { + if which::which(pager).is_ok() { + return Some(pager.to_string()); + } + } + + None +} + +/// Leading whitespace and comments, which the server's tokenizer discards +/// before matching a statement's keywords. +fn strip_leading_trivia(query: &str) -> &str { + let mut rest = query.trim_start(); + loop { + let remainder = if let Some(after) = rest.strip_prefix("--") { + after.find('\n').map_or("", |end| &after[end + 1..]) + } else if let Some(after) = rest.strip_prefix("/*") { + after.find("*/").map_or("", |end| &after[end + 2..]) + } else { + return rest; + }; + rest = remainder.trim_start(); + } +} + +/// Rows of a `SHOW JOBS` listing the interactive shell will print. The registry +/// can hold far more jobs than anyone reads at a terminal, and the server +/// returns the listing in one shot with no cursor to resume from, so the table +/// rendering cuts the display and points at ways to narrow the listing or +/// export every row. +const MAX_DISPLAYED_JOB_ROWS: usize = 1000; + +/// Ready a `SHOW JOBS` listing for display, returning it with any notice to +/// print after the formatted output. +/// +/// The `job_id` marking applies everywhere: it is the input to `CANCEL JOB`, +/// so it has to be copyable rather than readable. The row cut applies only to +/// the interactive table rendering -- CSV/JSON/NDJSON and one-shot invocations +/// are read by programs, which need every row and nothing but the payload. +fn prepare_show_jobs_result( + result: SqlResult, + interactive_table: bool, +) -> (SqlResult, Option) { + let total = crate::formatter::row_count(&result); + let cut = interactive_table && total > MAX_DISPLAYED_JOB_ROWS; + + let notice = cut.then(|| { + format!( + "\nShowing the first {MAX_DISPLAYED_JOB_ROWS} of {total} jobs. Narrow the \ + listing with SHOW JOBS FOR , or use lancedb sql --command 'SHOW JOBS' \ + --format json to retrieve all rows.\n" + ) + }); + let result = if cut { + crate::formatter::take_rows(result, MAX_DISPLAYED_JOB_ROWS) + } else { + result + }; + ( + crate::formatter::mark_no_truncate(result, &["job_id"]), + notice, + ) +} + +/// The first two keywords of `query`, matched on leading keywords rather than +/// a raw prefix so that `SHOW JOBS` and a comment-prefixed query both match +/// while `SHOW JOBSX` does not. +fn leading_keywords(query: &str) -> Option<(String, String)> { + let mut words = strip_leading_trivia(query) + .trim_end_matches(|c: char| c.is_whitespace() || c == ';') + .split_whitespace(); + let first = words.next()?.to_ascii_uppercase(); + let second = words.next()?.to_ascii_uppercase(); + Some((first, second)) +} + +/// Whether `query` is a `SHOW JOBS` statement. +fn is_show_jobs(query: &str) -> bool { + leading_keywords(query).is_some_and(|(first, second)| first == "SHOW" && second == "JOBS") +} + +/// Columns of a statement's result that a reader copies rather than reads +/// (identifiers), which the table view must not abbreviate. +fn copyable_columns(query: &str) -> &'static [&'static str] { + match leading_keywords(query) { + Some((first, second)) if first == "CANCEL" && second == "JOB" => &["job_id"], + // A Function version is the argument of the next statement a reader + // writes, so it has to survive display in full. + Some((first, second)) if first == "SHOW" && second == "FUNCTIONS" => &["version"], + Some((first, second)) if first == "SHOW" && second == "ERRORS" => { + &["job_id", "function_version"] + } + Some((first, second)) + if first == "REFRESH" && (second == "COLUMN" || second == "MATERIALIZED") => + { + &["job_id"] + } + _ => &[], + } +} + +/// Display output with pager if needed +fn display_with_pager(output: &str, interactive_mode: bool) -> Result<()> { + if !interactive_mode || !should_use_pager(output) { + // Just print directly + print!("{}", output); + io::stdout().flush()?; + return Ok(()); + } + + // Find pager + let Some(pager_cmd) = find_pager() else { + // No pager available, just print + print!("{}", output); + io::stdout().flush()?; + return Ok(()); + }; + + // Set up pager process + let mut cmd = Command::new(&pager_cmd); + + // If using less, add -FRX flags: + // -F: quit if one screen + // -R: raw control chars (for colors) + // -X: no init (don't clear screen) + if pager_cmd.contains("less") { + cmd.arg("-FRX"); + } + + cmd.stdin(Stdio::piped()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + + // Spawn pager + let mut child = cmd.spawn()?; + + // Write output to pager + if let Some(mut stdin) = child.stdin.take() { + // Ignore SIGPIPE errors when pager exits early (e.g., user presses 'q') + let _ = stdin.write_all(output.as_bytes()); + let _ = stdin.flush(); + } + + // Wait for pager to finish + let _ = child.wait(); + + Ok(()) +} + +impl LanceDBClient +where + S: SqlQueryExecutor, +{ + fn prompt(&self) -> String { + match &self.current_database { + Some(db) => format!("[{}]:{}=# ", self.profile_name, db), + None => format!("[{}]:lancedb=# ", self.profile_name), + } + } + + fn continuation_prompt(&self) -> &'static str { + " # " + } + + fn open_editor_for_last_query(&self) -> Result> { + // Use last executed query if available, otherwise empty string + let initial_query = self.last_query.as_deref().unwrap_or(""); + self.open_editor_for_query(initial_query) + } + + fn display_query_lines(&self, query: &str) { + // Display each line of the query as if the user had typed it + for (i, line) in query.lines().enumerate() { + if i == 0 { + println!("{}{}", self.prompt(), line); + } else { + println!("{}{}", self.continuation_prompt(), line); + } + } + } + + fn open_editor_for_query(&self, initial_query: &str) -> Result> { + // Determine which editor to use + let editor = std::env::var("EDITOR") + .or_else(|_| std::env::var("VISUAL")) + .unwrap_or_else(|_| { + for editor in &["vim", "vi", "emacs", "nano", "code"] { + if which::which(editor).is_ok() { + return editor.to_string(); + } + } + "vi".to_string() + }); + + // Create a temporary file with .sql extension + let mut temp_file = NamedTempFile::with_suffix(".sql") + .map_err(|e| anyhow!("Failed to create temporary file: {}", e))?; + + // Write initial query to the file + temp_file + .write_all(initial_query.as_bytes()) + .map_err(|e| anyhow!("Failed to write to temporary file: {}", e))?; + temp_file + .flush() + .map_err(|e| anyhow!("Failed to flush temporary file: {}", e))?; + + // Get the path before we move ownership + let temp_path = temp_file.path().to_path_buf(); + + // Launch the editor + let status = Command::new(&editor) + .arg(&temp_path) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .map_err(|e| anyhow!("Failed to launch editor '{}': {}", editor, e))?; + + if !status.success() { + // Editor exited with error, but we'll still try to read the file + eprintln!("Editor exited with non-zero status"); + } + + // Read the edited content + let edited_content = std::fs::read_to_string(&temp_path) + .map_err(|e| anyhow!("Failed to read edited file: {}", e))?; + + // Check if the content is empty or unchanged + let trimmed = edited_content.trim(); + if trimmed.is_empty() { + return Ok(None); + } + + Ok(Some(edited_content)) + } + + pub async fn new( + database_override: Option, + interactive_mode: bool, + output_format: OutputFormat, + authentication: Authentication, + profile_name: String, + sql_executor: S, + ) -> Result { + // Set up history file + let history_path = if let Some(proj_dirs) = ProjectDirs::from("com", "lancedb", "client") { + let data_dir = proj_dirs.data_dir(); + std::fs::create_dir_all(data_dir)?; + data_dir.join("history.txt") + } else { + PathBuf::from("/tmp/lancedb-history.txt") + }; + + // Create completer + let completer = LanceDBCompleter::new(); + + // Create editor with completer + let mut editor = Editor::with_config( + rustyline::config::Config::builder() + .completion_type(rustyline::config::CompletionType::List) + .build(), + ) + .map_err(|e| anyhow!("Failed to create editor: {}", e))?; + editor.set_helper(Some(completer)); + let _ = editor.load_history(&history_path); + + // Use database override, or system username as default database + let default_database = database_override.unwrap_or_else(|| { + std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_else(|_| "test".to_string()) + }); + + let formatter = ResultFormatter::new(output_format); + + Ok(Self { + editor, + history_path, + sql_executor, + sql_connected: false, + connection: None, + formatter, + current_database: Some(default_database), + connection_info: None, + interactive_mode, + authentication, + last_query: None, + last_command: None, + profile_name, + }) + } + + pub async fn connect(&mut self, conn: &ResolvedConnection) -> Result<()> { + self.connect_internal(conn, true).await + } + + async fn connect_internal( + &mut self, + conn: &ResolvedConnection, + show_message: bool, + ) -> Result<()> { + let sql_addr = &conn.sql_server_url; + let http_addr = &conn.http_server_url; + + // The REST address keeps whichever scheme the user configured, so an + // https:// address stays on TLS all the way to the transport. + let lancedb_uri = parse_endpoint(http_addr, DEFAULT_HTTP_PORT)?.url(); + + // Build the database URI + let db_name = self.current_database.as_deref().unwrap_or("test"); + + // Try both connections and report failures together + let (flight_server, sql_err) = + connect_sql_endpoint(&mut self.sql_executor, sql_addr, db_name).await?; + + let db_uri = format!("db://{}", db_name); + + // lancedb::connect() is lazy, so we create the connection then probe it + // with a table_names call to verify the HTTP endpoint is reachable. + let mut builder = lancedb::connect(&db_uri) + .region("self-hosted") + .host_override(&lancedb_uri); + if let Authentication::ApiKey(api_key) = &self.authentication { + builder = builder.api_key(api_key); + } else { + // The SDK requires an explicit API key even with a header provider. + // An empty key omits x-api-key; client_config supplies the OIDC headers. + builder = builder.api_key(""); + } + if let Some(client_config) = crate::lancedb_client_config(&conn.tls, &self.authentication) { + builder = builder.client_config(client_config); + } + let connection = builder + .execute() + .await + .map_err(|e| anyhow!("Failed to connect to LanceDB: {}", e))?; + + let http_err = connection + .list_tables(&[], ListingOptions::default().page_limit(1)) + .next_page() + .await + .err() + .map(anyhow::Error::from); + + report_connection_failures( + sql_err.as_ref().map(|err| (flight_server.as_str(), err)), + http_err.as_ref().map(|err| (lancedb_uri.as_str(), err)), + ); + + self.connection = Some(Arc::new(connection)); + self.sql_connected = sql_err.is_none(); + self.connection_info = Some(conn.clone()); + + // Only print connection message in interactive mode and when requested + if self.interactive_mode && show_message { + println!("Connected to SQL: {}, HTTP: {}", sql_addr, http_addr); + } + + // Update table name completions + self.update_completions().await; + + Ok(()) + } + + /// Fetch all table names with pagination + async fn table_names(connection: &lancedb::Connection) -> Result> { + let mut all_tables: Vec = connection + .list_tables(&[], ListingOptions::default().page_limit(100)) + .try_collect() + .await + .map_err(|e| anyhow!("Failed to list tables: {}", e))?; + + // The listing resumes from the store's own cursor, so its pages arrive in key order + // rather than name order. Callers here -- `\d`, `\dt`, and completion -- present the + // whole list to a person, and this walk already holds it in memory, so sort it once. + all_tables.sort(); + + Ok(all_tables) + } + + async fn update_completions(&mut self) { + if let Some(connection) = &self.connection { + if let Ok(table_names) = Self::table_names(connection).await { + if let Some(helper) = self.editor.helper_mut() { + helper.update_table_names(table_names.clone()); + + // Also gather all index names (unqualified and qualified) + let mut all_index_names = Vec::new(); + let mut qualified_index_names = Vec::new(); + for table_name in &table_names { + if let Ok(table) = connection.open_table(table_name).execute().await { + if let Ok(indices) = table.list_indices().await { + for index in indices { + qualified_index_names + .push(format!("{}.{}", table_name, index.name)); + all_index_names.push(index.name); + } + } + } + } + helper.update_index_names(all_index_names, qualified_index_names); + } + } + } + } + + pub async fn run_repl(&mut self) -> Result<()> { + println!("{}", LANCEDB_ASCII_ART); + println!("LanceDB CLI v{}", crate::build_version()); + println!("Type \\? for help or \\q to quit\n"); + + let mut multiline_query = String::new(); + let mut in_multiline = false; + + loop { + let prompt_string; + let prompt = if in_multiline { + self.continuation_prompt() + } else { + prompt_string = self.prompt(); + &prompt_string + }; + + match self.editor.readline(prompt) { + Ok(mut line) => { + let trimmed = line.trim(); + + // Skip empty lines + if trimmed.is_empty() { + continue; + } + + // Handle metacommands + if !in_multiline && trimmed.starts_with('\\') { + match run_with_cancellation(self.handle_metacommand(trimmed)).await { + Err(e) => { + eprintln!("ERROR: {}", e); + continue; + } + Ok(None) => { + // Cancelled + continue; + } + Ok(Some(result)) => { + match result { + MetaCommandResult::Handled => { + // Successful metacommand + self.editor.add_history_entry(&line)?; + // Remember listing metacommands so + // \watch can repeat them. + if is_watchable_metacommand(trimmed) { + self.last_command = Some(trimmed.to_string()); + } + continue; + } + MetaCommandResult::ReplaceInput(new_input) => { + line = new_input; + self.display_query_lines(&line); + // Fall through to process the new input + } + } + } + } + } + + // Now process line normally (either from readline or editor) + let trimmed = line.trim(); + + // Skip empty lines (in case editor returned empty) + if trimmed.is_empty() { + continue; + } + + // Add to history + self.editor.add_history_entry(&line)?; + + // Normal query accumulation + if in_multiline { + multiline_query.push('\n'); + } + multiline_query.push_str(&line); + + // Check if query is complete (ends with semicolon) + if multiline_query.trim().ends_with(';') { + // Store the complete query with semicolon before removing it + self.last_query = Some(multiline_query.clone()); + + // Remove the semicolon + multiline_query.pop(); + // Remember the query so \watch can repeat it. + self.last_command = Some(multiline_query.clone()); + + // Execute the query + match run_with_cancellation(self.execute_query(&multiline_query)).await { + Ok(Some(())) => {} + Ok(None) => {} + Err(e) => { + eprintln!("ERROR: {}", e); + } + } + + // Reset for next query + multiline_query.clear(); + in_multiline = false; + } else { + in_multiline = true; + } + } + Err(ReadlineError::Interrupted) => { + println!("^C"); + multiline_query.clear(); + in_multiline = false; + } + Err(ReadlineError::Eof) => { + println!("\\q"); + break; + } + Err(err) => { + eprintln!("Error: {:?}", err); + break; + } + } + } + + // Save history + self.editor.save_history(&self.history_path)?; + Ok(()) + } + + async fn execute_query(&mut self, query: &str) -> Result<()> { + let start_time = Instant::now(); + + // Check if this is a DDL statement and get the command type + let ddl_command = { + let trimmed = query.trim().to_uppercase(); + if trimmed.starts_with("CREATE TABLE") { + Some("CREATE TABLE") + } else if trimmed.starts_with("CREATE INDEX") { + Some("CREATE INDEX") + } else if trimmed.starts_with("CREATE VIEW") { + Some("CREATE VIEW") + } else if trimmed.starts_with("CREATE") { + Some("CREATE") + } else if trimmed.starts_with("ALTER TABLE") { + Some("ALTER TABLE") + } else if trimmed.starts_with("ALTER INDEX") { + Some("ALTER INDEX") + } else if trimmed.starts_with("ALTER VIEW") { + Some("ALTER VIEW") + } else if trimmed.starts_with("ALTER") { + Some("ALTER") + } else if trimmed.starts_with("DROP TABLE") { + Some("DROP TABLE") + } else if trimmed.starts_with("DROP INDEX") { + Some("DROP INDEX") + } else if trimmed.starts_with("DROP VIEW") { + Some("DROP VIEW") + } else if trimmed.starts_with("DROP") { + Some("DROP") + } else { + None + } + }; + + if !self.sql_connected { + return Err(anyhow!("SQL endpoint is not connected")); + } + + // Execute the query + let result = self.sql_executor.execute_query(query).await?; + + // Display output based on whether it's DDL or not + if let Some(command) = ddl_command { + // For DDL, just echo the command type + println!("{}", command); + } else { + // For non-DDL, format and display the result. + let (result, notice) = if is_show_jobs(query) { + let interactive_table = self.interactive_mode + && matches!(self.formatter.output_format(), OutputFormat::Table); + prepare_show_jobs_result(result, interactive_table) + } else { + ( + crate::formatter::mark_no_truncate(result, copyable_columns(query)), + None, + ) + }; + let query_result = QueryResult::SqlResult(result); + let mut output = self.formatter.format(&query_result)?; + // Appended rather than printed ahead of the payload, so the notice + // travels through the pager with the table it describes. + if let Some(notice) = notice { + output.push_str(¬ice); + } + display_with_pager(&output, self.interactive_mode)?; + } + + let elapsed = start_time.elapsed(); + if self.interactive_mode { + println!("Time: {:.3} ms", elapsed.as_secs_f64() * 1000.0); + } + + Ok(()) + } + + async fn handle_metacommand(&mut self, command: &str) -> Result { + let parts: Vec<&str> = command.split_whitespace().collect(); + if parts.is_empty() { + return Ok(MetaCommandResult::Handled); + } + + let cmd = parts[0]; + match cmd { + "\\?" | "\\h" | "\\help" | "help" => { + self.print_help(); + } + "\\d" | "\\d+" => { + let detailed = cmd.ends_with('+'); + if parts.len() < 2 { + // \d without argument lists tables + self.list_tables(detailed).await?; + } else { + // \d
describes the table + self.describe_table(parts[1]).await?; + } + } + "\\di" | "\\di+" => { + let detailed = cmd.ends_with('+'); + if parts.len() < 2 { + // \di without argument lists all indexes + self.list_all_indexes(detailed).await?; + } else { + // \di describes the specific index + self.describe_index(parts[1], detailed).await?; + } + } + "\\dt" | "\\dt+" => { + let detailed = cmd.ends_with('+'); + self.list_tables(detailed).await?; + } + // `\dj+` is a synonym for now. + "\\dj" | "\\dj+" => { + let statement = show_jobs_statement(parts.get(1).copied()); + self.execute_query(&statement).await?; + } + "\\watch" => { + let args = command.strip_prefix("\\watch").unwrap_or(""); + let spec = match parse_watch_args(args) { + Ok(spec) => spec, + Err(msg) => { + eprintln!("{msg}"); + return Ok(MetaCommandResult::Handled); + } + }; + + // Resolve what to repeat: an inline statement/listing if given, + // otherwise the last command. An inline `\`-target must be a + // repeatable `\d`-family listing -- this also rejects `\watch + // \watch ...`, which would otherwise recurse. + let target = match spec.target { + Some(t) if t.starts_with('\\') => { + if !is_watchable_metacommand(&t) { + eprintln!( + "\\watch: cannot watch '{t}'. Watch a SQL query or a \\d-family listing." + ); + return Ok(MetaCommandResult::Handled); + } + t + } + // Drop a trailing ';' so inline SQL matches the re-run form. + Some(t) => t.trim_end_matches(';').trim_end().to_string(), + None => { + let Some(last) = self.last_command.clone() else { + eprintln!("\\watch: no previous query or listing to watch."); + return Ok(MetaCommandResult::Handled); + }; + last + } + }; + self.watch_loop(target, spec.interval).await?; + } + "\\c" => { + if parts.len() < 2 { + match &self.current_database { + Some(db) => println!("You are now connected to database \"{}\".", db), + None => println!("You are not connected to a database."), + } + } else { + let new_db = parts[1].to_string(); + + // Check if we're already connected to this database + if let Some(current_db) = &self.current_database { + if current_db == &new_db { + println!("You are now connected to database \"{}\".", new_db); + return Ok(MetaCommandResult::Handled); + } + } + + let old_db = self.current_database.clone(); + self.current_database = Some(new_db.clone()); + + // If we're connected, reconnect with the new database + if let Some(conn) = &self.connection_info.clone() { + match self.connect_internal(conn, false).await { + Ok(_) => { + println!("You are now connected to database \"{}\".", new_db); + } + Err(e) => { + eprintln!("connection to database \"{}\" failed: {}", new_db, e); + // Restore the previous database on failure + self.current_database = old_db; + } + } + } else { + println!("You are now connected to database \"{}\".", new_db); + } + } + } + "\\e" => { + // Open editor and return the edited query + match self.open_editor_for_last_query() { + Ok(Some(edited)) => { + return Ok(MetaCommandResult::ReplaceInput(edited.trim().to_string())); + } + Ok(None) => { + // User cancelled or cleared editor + return Ok(MetaCommandResult::Handled); + } + Err(e) => { + return Err(e); + } + } + } + "\\q" | "\\quit" => { + println!("Use Ctrl+D to exit"); + } + _ => { + eprintln!("Invalid command \\{}. Try \\? for help.", &cmd[1..]); + } + } + Ok(MetaCommandResult::Handled) + } + + /// Re-run `command` (a SQL query or a `\d*` listing) every `interval` + /// seconds, clearing the screen between runs, until interrupted with + /// Ctrl-C. The REPL wraps this in `run_with_cancellation`, so Ctrl-C + /// drops this future and returns to the prompt; `NoPagerGuard` restores + /// the pager setting on that drop. Paging is suppressed for the duration + /// so a long listing doesn't block the loop waiting on the pager. + async fn watch_loop(&mut self, command: String, interval: Duration) -> Result<()> { + let _no_pager = NoPagerGuard::new(); + loop { + // Clear screen, cursor home, then a header line. + print!("\x1b[2J\x1b[H"); + println!( + "Watch every {:.1}s -- {} (Ctrl-C to stop)\n", + interval.as_secs_f64(), + command + ); + let _ = io::stdout().flush(); + + let result = if command.starts_with('\\') { + // Recursion (handle_metacommand -> watch_loop -> + // handle_metacommand) needs boxing to keep the future sized. + Box::pin(self.handle_metacommand(&command)) + .await + .map(|_| ()) + } else { + Box::pin(self.execute_query(&command)).await + }; + if let Err(e) = result { + eprintln!("ERROR: {}", e); + } + let _ = io::stdout().flush(); + + tokio::time::sleep(interval).await; + } + } + + fn print_help(&self) { + println!("\nGeneral"); + println!(" \\h, \\?, help show this help"); + println!(" \\q quit lancedb (or use Ctrl+D)"); + println!("\nEditing"); + println!(" \\e edit query buffer in external editor"); + println!("\nInformational"); + println!(" \\d list tables (simple)"); + println!(" \\d+ list tables with stats (detailed)"); + println!(" \\d TABLE describe table (show columns and indexes)"); + println!(" \\di list all indexes (simple)"); + println!(" \\di+ list all indexes with stats (detailed)"); + println!(" \\di [TABLE.]INDEX describe index"); + println!(" \\dt list tables (simple)"); + println!(" \\dt+ list tables with stats (detailed)"); + println!(" \\dj [TABLE] list jobs, optionally only those for TABLE"); + println!(" \\dj+ [TABLE] same as \\dj"); + println!("\nRepeat"); + println!(" \\watch [SEC] re-run the last query or listing every SEC seconds"); + println!( + " \\watch [SEC] STMT watch STMT (a SQL query or \\d-family listing) instead" + ); + println!(" (default 2s; Ctrl-C to stop)"); + println!("\nConnection"); + println!(" \\c [DBNAME] connect to database"); + println!("\nSQL"); + println!(" SQL queries end with semicolon (;)"); + println!(" Use Ctrl+C to cancel current input"); + println!(" Use Ctrl+D to exit"); + println!("\nOutput"); + println!(" Large results are automatically displayed in pager"); + println!(" Set NO_PAGER=1 to disable pager"); + println!(); + } + + async fn list_tables(&mut self, detailed: bool) -> Result<()> { + let connection = self + .connection + .as_ref() + .ok_or_else(|| anyhow!("Not connected to LanceDB"))?; + + let tables = Self::table_names(connection).await?; + + if tables.is_empty() { + println!("Did not find any relations."); + return Ok(()); + } + + if detailed { + // For \d+ and \dt+, we need to show detailed table information with stats + // Fetch table info for each table + let mut table_infos = Vec::new(); + for table_name in &tables { + let Some(table) = or_warn( + connection.open_table(table_name).execute().await, + &format!("opening table '{table_name}'"), + ) else { + table_infos.push(TableInfo { + name: table_name.clone(), + schema: Arc::new(Schema::empty()), + row_count: None, + size_bytes: None, + num_indices: None, + num_fragments: None, + }); + continue; + }; + + let schema = or_warn( + table.schema().await, + &format!("reading schema for table '{table_name}'"), + ) + .unwrap_or_else(|| Arc::new(Schema::empty())); + let stats = or_warn( + table.stats().await, + &format!("reading stats for table '{table_name}'"), + ); + let row_count = match stats.as_ref() { + Some(s) => Some(s.num_rows as u64), + None => or_warn( + table.count_rows(None).await, + &format!("counting rows for table '{table_name}'"), + ) + .map(|n| n as u64), + }; + + table_infos.push(TableInfo { + name: table_name.clone(), + schema, + row_count, + size_bytes: stats.as_ref().map(|s| s.total_bytes as u64), + num_indices: stats.as_ref().map(|s| s.num_indices as u64), + num_fragments: stats + .as_ref() + .map(|s| s.fragment_stats.num_fragments as u64), + }); + } + + // Use the formatter to display the results + let result = QueryResult::TableListDetailed(table_infos); + let output = self.formatter.format(&result)?; + display_with_pager(&output, self.interactive_mode)?; + } else { + // Simple list without stats + let result = QueryResult::TableList(tables); + let output = self.formatter.format(&result)?; + display_with_pager(&output, self.interactive_mode)?; + } + Ok(()) + } + + async fn describe_table(&mut self, table_name: &str) -> Result<()> { + let connection = self + .connection + .as_ref() + .ok_or_else(|| anyhow!("Not connected to LanceDB"))?; + + let table = connection + .open_table(table_name) + .execute() + .await + .map_err(|e| anyhow!("Failed to open table '{}': {}", table_name, e))?; + + let schema = table + .schema() + .await + .map_err(|e| anyhow!("Failed to get schema: {}", e))?; + + let stats = table.stats().await.ok(); + let row_count = if let Some(s) = stats.as_ref() { + Some(s.num_rows as u64) + } else { + table.count_rows(None).await.ok().map(|n| n as u64) + }; + + // Get index information + let indices = table.list_indices().await.ok(); + let mut index_info = Vec::new(); + if let Some(indices) = indices { + for index in indices { + let mut info = IndexInfo { + name: index.name.clone(), + table_name: table_name.to_string(), + index_type: index.index_type.to_string(), + columns: index.columns.clone(), + }; + + // Try to get distance type for vector indices + if let Ok(Some(stats)) = table.index_stats(&index.name).await { + if let Some(distance_type) = stats.distance_type { + info.index_type = + format!("{} distance={}", index.index_type, distance_type); + } + } + + index_info.push(info); + } + } + + let table_info = TableInfo { + name: table_name.to_string(), + schema, + row_count, + size_bytes: stats.as_ref().map(|s| s.total_bytes as u64), + num_indices: stats.as_ref().map(|s| s.num_indices as u64), + num_fragments: stats + .as_ref() + .map(|s| s.fragment_stats.num_fragments as u64), + }; + + let result = QueryResult::TableInfoWithIndices(table_info, index_info); + let output = self.formatter.format(&result)?; + display_with_pager(&output, self.interactive_mode)?; + Ok(()) + } + + async fn list_all_indexes(&mut self, detailed: bool) -> Result<()> { + let connection = self + .connection + .as_ref() + .ok_or_else(|| anyhow!("Not connected to LanceDB"))?; + + let tables = Self::table_names(connection).await?; + + // Each opened table is kept alongside its indexes because the `\di+` + // pass below needs the same handle: reopening per index would issue one + // request per index where one per table suffices. + let mut per_table = Vec::new(); + for table_name in tables { + // A failure here drops the table from the listing entirely, so it + // has to be reported: an incomplete index list is indistinguishable + // from a table that genuinely has no indexes. + let Some(table) = or_warn( + connection.open_table(&table_name).execute().await, + &format!("opening table '{table_name}'"), + ) else { + continue; + }; + let Some(indices) = or_warn( + table.list_indices().await, + &format!("listing indexes for table '{table_name}'"), + ) else { + continue; + }; + let infos: Vec = indices + .into_iter() + .map(|index| IndexInfo { + name: index.name, + table_name: table_name.clone(), + index_type: index.index_type.to_string(), + columns: index.columns, + }) + .collect(); + per_table.push((table_name, table, infos)); + } + + if detailed { + // Fetch stats for each index + let mut indices_with_stats = Vec::new(); + for (table_name, table, infos) in per_table { + for index in infos { + // `Ok(None)` is an index that legitimately has no stats; + // only `Err` is a failed request. Collapsing the two is what + // makes a failure read as an index without statistics. + let stats = or_warn( + table.index_stats(&index.name).await, + &format!( + "reading stats for index '{}' on table '{}'", + index.name, table_name + ), + ) + .flatten() + .map(|s| IndexStats { + num_indexed_rows: s.num_indexed_rows as u64, + num_unindexed_rows: s.num_unindexed_rows as u64, + distance_type: s.distance_type.map(|dt| dt.to_string()), + }); + indices_with_stats.push((index, stats)); + } + } + let result = QueryResult::IndexListDetailed(indices_with_stats); + let output = self.formatter.format(&result)?; + display_with_pager(&output, self.interactive_mode)?; + } else { + let all_indices: Vec = per_table + .into_iter() + .flat_map(|(_, _, infos)| infos) + .collect(); + let result = QueryResult::IndexList(all_indices); + let output = self.formatter.format(&result)?; + display_with_pager(&output, self.interactive_mode)?; + } + Ok(()) + } + + async fn describe_index(&mut self, index_name: &str, detailed: bool) -> Result<()> { + let connection = self + .connection + .as_ref() + .ok_or_else(|| anyhow!("Not connected to LanceDB"))?; + + // Support qualified table.index_name syntax + if let Some((table_name, idx_name)) = index_name.split_once('.') { + let table = connection + .open_table(table_name) + .execute() + .await + .map_err(|_| anyhow!("Table \"{}\" not found.", table_name))?; + let indices = table.list_indices().await?; + for index in indices { + if index.name == idx_name { + Self::print_index_details(&index, table_name, detailed, &table).await; + return Ok(()); + } + } + eprintln!( + "Did not find index \"{}\" on table \"{}\".", + idx_name, table_name + ); + return Ok(()); + } + + // Unqualified: search all tables + let tables = Self::table_names(connection).await?; + let mut found_count = 0u32; + + for table_name in &tables { + if let Ok(table) = connection.open_table(table_name).execute().await { + if let Ok(indices) = table.list_indices().await { + for index in &indices { + if index.name == index_name { + if found_count == 0 { + Self::print_index_details(index, table_name, detailed, &table) + .await; + } + found_count += 1; + } + } + } + } + } + + if found_count == 0 { + eprintln!("Did not find any index named \"{}\".", index_name); + } else if found_count > 1 { + eprintln!( + "\nNote: {} indexes named \"{}\" exist across different tables. \ + Use \\di TABLE.INDEX to specify.", + found_count, index_name + ); + } + Ok(()) + } + + async fn print_index_details( + index: &lancedb::index::IndexConfig, + table_name: &str, + detailed: bool, + table: &lancedb::Table, + ) { + println!("Index \"{}\"", index.name); + println!(" Table: {}", table_name); + println!(" Type: {}", index.index_type); + println!(" Columns: {}", index.columns.join(", ")); + + if detailed { + if let Ok(Some(stats)) = table.index_stats(&index.name).await { + println!("\nStatistics:"); + println!(" Indexed rows: {}", stats.num_indexed_rows); + println!(" Unindexed rows: {}", stats.num_unindexed_rows); + if let Some(dist) = stats.distance_type { + println!(" Distance type: {}", dist); + } + } + } + } +} + +// Compatibility functions for tests that use writer +pub async fn run_client_with_writer( + conn: &ResolvedConnection, + writer: Option>, +) -> anyhow::Result<()> { + // If writer is provided, we need different behavior for tests + // For now, just ignore the writer and use the new implementation + let _ = writer; + + // Create concrete implementations + let sql_executor = + SdkSqlExecutor::new_with_authentication(String::new(), conn.authentication.clone()) + .with_tls(conn.tls.clone()); + + let mut client = LanceDBClient::new( + Some(conn.database.clone()), + true, + OutputFormat::Table, + conn.authentication.clone(), + conn.profile_name.clone(), + sql_executor, + ) + .await?; + + if !conn.sql_server_url.is_empty() { + client.connect(conn).await?; + } + + client.run_repl().await +} + +pub async fn run_client_command_with_writer( + conn: &ResolvedConnection, + command: String, + format: String, + writer: Option>, +) -> anyhow::Result<()> { + // If writer is provided, we need to capture output + let _ = writer; + + let output_format = format.parse::()?; + + // Create concrete implementations + let sql_executor = + SdkSqlExecutor::new_with_authentication(String::new(), conn.authentication.clone()) + .with_tls(conn.tls.clone()); + let mut client = LanceDBClient::new( + Some(conn.database.clone()), + false, + output_format, + conn.authentication.clone(), + conn.profile_name.clone(), + sql_executor, + ) + .await?; + + if !conn.sql_server_url.is_empty() { + client.connect(conn).await?; + } + + let trimmed = command.trim(); + if trimmed.starts_with('\\') { + client.handle_metacommand(trimmed).await?; + } else { + client.execute_query(&command).await?; + } + + Ok(()) +} + +/// SQL lexical rules for scripts. Comments, including MySQL-style hints, must +/// remain comments when deciding which statements to submit independently. +#[derive(Debug, Default)] +struct ScriptDialect(GenericDialect); + +impl Dialect for ScriptDialect { + fn dialect(&self) -> std::any::TypeId { + self.0.dialect() + } + + fn is_identifier_start(&self, ch: char) -> bool { + self.0.is_identifier_start(ch) + } + + fn is_identifier_part(&self, ch: char) -> bool { + self.0.is_identifier_part(ch) + } + + fn is_delimited_identifier_start(&self, ch: char) -> bool { + self.0.is_delimited_identifier_start(ch) + } + + fn supports_nested_comments(&self) -> bool { + true + } + + fn supports_multiline_comment_hints(&self) -> bool { + false + } + + fn supports_string_escape_constant(&self) -> bool { + true + } + + fn supports_unicode_string_literal(&self) -> bool { + true + } +} + +/// Split only on SQL delimiter tokens, retaining the original spelling of each +/// statement. Tokenize the whole script before executing any of its statements. +fn split_sql_script(script: &str) -> Result> { + let tokens = Tokenizer::new(&ScriptDialect::default(), script) + .tokenize_with_location() + .context("Failed to tokenize SQL script")?; + // Token locations count characters rather than bytes. Walk the source once + // to translate delimiter locations without changing Unicode or line endings. + let mut offsets = script + .char_indices() + .scan(Location::new(1, 1), |location, (offset, ch)| { + let start = *location; + if ch == '\n' { + location.line += 1; + location.column = 1; + } else { + location.column += 1; + } + Some((start, offset)) + }); + let mut commands = Vec::new(); + let mut start = 0; + let mut has_statement = false; + for token in tokens { + match token.token { + Token::SemiColon => { + let (_, end) = offsets + .find(|(location, _)| *location == token.span.start) + .context("SQL delimiter location is outside the script")?; + if has_statement { + commands.push(script[start..end].trim()); + } + start = end + 1; + has_statement = false; + } + Token::Whitespace(_) => {} + _ => has_statement = true, + } + } + if has_statement { + commands.push(script[start..].trim()); + } + Ok(commands) +} + +pub async fn run_client_script_with_writer( + conn: &ResolvedConnection, + script: String, + format: String, + writer: Option>, +) -> anyhow::Result<()> { + let _ = writer; + let commands = split_sql_script(&script)?; + + let output_format = format.parse::()?; + + // Create concrete implementations + let sql_executor = + SdkSqlExecutor::new_with_authentication(String::new(), conn.authentication.clone()) + .with_tls(conn.tls.clone()); + let mut client = LanceDBClient::new( + Some(conn.database.clone()), + false, + output_format, + conn.authentication.clone(), + conn.profile_name.clone(), + sql_executor, + ) + .await?; + + if !conn.sql_server_url.is_empty() { + client.connect(conn).await?; + } + + for command in commands { + if command.starts_with('\\') { + client.handle_metacommand(command).await?; + } else { + client.execute_query(command).await?; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + connect_sql_endpoint, copyable_columns, is_show_jobs, prepare_show_jobs_result, + MAX_DISPLAYED_JOB_ROWS, + }; + use crate::formatter::{row_count, OutputFormat, QueryResult, ResultFormatter}; + use crate::mocks::test_mocks::MockSqlExecutor; + use crate::sql_executor::SqlResult; + + #[tokio::test] + async fn table_names_follows_short_and_empty_pages_and_sorts() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let app = axum::Router::new().fallback( + |axum::extract::Query(query): axum::extract::Query< + std::collections::HashMap, + >| async move { + assert_eq!(query.get("limit").map(String::as_str), Some("100")); + let page = match query.get("page_token").map(String::as_str) { + None => serde_json::json!({"tables": ["zebra"], "page_token": "empty"}), + Some("empty") => serde_json::json!({"tables": [], "page_token": "last"}), + Some("last") => serde_json::json!({"tables": ["apple"], "page_token": ""}), + token => panic!("unexpected page token: {token:?}"), + }; + axum::Json(page) + }, + ); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let connection = lancedb::connect("db://test") + .api_key("test") + .region("self-hosted") + .host_override(&endpoint) + .execute() + .await + .unwrap(); + + let tables = super::LanceDBClient::::table_names(&connection).await; + server.abort(); + assert_eq!(tables.unwrap(), ["apple", "zebra"]); + } + + #[tokio::test] + async fn http_connection_failure_preserves_status() { + for status in [ + axum::http::StatusCode::UNAUTHORIZED, + axum::http::StatusCode::FORBIDDEN, + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let app = axum::Router::new().fallback(move || async move { status }); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let connection = lancedb::connect("db://test") + .api_key("test") + .region("self-hosted") + .host_override(&endpoint) + .execute() + .await + .unwrap(); + let error = connection + .list_tables( + &[], + lancedb::listing::ListingOptions::default().page_limit(1), + ) + .next_page() + .await + .unwrap_err(); + server.abort(); + let message = super::connection_failure_message(&endpoint, "http", &error.into()); + assert!(message.contains(status.as_str()), "{message}"); + assert!(message.contains(&format!("{endpoint} (http)")), "{message}"); + assert!(!message.contains("connection refused"), "{message}"); + } + } + + #[tokio::test] + async fn sql_connection_failure_preserves_transport_cause() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + drop(listener); + let mut executor = super::SdkSqlExecutor::new_with_api_key(String::new(), String::new()); + let (_, error) = connect_sql_endpoint(&mut executor, &endpoint, "test") + .await + .unwrap(); + let message = super::connection_failure_message(&endpoint, "sql", &error.unwrap()); + assert!( + message.to_lowercase().contains("connection refused"), + "{message}" + ); + assert!(message.contains(&format!("{endpoint} (sql)")), "{message}"); + } + + /// The scheme a user configures must survive all the way to the transport; + /// the CLI used to rebuild every address as plaintext http. + #[tokio::test] + async fn sql_endpoint_preserves_the_configured_scheme() { + for (configured, dialed) in [ + ("localhost:10025", "http://localhost:10025"), + ("localhost", "http://localhost:10025"), + ("http://sql.example.com", "http://sql.example.com:80"), + ("https://sql.example.com", "https://sql.example.com:443"), + ( + "https://sql.example.com:10025", + "https://sql.example.com:10025", + ), + ( + "grpc://sql.example.com:10025", + "http://sql.example.com:10025", + ), + ( + "grpcs://sql.example.com:10025", + "https://sql.example.com:10025", + ), + ("grpcs://[fd00::1]:10025", "https://[fd00::1]:10025"), + ] { + let mut executor = MockSqlExecutor::new(); + let (url, err) = connect_sql_endpoint(&mut executor, configured, "analytics") + .await + .unwrap(); + + assert!(err.is_none()); + assert_eq!(url, dialed, "returned url for {configured}"); + assert_eq!( + executor.get_connected_urls(), + vec![dialed.to_string()], + "url handed to the executor for {configured}" + ); + } + } + + #[tokio::test] + async fn sql_endpoint_rejects_an_unusable_address() { + let mut executor = MockSqlExecutor::new(); + assert!( + connect_sql_endpoint(&mut executor, "ftp://sql.example.com", "analytics") + .await + .is_err() + ); + // A rejected address must not reach the transport at all. + assert!(executor.get_connected_urls().is_empty()); + } + + fn jobs(rows: usize) -> SqlResult { + let ids: Vec = (0..rows as i32).collect(); + SqlResult::Query(vec![ + arrow_array::record_batch!(("job_id", Int32, ids)).unwrap() + ]) + } + + #[test] + fn interactive_table_output_is_cut_and_annotated() { + let over = MAX_DISPLAYED_JOB_ROWS + 1; + let (result, notice) = prepare_show_jobs_result(jobs(over), true); + assert_eq!(row_count(&result), MAX_DISPLAYED_JOB_ROWS); + let notice = notice.unwrap(); + assert!(notice.contains(&format!("first {MAX_DISPLAYED_JOB_ROWS} of {over} jobs"))); + assert!(notice.contains("SHOW JOBS FOR
")); + assert!(notice.contains("lancedb sql --command 'SHOW JOBS' --format json")); + + // At or under the cap the listing is whole and says nothing. + let (result, notice) = prepare_show_jobs_result(jobs(MAX_DISPLAYED_JOB_ROWS), true); + assert_eq!(row_count(&result), MAX_DISPLAYED_JOB_ROWS); + assert!(notice.is_none()); + } + + /// A script reading `--format json` needs every row and a payload its + /// parser accepts, so neither the cut nor the notice may reach it. + #[test] + fn programmatic_output_keeps_every_row_and_stays_parsable() { + let over = MAX_DISPLAYED_JOB_ROWS + 1; + let (result, notice) = prepare_show_jobs_result(jobs(over), false); + assert_eq!(row_count(&result), over); + assert!(notice.is_none()); + + for format in [OutputFormat::Json, OutputFormat::Ndjson, OutputFormat::Csv] { + let output = ResultFormatter::new(format) + .format(&QueryResult::SqlResult(jobs(over))) + .unwrap(); + assert!( + !output.contains("Showing the first"), + "{format:?} output must carry no prose" + ); + } + } + + #[test] + fn show_jobs_is_recognized_through_leading_trivia() { + assert!(is_show_jobs("SHOW JOBS")); + assert!(is_show_jobs(" show jobs ;")); + assert!(is_show_jobs("-- list jobs\nSHOW JOBS;")); + assert!(is_show_jobs("/* list jobs */ SHOW JOBS")); + assert!(is_show_jobs("SHOW JOBS FOR t")); + + assert!(!is_show_jobs("SHOW JOBSX")); + assert!(!is_show_jobs("SELECT job_id FROM t")); + // A query that is only a comment names no statement at all. + assert!(!is_show_jobs("-- SHOW JOBS")); + } + + #[test] + fn copyable_columns_follow_the_statement() { + assert_eq!(copyable_columns("SELECT 1"), &[] as &[&str]); + assert_eq!(copyable_columns("cancel job 'x'"), &["job_id"]); + assert_eq!(copyable_columns("/* run */ Cancel Job 'x';"), &["job_id"]); + assert_eq!(copyable_columns("SHOW FUNCTIONS"), &["version"]); + assert_eq!( + copyable_columns("show errors on t"), + &["job_id", "function_version"] + ); + assert_eq!(copyable_columns("REFRESH COLUMN c ON t"), &["job_id"]); + assert_eq!(copyable_columns("REFRESH MATERIALIZED VIEW v"), &["job_id"]); + assert!(!is_show_jobs("/* SHOW JOBS")); + } +} + +#[cfg(test)] +mod watch_tests { + use super::*; + + #[test] + fn d_family_is_watchable() { + for cmd in [ + "\\d", + "\\d+", + "\\dt", + "\\dt+", + "\\di", + "\\di+", + "\\dj", + "\\dj+", + "\\d mytable", + "\\dj mytable", + ] { + assert!(is_watchable_metacommand(cmd), "{cmd} should be watchable"); + } + } + + #[test] + fn dj_maps_to_show_jobs() { + assert_eq!(show_jobs_statement(None), "SHOW JOBS"); + assert_eq!(show_jobs_statement(Some("t")), r#"SHOW JOBS FOR "t""#); + // Names that do not tokenize as a bare identifier; the parser-side + // round trip is pinned by `parses_show_jobs_for_quoted_table`. + assert_eq!( + show_jobs_statement(Some("my-table")), + r#"SHOW JOBS FOR "my-table""# + ); + assert_eq!(show_jobs_statement(Some("a.b")), r#"SHOW JOBS FOR "a.b""#); + assert_eq!( + show_jobs_statement(Some(r#"a"b"#)), + r#"SHOW JOBS FOR "a""b""# + ); + } + + #[test] + fn one_shot_and_watch_are_not_watchable() { + for cmd in [ + "\\watch", + "\\watch 5", + "\\c", + "\\e", + "\\q", + "\\?", + "\\h", + "", + ] { + assert!( + !is_watchable_metacommand(cmd), + "{cmd} should not be watchable" + ); + } + } + + #[test] + fn unrecognized_d_prefixed_commands_are_not_watchable() { + // A `\d`-prefixed typo/unsupported verb is rejected by + // `handle_metacommand`; it must not be treated as watchable, else it + // would clobber `last_command` and make `\watch` loop an error. + for cmd in ["\\dfoo", "\\ds", "\\dn", "\\dd", "\\d++"] { + assert!( + !is_watchable_metacommand(cmd), + "{cmd} should not be watchable" + ); + } + } + + #[test] + fn watch_interval_accepts_only_sane_positive_seconds() { + // Accept normal intervals, including exactly the floor. + assert!(watch_interval(2.0).is_some()); + assert!(watch_interval(0.5).is_some()); + assert!(watch_interval(MIN_WATCH_INTERVAL_SECS).is_some()); + // Reject non-positive, non-finite, and overflowing values (which would + // panic the sleep), plus positive-but-tiny values below the floor that + // would spin the loop with no real delay (e.g. 1e-20 rounds to a + // zero-length sleep; 1e-6 to a nonzero-but-negligible 1us). + for bad in [f64::INFINITY, 1e300, 0.0, -1.0, f64::NAN, 1e-20, 1e-6, 0.05] { + assert!(watch_interval(bad).is_none(), "{bad} should be rejected"); + } + } + + #[test] + fn parse_watch_args_reads_interval_and_optional_statement() { + // (input after `\watch`, expected secs, expected target) + let cases: &[(&str, f64, Option<&str>)] = &[ + ("", 2.0, None), + (" ", 2.0, None), + ("5", 5.0, None), + ("0.5", 0.5, None), + ("SELECT 1", 2.0, Some("SELECT 1")), + ("5 SELECT * FROM t", 5.0, Some("SELECT * FROM t")), + ("2.5 SELECT 1", 2.5, Some("SELECT 1")), + ("\\di+", 2.0, Some("\\di+")), + ("3 \\dt+", 3.0, Some("\\dt+")), + ]; + for (input, secs, target) in cases { + let got = parse_watch_args(input).unwrap_or_else(|e| panic!("{input:?}: {e}")); + assert_eq!( + got.interval, + Duration::from_secs_f64(*secs), + "interval for {input:?}" + ); + assert_eq!(got.target.as_deref(), *target, "target for {input:?}"); + } + } + + #[test] + fn parse_watch_args_rejects_numeric_but_invalid_intervals() { + // A numeric first token is always an interval attempt (never SQL), so + // an unusable one is an error rather than being treated as a statement. + for bad in [ + "0", + "-1", + "inf", + "1e300", + "1e-20", + "0 SELECT 1", + "-2 SELECT 1", + "1e-20 SELECT 1", + ] { + assert!(parse_watch_args(bad).is_err(), "{bad:?} should be rejected"); + } + } +} diff --git a/rust/lancedb/cli/src/dml_result.rs b/rust/lancedb/cli/src/dml_result.rs new file mode 100644 index 000000000..5955ce458 --- /dev/null +++ b/rust/lancedb/cli/src/dml_result.rs @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +//! Typed DML result serializable to/from RecordBatch for Arrow Flight transport. + +use arrow_array::{Int64Array, RecordBatch, StringArray}; +use arrow_schema::{DataType, Field, Schema}; +use std::fmt; +use std::sync::Arc; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DmlOperation { + Insert, + Update, + Delete, +} + +impl DmlOperation { + pub fn as_str(self) -> &'static str { + match self { + Self::Insert => "INSERT", + Self::Update => "UPDATE", + Self::Delete => "DELETE", + } + } + + fn parse(s: &str) -> Option { + match s { + "INSERT" => Some(Self::Insert), + "UPDATE" => Some(Self::Update), + "DELETE" => Some(Self::Delete), + _ => None, + } + } +} + +impl fmt::Display for DmlOperation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct DmlResult { + pub table: String, + pub operation: DmlOperation, + pub rows_affected: i64, + pub version: i64, +} + +fn schema() -> Schema { + Schema::new(vec![ + Field::new("table", DataType::Utf8, false), + Field::new("operation", DataType::Utf8, false), + Field::new("rows_affected", DataType::Int64, false), + Field::new("version", DataType::Int64, false), + ]) +} + +impl DmlResult { + pub fn new( + table: impl Into, + operation: DmlOperation, + rows_affected: i64, + version: i64, + ) -> Self { + Self { + table: table.into(), + operation, + rows_affected, + version, + } + } + + pub fn to_record_batch(&self) -> RecordBatch { + RecordBatch::try_new( + Arc::new(schema()), + vec![ + Arc::new(StringArray::from(vec![self.table.as_str()])), + Arc::new(StringArray::from(vec![self.operation.as_str()])), + Arc::new(Int64Array::from(vec![self.rows_affected])), + Arc::new(Int64Array::from(vec![self.version])), + ], + ) + .expect("static schema") + } + + pub fn try_from_batch(batch: &RecordBatch) -> Option { + if *batch.schema().as_ref() != schema() || batch.num_rows() != 1 { + return None; + } + Some(Self { + table: batch + .column(0) + .as_any() + .downcast_ref::()? + .value(0) + .to_string(), + operation: DmlOperation::parse( + batch + .column(1) + .as_any() + .downcast_ref::()? + .value(0), + )?, + rows_affected: batch + .column(2) + .as_any() + .downcast_ref::()? + .value(0), + version: batch + .column(3) + .as_any() + .downcast_ref::()? + .value(0), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip() { + let original = DmlResult::new("foo", DmlOperation::Insert, 1, 3); + let batch = original.to_record_batch(); + assert_eq!(DmlResult::try_from_batch(&batch), Some(original)); + } + + #[test] + fn non_dml_returns_none() { + let s = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)])); + let batch = RecordBatch::try_new(s, vec![Arc::new(Int64Array::from(vec![1]))]).unwrap(); + assert_eq!(DmlResult::try_from_batch(&batch), None); + } +} diff --git a/rust/lancedb/cli/src/formatter.rs b/rust/lancedb/cli/src/formatter.rs new file mode 100644 index 000000000..05e6deb4d --- /dev/null +++ b/rust/lancedb/cli/src/formatter.rs @@ -0,0 +1,2516 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use crate::client::{IndexInfo, IndexStats, TableInfo}; +use crate::sql_executor::SqlResult; +use crate::table_writer::TableWriter; +use anyhow::Result; +use arrow_array::types::ArrowDictionaryKeyType; +use arrow_array::{ + downcast_dictionary_array, Array, BinaryArray, BooleanArray, DictionaryArray, + FixedSizeListArray, Float32Array, Float64Array, Int16Array, Int32Array, Int64Array, Int8Array, + LargeBinaryArray, ListArray, RecordBatch, StringArray, UInt16Array, UInt32Array, UInt64Array, + UInt8Array, +}; +use arrow_buffer::ArrowNativeType; +use arrow_cast::display::{ArrayFormatter, FormatOptions}; +use arrow_json::writer::{make_encoder, Encoder, EncoderFactory, EncoderOptions, NullableEncoder}; +use arrow_schema::{DataType, Field, Schema}; +use std::io::Write; +use std::str::FromStr; +use std::sync::Arc; +use tracing::warn; + +/// Result of a query engine command +#[derive(Debug)] +pub enum QueryResult { + /// SQL query result + SqlResult(SqlResult), + /// List of table names + TableList(Vec), + /// List of tables with detailed stats + TableListDetailed(Vec), + /// Table information + TableInfo(TableInfo), + /// Table information with indices + TableInfoWithIndices(TableInfo, Vec), + /// Index information + IndexList(Vec), + /// List of indices with detailed stats + IndexListDetailed(Vec<(IndexInfo, Option)>), +} + +/// Output format options +#[derive(Debug, Clone, Copy)] +pub enum OutputFormat { + Table, + Csv, + Json, + Ndjson, +} + +impl FromStr for OutputFormat { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "table" => Ok(Self::Table), + "csv" => Ok(Self::Csv), + "json" => Ok(Self::Json), + "ndjson" => Ok(Self::Ndjson), + _ => Err(anyhow::anyhow!( + "Invalid output format: {}. Must be one of: table, csv, json, ndjson", + s + )), + } + } +} + +/// Formats query results for display +pub struct ResultFormatter { + output_format: OutputFormat, + /// Optional override for the terminal width used to choose between table + /// and expanded rendering. `None` auto-detects from the terminal; + /// tests pin it so output is independent of the ambient terminal size. + terminal_width: Option, +} + +impl ResultFormatter { + /// Create a new ResultFormatter + pub fn new(output_format: OutputFormat) -> Self { + Self { + output_format, + terminal_width: None, + } + } + + pub fn with_terminal_width(mut self, width: usize) -> Self { + self.terminal_width = Some(width); + self + } + + pub fn output_format(&self) -> OutputFormat { + self.output_format + } + + /// Format a QueryResult to string + pub fn format(&self, result: &QueryResult) -> Result { + let mut output = Vec::new(); + self.format_to_writer(result, &mut output)?; + Ok(String::from_utf8(output)?) + } + + /// Format a QueryResult to a writer + pub fn format_to_writer(&self, result: &QueryResult, writer: &mut W) -> Result<()> { + match result { + QueryResult::SqlResult(sql_result) => self.format_sql_result(sql_result, writer), + QueryResult::TableList(tables) => self.format_table_list(tables, writer), + QueryResult::TableListDetailed(tables) => { + self.format_table_list_detailed(tables, writer) + } + QueryResult::TableInfo(info) => self.format_table_info(info, writer), + QueryResult::TableInfoWithIndices(info, indices) => { + self.format_table_info_with_indices(info, indices, writer) + } + QueryResult::IndexList(indices) => self.format_index_list(indices, writer), + QueryResult::IndexListDetailed(indices) => { + self.format_index_list_detailed(indices, writer) + } + } + } + + /// Format SQL result + fn format_sql_result(&self, result: &SqlResult, writer: &mut W) -> Result<()> { + match result { + SqlResult::Query(batches) => self.format_record_batches(batches, writer), + SqlResult::Dml(dml) => { + writeln!( + writer, + "{} {} row(s), version {}", + dml.operation, dml.rows_affected, dml.version + )?; + Ok(()) + } + SqlResult::Success => { + writeln!(writer, "Success")?; + Ok(()) + } + } + } + + /// Format record batches + pub fn format_record_batches( + &self, + batches: &[RecordBatch], + writer: &mut W, + ) -> Result<()> { + if batches.is_empty() { + return Ok(()); + } + + match self.output_format { + OutputFormat::Table => self.format_batches_as_table(batches, writer), + OutputFormat::Csv => self.format_batches_as_csv(batches, writer), + OutputFormat::Json => self.format_batches_as_json(batches, writer), + OutputFormat::Ndjson => self.format_batches_as_ndjson(batches, writer), + } + } + + /// Format batches as table + fn format_batches_as_table( + &self, + batches: &[RecordBatch], + writer: &mut W, + ) -> Result<()> { + let schema = batches[0].schema(); + let column_names: Vec = schema.fields().iter().map(|f| f.name().clone()).collect(); + + // Check if this is an EXPLAIN query (has plan_type and plan columns) + let is_explain = + column_names.len() == 2 && column_names[0] == "plan_type" && column_names[1] == "plan"; + + if is_explain { + return self.format_explain_results(batches, writer); + } + + let mut rows = Vec::new(); + let mut total_rows = 0; + + for batch in batches { + total_rows += batch.num_rows(); + for row_idx in 0..batch.num_rows() { + let mut row = Vec::new(); + for col_idx in 0..batch.num_columns() { + let column = batch.column(col_idx); + let field = schema.field(col_idx); + let value = format_arrow_value(column, row_idx, field); + row.push(value); + } + rows.push(row); + } + } + + TableWriter::print_table(writer, &column_names, &rows, self.terminal_width)?; + writeln!( + writer, + "({} row{})", + total_rows, + if total_rows == 1 { "" } else { "s" } + )?; + Ok(()) + } + + /// Format EXPLAIN query results + fn format_explain_results( + &self, + batches: &[RecordBatch], + writer: &mut W, + ) -> Result<()> { + writeln!( + writer, + " QUERY PLAN" + )?; + writeln!(writer, "---------------------------------------------------------------------------------------------------")?; + + let mut logical_plan = String::new(); + let mut physical_plan = String::new(); + let mut plan_with_metrics = String::new(); + + // Extract the plans from the batches + for batch in batches { + for row_idx in 0..batch.num_rows() { + if batch.num_columns() >= 2 { + let plan_type_col = batch.column(0); + let plan_col = batch.column(1); + + let plan_type = plan_type_col + .as_any() + .downcast_ref::() + .map(|arr| arr.value(row_idx)) + .unwrap_or(""); + + let plan = plan_col + .as_any() + .downcast_ref::() + .map(|arr| arr.value(row_idx)) + .unwrap_or(""); + + match plan_type { + "logical_plan" => logical_plan = plan.to_string(), + "physical_plan" => physical_plan = plan.to_string(), + "Plan with Metrics" => plan_with_metrics = plan.to_string(), + _ => {} + } + } + } + } + + // Display logical plan + if !logical_plan.is_empty() { + writeln!(writer, "Logical Plan:")?; + for line in logical_plan.lines() { + writeln!(writer, " {}", line)?; + } + } + + // Display physical plan + if !physical_plan.is_empty() { + if !logical_plan.is_empty() { + writeln!(writer)?; + } + writeln!(writer, "Physical Plan:")?; + for line in physical_plan.lines() { + writeln!(writer, " {}", line)?; + } + } + + // Display plan with metrics (EXPLAIN ANALYZE) + if !plan_with_metrics.is_empty() { + if !logical_plan.is_empty() || !physical_plan.is_empty() { + writeln!(writer)?; + } + for line in plan_with_metrics.lines() { + writeln!(writer, "{}", line)?; + } + } + + writeln!( + writer, + "({} row{})", + batches.iter().map(|b| b.num_rows()).sum::(), + if batches.iter().map(|b| b.num_rows()).sum::() == 1 { + "" + } else { + "s" + } + )?; + Ok(()) + } + + /// Format batches as CSV + fn format_batches_as_csv( + &self, + batches: &[RecordBatch], + writer: &mut W, + ) -> Result<()> { + let schema = batches[0].schema(); + + for (index, field) in schema.fields().iter().enumerate() { + if index > 0 { + write!(writer, ",")?; + } + write_csv_value(writer, field.name())?; + } + writeln!(writer)?; + + let scalar_options = FormatOptions::default(); + let json_options = EncoderOptions::default() + .with_explicit_nulls(true) + .with_encoder_factory(Arc::new(CsvJsonEncoderFactory)); + let mut json_value = Vec::new(); + + for batch in batches { + if batch.num_rows() == 0 { + continue; + } + let schema = batch.schema(); + let mut formatters = schema + .fields() + .iter() + .zip(batch.columns()) + .map(|(field, column)| { + if csv_uses_json(column.data_type()) { + Ok(CsvValueFormatter::Json(make_encoder( + field, + column.as_ref(), + &json_options, + )?)) + } else { + Ok(CsvValueFormatter::Scalar(ArrayFormatter::try_new( + column.as_ref(), + &scalar_options, + )?)) + } + }) + .collect::>>()?; + let nulls: Vec<_> = batch + .columns() + .iter() + .map(|column| column.logical_nulls()) + .collect(); + + for row_idx in 0..batch.num_rows() { + for (col_idx, formatter) in formatters.iter_mut().enumerate() { + if col_idx > 0 { + write!(writer, ",")?; + } + // Logical nulls include null dictionary values and Null arrays. + if nulls[col_idx] + .as_ref() + .is_some_and(|nulls| nulls.is_null(row_idx)) + { + write!(writer, "{CSV_NULL_VALUE}")?; + continue; + } + match formatter { + CsvValueFormatter::Scalar(formatter) => { + write_csv_cell(writer, &formatter.value(row_idx).try_to_string()?)?; + } + CsvValueFormatter::Json(encoder) => { + json_value.clear(); + encoder.encode(row_idx, &mut json_value); + write_csv_cell(writer, std::str::from_utf8(&json_value)?)?; + } + } + } + writeln!(writer)?; + } + } + Ok(()) + } + + /// Format batches as JSON + fn format_batches_as_json( + &self, + batches: &[RecordBatch], + writer: &mut W, + ) -> Result<()> { + if batches.is_empty() { + writeln!(writer, "[]")?; + return Ok(()); + } + // Arrow's bytes go out as written. Re-reading them through + // `serde_json::Value` to pretty-print would round every decimal the + // writer emitted exactly. + let mut json = Vec::new(); + let mut json_writer = arrow_json::writer::WriterBuilder::new() + .with_explicit_nulls(true) + .build::<_, arrow_json::writer::JsonArray>(&mut json); + for batch in batches { + json_writer.write(batch)?; + } + json_writer.finish()?; + writer.write_all(&json)?; + writeln!(writer)?; + Ok(()) + } + + /// Format batches as NDJSON + fn format_batches_as_ndjson( + &self, + batches: &[RecordBatch], + writer: &mut W, + ) -> Result<()> { + if batches.is_empty() { + return Ok(()); + } + let mut json = Vec::new(); + let mut json_writer = arrow_json::writer::WriterBuilder::new() + .with_explicit_nulls(true) + .build::<_, arrow_json::writer::LineDelimited>(&mut json); + for batch in batches { + json_writer.write(batch)?; + } + json_writer.finish()?; + writer.write_all(&json)?; + Ok(()) + } + + /// Format table list + fn format_table_list(&self, tables: &[String], writer: &mut W) -> Result<()> { + match self.output_format { + OutputFormat::Json => { + let json = serde_json::to_string_pretty(&tables)?; + writeln!(writer, "{}", json)?; + } + _ => { + if tables.is_empty() { + writeln!(writer, "Did not find any relations.")?; + } else { + let headers = vec!["Tables".to_string()]; + let data: Vec> = tables.iter().map(|t| vec![t.clone()]).collect(); + TableWriter::print_table_with_title( + writer, + "List of relations", + &headers, + &data, + self.terminal_width, + )?; + writeln!( + writer, + "({} row{})", + tables.len(), + if tables.len() == 1 { "" } else { "s" } + )?; + } + } + } + Ok(()) + } + + /// Format detailed table list with stats + fn format_table_list_detailed( + &self, + tables: &[TableInfo], + writer: &mut W, + ) -> Result<()> { + match self.output_format { + OutputFormat::Json => { + let json = serde_json::json!(tables + .iter() + .map(|t| { + serde_json::json!({ + "name": t.name, + "type": "table", + "rows": t.row_count, + "size_bytes": t.size_bytes, + "indices": t.num_indices, + "fragments": t.num_fragments, + }) + }) + .collect::>()); + let json_string = serde_json::to_string_pretty(&json)?; + writeln!(writer, "{}", json_string)?; + } + _ => { + if tables.is_empty() { + writeln!(writer, "Did not find any relations.")?; + } else { + let headers = vec![ + "Name".to_string(), + "Type".to_string(), + "Rows".to_string(), + "Size".to_string(), + "Indices".to_string(), + "Fragments".to_string(), + ]; + + let data: Vec> = tables + .iter() + .map(|t| { + vec![ + t.name.clone(), + "table".to_string(), + t.row_count.map_or("".to_string(), |r| r.to_string()), + t.size_bytes.map_or("".to_string(), format_bytes), + t.num_indices + .filter(|&n| n > 0) + .map_or("".to_string(), |n| n.to_string()), + t.num_fragments + .filter(|&n| n > 0) + .map_or("".to_string(), |n| n.to_string()), + ] + }) + .collect(); + + TableWriter::print_table_with_title( + writer, + "List of relations", + &headers, + &data, + self.terminal_width, + )?; + writeln!( + writer, + "({} row{})", + tables.len(), + if tables.len() == 1 { "" } else { "s" } + )?; + } + } + } + Ok(()) + } + + /// Format table info + fn format_table_info(&self, info: &TableInfo, writer: &mut W) -> Result<()> { + match self.output_format { + OutputFormat::Json => { + let json = serde_json::json!({ + "name": info.name, + "schema": schema_to_json(&info.schema), + "row_count": info.row_count, + "size_bytes": info.size_bytes, + "num_indices": info.num_indices, + "num_fragments": info.num_fragments, + }); + let json_string = serde_json::to_string_pretty(&json)?; + writeln!(writer, "{}", json_string)?; + } + _ => { + // Format as table + writeln!(writer, " Table \"{}\"", info.name)?; + + let headers = vec![ + "Column".to_string(), + "Type".to_string(), + "Nullable".to_string(), + ]; + let mut data = Vec::new(); + + for field in info.schema.fields() { + let data_type = format_data_type(field.data_type()); + let nullable = if field.is_nullable() { "YES" } else { "" }; + data.push(vec![field.name().clone(), data_type, nullable.to_string()]); + } + + TableWriter::print_table(writer, &headers, &data, self.terminal_width)?; + + // Add stats if available + if let Some(row_count) = info.row_count { + writeln!(writer, "\nRows: {}", row_count)?; + } + if let Some(size) = info.size_bytes { + writeln!(writer, "Size: {}", format_bytes(size))?; + } + } + } + Ok(()) + } + + /// Format table info with indices (similar to psql \d
) + fn format_table_info_with_indices( + &self, + info: &TableInfo, + indices: &[IndexInfo], + writer: &mut W, + ) -> Result<()> { + match self.output_format { + OutputFormat::Table => { + // Format as table (psql style) + writeln!(writer, " Table \"{}\"", info.name)?; + + let headers = vec![ + "Column".to_string(), + "Type".to_string(), + "Nullable".to_string(), + ]; + let mut data = Vec::new(); + for field in info.schema.fields() { + let data_type = format_data_type(field.data_type()); + let nullable = if field.is_nullable() { "YES" } else { "" }; + data.push(vec![field.name().clone(), data_type, nullable.to_string()]); + } + TableWriter::print_table(writer, &headers, &data, self.terminal_width)?; + + // List indexes if any + if !indices.is_empty() { + writeln!(writer, "Indexes:")?; + for index in indices { + let columns = index.columns.join(", "); + writeln!( + writer, + " \"{}\" {} ({})", + index.name, index.index_type, columns + )?; + } + } + + // Add stats if available + if let Some(row_count) = info.row_count { + writeln!(writer, "\nRows: {}", row_count)?; + } + if let Some(size) = info.size_bytes { + writeln!(writer, "Size: {}", format_bytes(size))?; + } + } + OutputFormat::Csv => { + // CSV format: output schema info and indices as separate sections + writeln!(writer, "# Table: {}", info.name)?; + writeln!(writer, "# Schema")?; + writeln!(writer, "Column,Type,Nullable")?; + for field in info.schema.fields() { + let data_type = format_data_type(field.data_type()); + let nullable = if field.is_nullable() { "YES" } else { "NO" }; + writeln!(writer, "{},{},{}", field.name(), data_type, nullable)?; + } + + if !indices.is_empty() { + writeln!(writer, "\n# Indices")?; + writeln!(writer, "Index Name,Index Type,Columns")?; + for index in indices { + let columns = index.columns.join(";"); + writeln!(writer, "{},{},{}", index.name, index.index_type, columns)?; + } + } + + if let Some(row_count) = info.row_count { + writeln!(writer, "\n# Row Count: {}", row_count)?; + } + if let Some(size) = info.size_bytes { + writeln!(writer, "# Size: {}", format_bytes(size))?; + } + } + OutputFormat::Json => { + let json = serde_json::json!({ + "name": info.name, + "schema": schema_to_json(&info.schema), + "row_count": info.row_count, + "size_bytes": info.size_bytes, + "num_indices": info.num_indices, + "num_fragments": info.num_fragments, + "indices": indices, + }); + let json_string = serde_json::to_string_pretty(&json)?; + writeln!(writer, "{}", json_string)?; + } + OutputFormat::Ndjson => { + // NDJSON: one line for table info, one line per index + let table_json = serde_json::json!({ + "type": "table_info", + "name": info.name, + "schema": schema_to_json(&info.schema), + "row_count": info.row_count, + "size_bytes": info.size_bytes, + "num_indices": info.num_indices, + "num_fragments": info.num_fragments, + }); + writeln!(writer, "{}", serde_json::to_string(&table_json)?)?; + + for index in indices { + let index_json = serde_json::json!({ + "type": "index_info", + "table": info.name, + "name": index.name, + "index_type": index.index_type, + "columns": index.columns, + }); + writeln!(writer, "{}", serde_json::to_string(&index_json)?)?; + } + } + } + Ok(()) + } + + /// Format index list + fn format_index_list(&self, indices: &[IndexInfo], writer: &mut W) -> Result<()> { + match self.output_format { + OutputFormat::Json => { + let json = serde_json::to_string_pretty(&indices)?; + writeln!(writer, "{}", json)?; + } + _ => { + if indices.is_empty() { + writeln!(writer, "Did not find any indexes.")?; + } else { + let headers = vec!["Name".to_string(), "Type".to_string(), "Table".to_string()]; + let data: Vec> = indices + .iter() + .map(|idx| { + vec![ + idx.name.clone(), + idx.index_type.clone(), + idx.table_name.clone(), + ] + }) + .collect(); + + TableWriter::print_table_with_title( + writer, + "List of indices", + &headers, + &data, + self.terminal_width, + )?; + writeln!( + writer, + "({} row{})", + indices.len(), + if indices.len() == 1 { "" } else { "s" } + )?; + } + } + } + Ok(()) + } + + /// Format detailed index list with stats + fn format_index_list_detailed( + &self, + indices: &[(IndexInfo, Option)], + writer: &mut W, + ) -> Result<()> { + match self.output_format { + OutputFormat::Json => { + let json = serde_json::json!(indices + .iter() + .map(|(info, stats)| { + let mut obj = serde_json::json!({ + "name": info.name, + "type": info.index_type, + "table": info.table_name, + "columns": info.columns, + }); + if let Some(stats) = stats { + obj["distance_type"] = serde_json::json!(stats.distance_type); + obj["indexed_rows"] = serde_json::json!(stats.num_indexed_rows); + obj["unindexed_rows"] = serde_json::json!(stats.num_unindexed_rows); + } + obj + }) + .collect::>()); + let json_string = serde_json::to_string_pretty(&json)?; + writeln!(writer, "{}", json_string)?; + } + _ => { + if indices.is_empty() { + writeln!(writer, "Did not find any indexes.")?; + } else { + let headers = vec![ + "Name".to_string(), + "Type".to_string(), + "Distance".to_string(), + "Indexed Rows".to_string(), + "Unindexed Rows".to_string(), + "Table".to_string(), + ]; + + let data: Vec> = indices + .iter() + .map(|(info, stats)| { + let (distance, indexed, unindexed) = if let Some(s) = stats { + ( + s.distance_type.as_deref().unwrap_or(""), + s.num_indexed_rows.to_string(), + s.num_unindexed_rows.to_string(), + ) + } else { + ("", "".to_string(), "".to_string()) + }; + + vec![ + info.name.clone(), + info.index_type.clone(), + distance.to_string(), + indexed, + unindexed, + info.table_name.clone(), + ] + }) + .collect(); + + TableWriter::print_table_with_title( + writer, + "List of relations", + &headers, + &data, + self.terminal_width, + )?; + writeln!( + writer, + "({} row{})", + indices.len(), + if indices.len() == 1 { "" } else { "s" } + )?; + } + } + } + Ok(()) + } +} + +// Helper functions + +enum CsvValueFormatter<'a> { + Scalar(ArrayFormatter<'a>), + Json(NullableEncoder<'a>), +} + +fn csv_uses_json(data_type: &DataType) -> bool { + match data_type { + DataType::Dictionary(_, value) => csv_uses_json(value), + DataType::RunEndEncoded(_, value) => csv_uses_json(value.data_type()), + _ => data_type.is_nested(), + } +} + +const CSV_NULL_VALUE: &str = r"\N"; + +fn write_csv_cell(writer: &mut W, value: &str) -> Result<()> { + // Escape leading backslashes so the null marker cannot collide with data. + if value.starts_with('\\') { + write_csv_value(writer, &format!("\\{value}")) + } else { + write_csv_value(writer, value) + } +} + +fn write_csv_value(writer: &mut W, value: &str) -> Result<()> { + // An empty single-column record must not become a blank line. + if value.is_empty() || value.contains([',', '"', '\n', '\r']) { + write!(writer, "\"{}\"", value.replace('"', "\"\""))?; + } else { + write!(writer, "{value}")?; + } + Ok(()) +} + +#[derive(Debug)] +struct CsvJsonEncoderFactory; + +impl EncoderFactory for CsvJsonEncoderFactory { + fn make_default_encoder<'a>( + &self, + field: &'a arrow_schema::FieldRef, + array: &'a dyn arrow_array::Array, + options: &'a EncoderOptions, + ) -> std::result::Result>, arrow_schema::ArrowError> { + downcast_dictionary_array! { + array => { + let values = make_encoder(field, array.values().as_ref(), options)?; + // Parent encoders must see keys pointing to null values as nulls. + return Ok(Some(NullableEncoder::new( + Box::new(CsvJsonDictionaryEncoder { dictionary: array, values }), + array.logical_nulls(), + ))); + } + _ => {} + } + if !matches!( + array.data_type(), + DataType::Float16 | DataType::Float32 | DataType::Float64 + ) { + return Ok(None); + } + let formatter = ArrayFormatter::try_new(array, &FormatOptions::default())?; + Ok(Some(NullableEncoder::new( + Box::new(CsvJsonFloatEncoder(formatter)), + array.nulls().cloned(), + ))) + } +} + +struct CsvJsonDictionaryEncoder<'a, K: ArrowDictionaryKeyType> { + dictionary: &'a DictionaryArray, + values: NullableEncoder<'a>, +} + +impl Encoder for CsvJsonDictionaryEncoder<'_, K> { + fn encode(&mut self, idx: usize, out: &mut Vec) { + self.values + .encode(self.dictionary.keys().value(idx).as_usize(), out); + } +} + +struct CsvJsonFloatEncoder<'a>(ArrayFormatter<'a>); + +impl Encoder for CsvJsonFloatEncoder<'_> { + fn encode(&mut self, idx: usize, out: &mut Vec) { + let value = self.0.value(idx).to_string(); + // JSON has no non-finite numbers; keep their names instead of turning + // them into nulls, as Arrow's default JSON float encoder does. + if matches!(value.as_str(), "NaN" | "inf" | "-inf") { + out.push(b'"'); + out.extend_from_slice(value.as_bytes()); + out.push(b'"'); + } else { + out.extend_from_slice(value.as_bytes()); + } + } +} + +/// Longest string rendered in full when a column has not opted out of +/// truncation. +const MAX_UNTRUNCATED_STRING_LEN: usize = 50; + +/// Arrow field metadata key marking a column that must be rendered in full. +const NO_TRUNCATE_METADATA_KEY: &str = "lancedb.display.no_truncate"; + +/// Whether this field is marked to render in full. +fn field_opts_out_of_truncation(field: &Field) -> bool { + field + .metadata() + .get(NO_TRUNCATE_METADATA_KEY) + .is_some_and(|value| value == "true") +} + +/// Rows a query returned, across batches. Zero for non-query results. +pub fn row_count(result: &SqlResult) -> usize { + match result { + SqlResult::Query(batches) => batches.iter().map(RecordBatch::num_rows).sum(), + _ => 0, + } +} + +/// Keep the first `limit` rows, for a listing whose reader is a human at a +/// terminal rather than a program. Slices are zero-copy, so the dropped rows +/// cost nothing beyond the memory they already occupy. Non-query results pass +/// through. +pub fn take_rows(result: SqlResult, limit: usize) -> SqlResult { + let SqlResult::Query(batches) = result else { + return result; + }; + + let mut remaining = limit; + let mut kept = Vec::with_capacity(batches.len()); + for batch in batches { + if remaining == 0 { + break; + } + let take = remaining.min(batch.num_rows()); + remaining -= take; + kept.push(batch.slice(0, take)); + } + SqlResult::Query(kept) +} + +/// Mark `columns` so rendering keeps their values whole, for results whose +/// consumer needs to copy a value verbatim rather than read it. +pub fn mark_no_truncate(result: SqlResult, columns: &[&str]) -> SqlResult { + match result { + SqlResult::Query(batches) => SqlResult::Query( + batches + .into_iter() + .map(|batch| mark_batch_columns(batch, columns)) + .collect(), + ), + other => other, + } +} + +/// Rebuild `batch` with the named fields marked, leaving its data untouched. +fn mark_batch_columns(batch: RecordBatch, columns: &[&str]) -> RecordBatch { + let schema = batch.schema(); + let fields: Vec = schema + .fields() + .iter() + .map(|field| { + if !columns.contains(&field.name().as_str()) { + return field.as_ref().clone(); + } + let mut metadata = field.metadata().clone(); + metadata.insert(NO_TRUNCATE_METADATA_KEY.to_string(), "true".to_string()); + field.as_ref().clone().with_metadata(metadata) + }) + .collect(); + + let marked = Arc::new(Schema::new_with_metadata(fields, schema.metadata().clone())); + match RecordBatch::try_new(marked, batch.columns().to_vec()) { + Ok(batch) => batch, + // Only the metadata changed, so this should not fail. Render the + // original rather than the error, at the cost of abbreviating them. + Err(err) => { + warn!("could not mark columns to render in full, values may be abbreviated: {err}"); + batch + } + } +} + +/// Render a string cell, abbreviating it unless the field opts out. +fn format_string_value(value: &str, field: &Field) -> String { + if value.len() <= MAX_UNTRUNCATED_STRING_LEN || field_opts_out_of_truncation(field) { + return value.to_string(); + } + let end = value.floor_char_boundary(MAX_UNTRUNCATED_STRING_LEN); + format!("{}...({} chars)", &value[..end], value.chars().count()) +} + +/// Format arrow value as string +fn format_arrow_value(column: &dyn arrow_array::Array, row_idx: usize, field: &Field) -> String { + if column.is_null(row_idx) { + return "NULL".to_string(); + } + + match field.data_type() { + DataType::Utf8 => { + let array = column.as_any().downcast_ref::().unwrap(); + format_string_value(array.value(row_idx), field) + } + DataType::LargeUtf8 => { + let array = column + .as_any() + .downcast_ref::() + .unwrap(); + format_string_value(array.value(row_idx), field) + } + DataType::Utf8View => { + let array = column + .as_any() + .downcast_ref::() + .unwrap(); + format_string_value(array.value(row_idx), field) + } + DataType::Int8 => { + let array = column.as_any().downcast_ref::().unwrap(); + array.value(row_idx).to_string() + } + DataType::Int16 => { + let array = column.as_any().downcast_ref::().unwrap(); + array.value(row_idx).to_string() + } + DataType::Int32 => { + let array = column.as_any().downcast_ref::().unwrap(); + array.value(row_idx).to_string() + } + DataType::Int64 => { + let array = column.as_any().downcast_ref::().unwrap(); + array.value(row_idx).to_string() + } + DataType::UInt8 => { + let array = column.as_any().downcast_ref::().unwrap(); + array.value(row_idx).to_string() + } + DataType::UInt16 => { + let array = column.as_any().downcast_ref::().unwrap(); + array.value(row_idx).to_string() + } + DataType::UInt32 => { + let array = column.as_any().downcast_ref::().unwrap(); + array.value(row_idx).to_string() + } + DataType::UInt64 => { + let array = column.as_any().downcast_ref::().unwrap(); + array.value(row_idx).to_string() + } + DataType::Float32 => { + let array = column.as_any().downcast_ref::().unwrap(); + array.value(row_idx).to_string() + } + DataType::Float64 => { + let array = column.as_any().downcast_ref::().unwrap(); + array.value(row_idx).to_string() + } + DataType::Boolean => { + let array = column.as_any().downcast_ref::().unwrap(); + array.value(row_idx).to_string() + } + DataType::FixedSizeList(inner_field, size) => { + // Handle fixed size vectors (e.g., embeddings) + let list_array = column + .as_any() + .downcast_ref::() + .unwrap(); + let values = list_array.value(row_idx); + + // Format as [value1, value2, ...] for short vectors, with size info for long ones + match inner_field.data_type() { + DataType::Float32 => { + let float_array = values.as_any().downcast_ref::().unwrap(); + if float_array.len() > 10 { + let first = float_array.value(0); + let second = float_array.value(1); + format!("[{}, {}, ...{} more]", first, second, float_array.len() - 2) + } else { + // Show all values if 10 or fewer + let values: Vec = (0..float_array.len()) + .map(|i| float_array.value(i).to_string()) + .collect(); + format!("[{}]", values.join(", ")) + } + } + DataType::Float64 => { + let float_array = values.as_any().downcast_ref::().unwrap(); + if float_array.len() > 10 { + let first = float_array.value(0); + let second = float_array.value(1); + format!("[{}, {}, ...{} more]", first, second, float_array.len() - 2) + } else { + // Show all values if 10 or fewer + let values: Vec = (0..float_array.len()) + .map(|i| float_array.value(i).to_string()) + .collect(); + format!("[{}]", values.join(", ")) + } + } + _ => { + format!("[{} items]", *size) + } + } + } + DataType::List(inner_field) => { + // Handle variable size lists + let list_array = column.as_any().downcast_ref::().unwrap(); + let values = list_array.value(row_idx); + let size = values.len(); + + // Format as [value1, value2, ...] for short vectors, truncated for long ones + match inner_field.data_type() { + DataType::Float32 => { + let float_array = values.as_any().downcast_ref::().unwrap(); + if float_array.len() > 10 { + let first = float_array.value(0); + let second = float_array.value(1); + format!("[{}, {}, ...{} more]", first, second, float_array.len() - 2) + } else { + // Show all values if 10 or fewer + let values: Vec = (0..float_array.len()) + .map(|i| float_array.value(i).to_string()) + .collect(); + format!("[{}]", values.join(", ")) + } + } + DataType::Float64 => { + let float_array = values.as_any().downcast_ref::().unwrap(); + if float_array.len() > 10 { + let first = float_array.value(0); + let second = float_array.value(1); + format!("[{}, {}, ...{} more]", first, second, float_array.len() - 2) + } else { + // Show all values if 10 or fewer + let values: Vec = (0..float_array.len()) + .map(|i| float_array.value(i).to_string()) + .collect(); + format!("[{}]", values.join(", ")) + } + } + DataType::Int32 => { + let int_array = values.as_any().downcast_ref::().unwrap(); + if int_array.len() > 10 { + let first = int_array.value(0); + let second = int_array.value(1); + format!("[{}, {}, ...{} more]", first, second, int_array.len() - 2) + } else { + let values: Vec = (0..int_array.len()) + .map(|i| int_array.value(i).to_string()) + .collect(); + format!("[{}]", values.join(", ")) + } + } + DataType::Int64 => { + let int_array = values.as_any().downcast_ref::().unwrap(); + if int_array.len() > 10 { + let first = int_array.value(0); + let second = int_array.value(1); + format!("[{}, {}, ...{} more]", first, second, int_array.len() - 2) + } else { + let values: Vec = (0..int_array.len()) + .map(|i| int_array.value(i).to_string()) + .collect(); + format!("[{}]", values.join(", ")) + } + } + _ => { + format!("[{} items]", size) + } + } + } + DataType::Binary => { + let array = column.as_any().downcast_ref::().unwrap(); + let bytes = array.value(row_idx); + if bytes.len() > 32 { + // Show first few bytes with ellipsis for large binary data + let preview: Vec = bytes[..8].iter().map(|b| format!("{}", b)).collect(); + format!( + "[{}, ...{} more bytes]", + preview.join(", "), + bytes.len() - 8 + ) + } else { + // Show all bytes if small + let values: Vec = bytes.iter().map(|b| format!("{}", b)).collect(); + format!("[{}]", values.join(", ")) + } + } + DataType::LargeBinary => { + let array = column.as_any().downcast_ref::().unwrap(); + let bytes = array.value(row_idx); + if bytes.len() > 32 { + // Show first few bytes with ellipsis for large binary data + let preview: Vec = bytes[..8].iter().map(|b| format!("{}", b)).collect(); + format!( + "[{}, ...{} more bytes]", + preview.join(", "), + bytes.len() - 8 + ) + } else { + // Show all bytes if small + let values: Vec = bytes.iter().map(|b| format!("{}", b)).collect(); + format!("[{}]", values.join(", ")) + } + } + DataType::Struct(fields) => { + let struct_array = column + .as_any() + .downcast_ref::() + .unwrap(); + let mut field_strs = Vec::new(); + + for (i, field) in fields.iter().enumerate() { + let field_column = struct_array.column(i); + let field_value = format_arrow_value(field_column, row_idx, field); + field_strs.push(format!("{}: {}", field.name(), field_value)); + } + + format!("{{{}}}", field_strs.join(", ")) + } + _ => arrow_cast::display::array_value_to_string(column, row_idx) + .unwrap_or_else(|_| format!("{:?}", column.slice(row_idx, 1))), + } +} + +/// Format arrow value as JSON +/// Convert batches to JSON +/// Format data type for display +fn format_data_type(data_type: &arrow_schema::DataType) -> String { + use arrow_schema::DataType; + + match data_type { + DataType::Boolean => "BOOLEAN".to_string(), + DataType::Int8 => "TINYINT".to_string(), + DataType::Int16 => "SMALLINT".to_string(), + DataType::Int32 => "INT".to_string(), + DataType::Int64 => "BIGINT".to_string(), + DataType::UInt8 => "TINYINT UNSIGNED".to_string(), + DataType::UInt16 => "SMALLINT UNSIGNED".to_string(), + DataType::UInt32 => "INT UNSIGNED".to_string(), + DataType::UInt64 => "BIGINT UNSIGNED".to_string(), + DataType::Float32 => "FLOAT".to_string(), + DataType::Float64 => "DOUBLE".to_string(), + DataType::Utf8 => "TEXT".to_string(), + DataType::LargeUtf8 => "TEXT".to_string(), + DataType::Binary => "BLOB".to_string(), + DataType::LargeBinary => "BLOB".to_string(), + DataType::Date32 => "DATE".to_string(), + DataType::Date64 => "DATE".to_string(), + DataType::Timestamp(_unit, tz) => { + let tz_str = tz.as_ref().map(|s| format!(" ({})", s)).unwrap_or_default(); + format!("TIMESTAMP{}", tz_str) + } + DataType::Time32(_) => "TIME".to_string(), + DataType::Time64(_) => "TIME".to_string(), + DataType::Decimal128(precision, scale) => format!("DECIMAL({}, {})", precision, scale), + DataType::Decimal256(precision, scale) => format!("DECIMAL({}, {})", precision, scale), + DataType::List(field) => format!("LIST<{}>", format_data_type(field.data_type())), + DataType::FixedSizeList(field, size) => { + format!("{}[{}]", format_data_type(field.data_type()), size) + } + DataType::Struct(fields) => { + let field_types: Vec = fields + .iter() + .map(|f| format!("{}: {}", f.name(), format_data_type(f.data_type()))) + .collect(); + format!("STRUCT<{}>", field_types.join(", ")) + } + _ => format!("{:?}", data_type), + } +} + +/// Format bytes to human readable +fn format_bytes(bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB", "PB"]; + if bytes == 0 { + return "0 B".to_string(); + } + + let mut size = bytes as f64; + let mut unit_index = 0; + + while size >= 1024.0 && unit_index < UNITS.len() - 1 { + size /= 1024.0; + unit_index += 1; + } + + if unit_index == 0 { + format!("{} {}", bytes, UNITS[unit_index]) + } else { + format!("{:.1} {}", size, UNITS[unit_index]) + } +} + +/// Convert schema to JSON +fn schema_to_json(schema: &Schema) -> serde_json::Value { + use serde_json::json; + + let fields: Vec = schema + .fields() + .iter() + .map(|field| { + json!({ + "name": field.name(), + "type": format_data_type(field.data_type()), + "nullable": field.is_nullable(), + }) + }) + .collect(); + + json!(fields) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::IndexStats; + use crate::dml_result::{DmlOperation, DmlResult}; + use arrow_array::{ + ArrayRef, BooleanArray, FixedSizeListArray, Float32Array, Int32Array, StringArray, + }; + use arrow_schema::{DataType, Field, Schema}; + use std::sync::Arc; + + fn create_test_batch() -> RecordBatch { + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new("name", DataType::Utf8, false), + Field::new("score", DataType::Float32, true), + Field::new("active", DataType::Boolean, false), + ])); + + let id_array = Int32Array::from(vec![1, 2, 3]); + let name_array = StringArray::from(vec!["Alice", "Bob", "Charlie"]); + let score_array = Float32Array::from(vec![Some(95.5), None, Some(87.3)]); + let active_array = BooleanArray::from(vec![true, false, true]); + + RecordBatch::try_new( + schema, + vec![ + Arc::new(id_array) as ArrayRef, + Arc::new(name_array) as ArrayRef, + Arc::new(score_array) as ArrayRef, + Arc::new(active_array) as ArrayRef, + ], + ) + .unwrap() + } + + fn create_vector_batch() -> RecordBatch { + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new( + "embedding", + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::Float32, true)), + 1024, + ), + false, + ), + ])); + + let id_array = Int32Array::from(vec![1, 2]); + + // Create vectors for 2 rows, each with 1024 elements + let mut vector_data = vec![]; + for row in 0..2 { + for i in 0..1024 { + vector_data.push((row * 1024 + i) as f32 * 0.001); + } + } + let values = Float32Array::from(vector_data); + let list_array = FixedSizeListArray::new( + Arc::new(Field::new("item", DataType::Float32, true)), + 1024, + Arc::new(values), + None, + ); + + RecordBatch::try_new( + schema, + vec![ + Arc::new(id_array) as ArrayRef, + Arc::new(list_array) as ArrayRef, + ], + ) + .unwrap() + } + + #[test] + fn test_table_format() { + let formatter = ResultFormatter::new(OutputFormat::Table); + let batch = create_test_batch(); + let result = QueryResult::SqlResult(SqlResult::Query(vec![batch])); + + let output = formatter.format(&result).unwrap(); + + let expected = concat!( + " id | name | score | active\n", + "----+---------+-------+--------\n", + " 1 | Alice | 95.5 | true \n", + " 2 | Bob | NULL | false \n", + " 3 | Charlie | 87.3 | true \n", + "(3 rows)\n", + ); + + assert_eq!(output, expected); + } + + #[test] + fn test_csv_format() { + let formatter = ResultFormatter::new(OutputFormat::Csv); + let batch = create_test_batch(); + let result = QueryResult::SqlResult(SqlResult::Query(vec![batch])); + + let output = formatter.format(&result).unwrap(); + + // Check CSV header + assert!(output.starts_with("id,name,score,active\n")); + + // Check data rows + assert!(output.contains("1,Alice,95.5,true")); + assert!(output.contains("2,Bob,\\N,false")); + assert!(output.contains("3,Charlie,87.3,true")); + } + + #[test] + fn test_json_format() { + let formatter = ResultFormatter::new(OutputFormat::Json); + let batch = create_test_batch(); + let result = QueryResult::SqlResult(SqlResult::Query(vec![batch])); + + let output = formatter.format(&result).unwrap(); + let json: serde_json::Value = serde_json::from_str(&output).unwrap(); + + assert!(json.is_array()); + let array = json.as_array().unwrap(); + assert_eq!(array.len(), 3); + + // Check first row + assert_eq!(array[0]["id"], 1); + assert_eq!(array[0]["name"], "Alice"); + assert_eq!(array[0]["score"], 95.5); + assert_eq!(array[0]["active"], true); + + // Check NULL handling + assert_eq!(array[1]["score"], serde_json::Value::Null); + } + + /// A timestamp renders as its value, not the `Debug` dump of a one-row array. + #[test] + fn test_table_renders_timestamp_value() { + let ts = arrow_array::TimestampMillisecondArray::from(vec![1_758_217_603_403]) + .with_timezone("UTC"); + let batch = + RecordBatch::try_from_iter(vec![("last_refresh", Arc::new(ts) as ArrayRef)]).unwrap(); + + let output = ResultFormatter::new(OutputFormat::Table) + .format(&QueryResult::SqlResult(SqlResult::Query(vec![batch]))) + .unwrap(); + + assert_eq!( + output, + " last_refresh \n--------------------------\n 2025-09-18T17:46:43.403Z\n(1 row)\n" + ); + } + + /// Arrow writes the exact decimal token; re-reading it through a + /// `serde_json::Value` would round it to the nearest double. + #[test] + fn test_json_preserves_exact_decimal_tokens() { + let amounts = + arrow_array::Decimal128Array::from(vec![123_456_789_012_345_678_901_234_567_890_i128]) + .with_precision_and_scale(30, 10) + .unwrap(); + let batch = RecordBatch::try_from_iter(vec![( + "amount", + Arc::new(amounts) as arrow_array::ArrayRef, + )]) + .unwrap(); + + let output = ResultFormatter::new(OutputFormat::Json) + .format(&QueryResult::SqlResult(SqlResult::Query(vec![batch]))) + .unwrap(); + + assert!( + output.contains("12345678901234567890.1234567890"), + "decimal token was rewritten: {output}" + ); + } + + /// The display path elides a long value and previews a list; a machine + /// format has to carry both whole. + #[test] + fn test_json_is_lossless_for_long_and_nested_values() { + let long = "x".repeat(80); + let listed = + arrow_array::ListArray::from_iter_primitive::([ + Some(vec![Some(1), Some(2), Some(3)]), + ]); + let batch = RecordBatch::try_from_iter(vec![ + ( + "note", + Arc::new(arrow_array::LargeStringArray::from(vec![long.as_str()])) + as arrow_array::ArrayRef, + ), + ("scores", Arc::new(listed) as arrow_array::ArrayRef), + ]) + .unwrap(); + + let output = ResultFormatter::new(OutputFormat::Json) + .format(&QueryResult::SqlResult(SqlResult::Query(vec![batch]))) + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&output).unwrap(); + + assert_eq!(json[0]["note"], long); + assert_eq!(json[0]["scores"], serde_json::json!([1, 2, 3])); + } + + /// A narrow integer used to fall through to a debug dump of the sliced + /// array; `SHOW ERRORS` returns `row_offset` as one. + #[test] + fn test_json_renders_narrow_integers_as_numbers() { + let batch = arrow_array::record_batch!( + ("row_offset", UInt32, [1_u32, 2]), + ("attempt", UInt8, [3_u8, 4]) + ) + .unwrap(); + let output = ResultFormatter::new(OutputFormat::Json) + .format(&QueryResult::SqlResult(SqlResult::Query(vec![batch]))) + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&output).unwrap(); + + assert_eq!(json[0]["row_offset"], 1); + assert_eq!(json[0]["attempt"], 3); + assert_eq!(json[1]["row_offset"], 2); + } + + #[test] + fn test_ndjson_format() { + let formatter = ResultFormatter::new(OutputFormat::Ndjson); + let batch = create_test_batch(); + let result = QueryResult::SqlResult(SqlResult::Query(vec![batch])); + + let output = formatter.format(&result).unwrap(); + let lines: Vec<&str> = output.trim().split('\n').collect(); + + assert_eq!(lines.len(), 3); + + // Parse each line as JSON + for line in lines.iter() { + let json: serde_json::Value = serde_json::from_str(line).unwrap(); + assert!(json.is_object()); + assert!(json["id"].is_number()); + assert!(json["name"].is_string()); + } + } + + #[test] + fn test_vector_formatting() { + let formatter = ResultFormatter::new(OutputFormat::Table); + let batch = create_vector_batch(); + let result = QueryResult::SqlResult(SqlResult::Query(vec![batch])); + + let output = formatter.format(&result).unwrap(); + + // Check that vectors are abbreviated + assert!(output.contains("[0, 0.001, ...1022 more]")); + assert!(!output.contains("0.999")); // Should not show all values + } + + #[test] + fn test_empty_result() { + let formatter = ResultFormatter::new(OutputFormat::Table); + let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)])); + let batch = RecordBatch::new_empty(schema); + let result = QueryResult::SqlResult(SqlResult::Query(vec![batch])); + + let output = formatter.format(&result).unwrap(); + + assert!(output.contains("(0 rows)")); + } + + #[test] + fn test_table_list_formatting() { + let formatter = ResultFormatter::new(OutputFormat::Table); + let tables = vec![ + "users".to_string(), + "products".to_string(), + "orders".to_string(), + ]; + let result = QueryResult::TableList(tables); + + let output = formatter.format(&result).unwrap(); + + let expected = "List of relations + Tables +---------- + users + products + orders +(3 rows) +"; + + assert_eq!(output, expected); + } + + #[test] + fn test_table_list_detailed_formatting() { + let formatter = ResultFormatter::new(OutputFormat::Table).with_terminal_width(200); + let tables = vec![ + TableInfo { + name: "users".to_string(), + schema: Arc::new(Schema::empty()), + row_count: Some(1000), + size_bytes: Some(1024 * 1024), // 1 MB + num_indices: Some(2), + num_fragments: Some(1), + }, + TableInfo { + name: "products".to_string(), + schema: Arc::new(Schema::empty()), + row_count: Some(500), + size_bytes: Some(512 * 1024), // 512 KB + num_indices: None, + num_fragments: None, + }, + ]; + let result = QueryResult::TableListDetailed(tables); + + let output = formatter.format(&result).unwrap(); + + let expected = " List of relations + Name | Type | Rows | Size | Indices | Fragments +----------+-------+------+----------+---------+----------- + users | table | 1000 | 1.0 MB | 2 | 1 + products | table | 500 | 512.0 KB | | +(2 rows) +"; + + assert_eq!(output, expected); + } + + #[test] + fn test_index_list_formatting() { + let formatter = ResultFormatter::new(OutputFormat::Table); + let indices = vec![ + IndexInfo { + name: "idx_users_email".to_string(), + table_name: "users".to_string(), + index_type: "IVF_PQ".to_string(), + columns: vec!["email".to_string()], + }, + IndexInfo { + name: "idx_products_name".to_string(), + table_name: "products".to_string(), + index_type: "BTREE".to_string(), + columns: vec!["name".to_string()], + }, + ]; + let result = QueryResult::IndexList(indices); + + let output = formatter.format(&result).unwrap(); + + let expected = " List of indices + Name | Type | Table +-------------------+--------+---------- + idx_users_email | IVF_PQ | users + idx_products_name | BTREE | products +(2 rows) +"; + + assert_eq!(output, expected); + } + + #[test] + fn test_csv_escaping() { + let formatter = ResultFormatter::new(OutputFormat::Csv); + let schema = Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)])); + + let array = StringArray::from(vec!["simple", "with,comma", "with\"quote", "with\nnewline"]); + + let batch = RecordBatch::try_new(schema, vec![Arc::new(array) as ArrayRef]).unwrap(); + + let result = QueryResult::SqlResult(SqlResult::Query(vec![batch])); + let output = formatter.format(&result).unwrap(); + + assert!(output.contains("simple")); + assert!(output.contains("\"with,comma\"")); + assert!(output.contains("\"with\"\"quote\"")); + assert!(output.contains("\"with\nnewline\"")); + } + + #[test] + fn test_csv_preserves_scalar_values() { + let long = "µ".repeat(300); + let binary = vec![0xab; 100]; + let batch = RecordBatch::try_from_iter(vec![ + ( + "long", + Arc::new(StringArray::from(vec![long.as_str()])) as ArrayRef, + ), + ( + "large", + Arc::new(arrow_array::LargeStringArray::from(vec![long.as_str()])), + ), + ( + "view", + Arc::new(arrow_array::StringViewArray::from(vec![long.as_str()])), + ), + ("small", Arc::new(Float64Array::from(vec![0.000123]))), + ("f32", Arc::new(Float32Array::from(vec![0.00693]))), + ( + "f64", + Arc::new(Float64Array::from(vec![1.2345678901234567])), + ), + ("literal_null", Arc::new(StringArray::from(vec!["NULL"]))), + ("null", Arc::new(StringArray::from(vec![None::<&str>]))), + ("empty", Arc::new(StringArray::from(vec![""]))), + ( + "binary", + Arc::new(BinaryArray::from(vec![binary.as_slice()])), + ), + ( + "decimal", + Arc::new( + arrow_array::Decimal128Array::from(vec![123456789012345678901234567890_i128]) + .with_precision_and_scale(30, 10) + .unwrap(), + ), + ), + ]) + .unwrap(); + let mut output = Vec::new(); + ResultFormatter::new(OutputFormat::Csv) + .format_record_batches(&[batch], &mut output) + .unwrap(); + + let row = csv::Reader::from_reader(output.as_slice()) + .records() + .next() + .unwrap() + .unwrap(); + for index in 0..3 { + assert_eq!(&row[index], long); + } + assert_eq!(row[3].parse::().unwrap(), 0.000123); + assert_eq!( + row[4].parse::().unwrap().to_bits(), + 0.00693_f32.to_bits() + ); + assert_eq!( + row[5].parse::().unwrap().to_bits(), + 1.2345678901234567_f64.to_bits() + ); + assert_eq!(&row[6], "NULL"); + assert_eq!(&row[7], CSV_NULL_VALUE); + assert_eq!(&row[8], ""); + assert!(String::from_utf8(output) + .unwrap() + .contains(",NULL,\\N,\"\",")); + assert_eq!(&row[9], "ab".repeat(100)); + assert_eq!(&row[10], "12345678901234567890.1234567890"); + } + + #[test] + fn test_csv_preserves_nested_values() { + use arrow_array::builder::{ListBuilder, StringBuilder}; + use arrow_array::types::{Float16Type, Float32Type, UInt8Type}; + use arrow_array::Array; + + let mut tags = ListBuilder::new(StringBuilder::new()); + for value in [ + Some("a,b"), + Some("\"quoted\""), + Some("NULL"), + Some(""), + None, + ] { + tags.values().append_option(value); + } + tags.append(true); + let tags = tags.finish(); + let vector = FixedSizeListArray::from_iter_primitive::( + [Some( + (0..12) + .map(|i| Some(i as f32 + 0.000123)) + .collect::>(), + )], + 12, + ); + let small = ListArray::from_iter_primitive::([Some(vec![ + Some(half::f16::from_f32(0.00693)), + None, + ])]); + let bytes = ListArray::from_iter_primitive::([Some(vec![ + Some(0), + Some(255), + None, + ])]); + let multivector = ListArray::new( + Arc::new(Field::new("item", vector.data_type().clone(), true)), + arrow_buffer::OffsetBuffer::new(vec![0, 1].into()), + Arc::new(vector.clone()), + None, + ); + let special = ListArray::from_iter_primitive::([Some(vec![ + Some(f32::NAN), + Some(f32::INFINITY), + Some(f32::NEG_INFINITY), + None, + ])]); + let nested = arrow_array::StructArray::from(vec![( + Arc::new(Field::new("tags", tags.data_type().clone(), true)), + { + let mut values = ListBuilder::new(StringBuilder::new()); + values.values().append_value("NULL"); + values.values().append_null(); + values.append(true); + Arc::new(values.finish()) as ArrayRef + }, + )]); + let batch = RecordBatch::try_from_iter(vec![ + ("tags", Arc::new(tags) as ArrayRef), + ("vector", Arc::new(vector)), + ("f16", Arc::new(small)), + ("bytes", Arc::new(bytes)), + ("multivector", Arc::new(multivector)), + ("struct", Arc::new(nested)), + ("special", Arc::new(special)), + ]) + .unwrap(); + let mut output = Vec::new(); + ResultFormatter::new(OutputFormat::Csv) + .format_record_batches(&[batch], &mut output) + .unwrap(); + let row = csv::Reader::from_reader(output.as_slice()) + .records() + .next() + .unwrap() + .unwrap(); + let values: Vec = row + .iter() + .map(|cell| serde_json::from_str(cell).unwrap()) + .collect(); + assert_eq!( + values[0], + serde_json::json!(["a,b", "\"quoted\"", "NULL", "", null]) + ); + assert_eq!(values[1].as_array().unwrap().len(), 12); + for (index, value) in values[1].as_array().unwrap().iter().enumerate() { + assert_eq!(value.as_f64().unwrap() as f32, index as f32 + 0.000123); + } + assert_eq!( + values[2][0].as_f64().unwrap() as f32, + half::f16::from_f32(0.00693).to_f32() + ); + assert!(values[2][1].is_null()); + assert_eq!(values[3], serde_json::json!([0, 255, null])); + assert_eq!(values[4], serde_json::json!([values[1]])); + assert_eq!(values[5], serde_json::json!({"tags": ["NULL", null]})); + assert_eq!(values[6], serde_json::json!(["NaN", "inf", "-inf", null])); + } + + #[test] + fn test_csv_distinguishes_nulls_from_empty_values() { + use arrow_array::types::{Float64Type, Int8Type}; + let dictionary = arrow_array::DictionaryArray::::try_new( + Int8Array::from(vec![Some(0), Some(1), None]), + Arc::new(StringArray::from(vec![None, Some("")])), + ) + .unwrap(); + let lists = ListArray::from_iter_primitive::([ + None, + Some(vec![]), + Some(vec![None]), + ]); + let batch = RecordBatch::try_from_iter(vec![ + ("null", Arc::new(arrow_array::NullArray::new(3)) as ArrayRef), + ("dictionary", Arc::new(dictionary)), + ("list", Arc::new(lists)), + ]) + .unwrap(); + let mut output = Vec::new(); + ResultFormatter::new(OutputFormat::Csv) + .format_record_batches(&[batch], &mut output) + .unwrap(); + assert_eq!( + String::from_utf8(output).unwrap(), + "null,dictionary,list\n\\N,\\N,\\N\n\\N,\"\",[]\n\\N,\\N,[null]\n" + ); + } + + #[test] + fn test_csv_preserves_single_column_null_rows() { + let original = vec![ + Some("before"), + None, + Some(""), + Some("NULL"), + Some(r"\N"), + Some(r"\\N"), + Some(r"\path"), + Some("after"), + ]; + let batch = RecordBatch::try_from_iter(vec![( + "v", + Arc::new(StringArray::from(original.clone())) as ArrayRef, + )]) + .unwrap(); + let mut output = Vec::new(); + ResultFormatter::new(OutputFormat::Csv) + .format_record_batches(&[batch], &mut output) + .unwrap(); + let rows = csv::Reader::from_reader(output.as_slice()) + .records() + .collect::, _>>() + .unwrap(); + assert_eq!(rows.len(), 8); + let values: Vec<_> = rows.iter().map(|row| &row[0]).collect(); + assert_eq!( + values, + ["before", r"\N", "", "NULL", r"\\N", r"\\\N", r"\\path", "after"] + ); + let decoded: Vec<_> = values + .iter() + .map(|&value| { + if value == CSV_NULL_VALUE { + None + } else if value.starts_with(r"\\") { + Some(&value[1..]) + } else { + Some(value) + } + }) + .collect(); + assert_eq!(decoded, original); + } + + #[test] + fn test_csv_preserves_nested_dictionary_nulls() { + use arrow_array::types::Int8Type; + use arrow_array::{Array, DictionaryArray, StructArray}; + + let dictionary = DictionaryArray::::try_new( + Int8Array::from(vec![Some(0), Some(1), None]), + Arc::new(StringArray::from(vec![None, Some("")])), + ) + .unwrap(); + let field = Arc::new(Field::new("item", dictionary.data_type().clone(), true)); + let list = ListArray::new( + field.clone(), + arrow_buffer::OffsetBuffer::new(vec![0, 3].into()), + Arc::new(dictionary.clone()), + None, + ); + let structs = StructArray::from(vec![(field, Arc::new(dictionary) as ArrayRef)]); + let struct_list = ListArray::new( + Arc::new(Field::new("item", structs.data_type().clone(), true)), + arrow_buffer::OffsetBuffer::new(vec![0, 3].into()), + Arc::new(structs), + None, + ); + let encoded_list = + DictionaryArray::::try_new(Int8Array::from(vec![0]), Arc::new(list.clone())) + .unwrap(); + let batch = RecordBatch::try_from_iter(vec![ + ("list", Arc::new(list) as ArrayRef), + ("structs", Arc::new(struct_list)), + ("encoded_list", Arc::new(encoded_list)), + ]) + .unwrap(); + let mut output = Vec::new(); + ResultFormatter::new(OutputFormat::Csv) + .format_record_batches(&[batch], &mut output) + .unwrap(); + let row = csv::Reader::from_reader(output.as_slice()) + .records() + .next() + .unwrap() + .unwrap(); + assert_eq!(&row[0], r#"[null,"",null]"#); + assert_eq!(&row[1], r#"[{"item":null},{"item":""},{"item":null}]"#); + assert_eq!(&row[2], r#"[null,"",null]"#); + } + + #[test] + fn test_csv_escapes_headers_and_carriage_returns_across_batches() { + let batch = RecordBatch::try_from_iter(vec![ + ( + "with,comma", + Arc::new(StringArray::from(vec!["with\rcarriage return"])) as ArrayRef, + ), + ( + "with\"quote", + Arc::new(StringArray::from(vec!["with\"quote"])), + ), + ( + "with\nnewline", + Arc::new(StringArray::from(vec!["with\nnewline"])), + ), + ]) + .unwrap(); + let mut output = Vec::new(); + ResultFormatter::new(OutputFormat::Csv) + .format_record_batches(&[batch.slice(0, 0), batch.clone(), batch], &mut output) + .unwrap(); + let mut reader = csv::Reader::from_reader(output.as_slice()); + assert_eq!( + reader.headers().unwrap(), + &csv::StringRecord::from(vec!["with,comma", "with\"quote", "with\nnewline"]) + ); + let rows = reader + .records() + .collect::, _>>() + .unwrap(); + assert_eq!(rows.len(), 2); + for row in rows { + assert_eq!( + row, + csv::StringRecord::from(vec![ + "with\rcarriage return", + "with\"quote", + "with\nnewline" + ]) + ); + } + } + + #[test] + fn test_table_preserves_small_float_precision() { + let batch = arrow_array::record_batch!(("small", Float64, [0.000123, 0.00693])).unwrap(); + let mut output = Vec::new(); + ResultFormatter::new(OutputFormat::Table) + .with_terminal_width(200) + .format_record_batches(&[batch], &mut output) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("0.000123")); + assert!(output.contains("0.00693")); + } + + #[test] + fn test_format_bytes() { + assert_eq!(format_bytes(0), "0 B"); + assert_eq!(format_bytes(1023), "1023 B"); + assert_eq!(format_bytes(1024), "1.0 KB"); + assert_eq!(format_bytes(1536), "1.5 KB"); + assert_eq!(format_bytes(1024 * 1024), "1.0 MB"); + assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB"); + } + + #[test] + fn test_format_data_type() { + assert_eq!(format_data_type(&DataType::Boolean), "BOOLEAN"); + assert_eq!(format_data_type(&DataType::Int32), "INT"); + assert_eq!(format_data_type(&DataType::Float64), "DOUBLE"); + assert_eq!(format_data_type(&DataType::Utf8), "TEXT"); + assert_eq!( + format_data_type(&DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::Float32, true)), + 128 + )), + "FLOAT[128]" + ); + } + + #[test] + fn test_sql_result_dml_insert() { + let formatter = ResultFormatter::new(OutputFormat::Table); + let dml = DmlResult::new("foo", DmlOperation::Insert, 1, 3); + let result = QueryResult::SqlResult(SqlResult::Dml(dml)); + + let output = formatter.format(&result).unwrap(); + assert_eq!(output, "INSERT 1 row(s), version 3\n"); + } + + #[test] + fn test_sql_result_dml_delete() { + let formatter = ResultFormatter::new(OutputFormat::Table); + let dml = DmlResult::new("bar", DmlOperation::Delete, 0, 4); + let result = QueryResult::SqlResult(SqlResult::Dml(dml)); + + let output = formatter.format(&result).unwrap(); + assert_eq!(output, "DELETE 0 row(s), version 4\n"); + } + + #[test] + fn test_sql_result_success() { + let formatter = ResultFormatter::new(OutputFormat::Table); + let result = QueryResult::SqlResult(SqlResult::Success); + + let output = formatter.format(&result).unwrap(); + assert_eq!(output, "Success\n"); + } + + #[test] + fn test_table_info_formatting() { + let formatter = ResultFormatter::new(OutputFormat::Table); + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new("email", DataType::Utf8, false), + Field::new( + "created_at", + DataType::Timestamp(arrow_schema::TimeUnit::Microsecond, None), + false, + ), + Field::new("score", DataType::Float64, true), + ])); + + let table_info = TableInfo { + name: "users".to_string(), + schema, + row_count: Some(1500), + size_bytes: Some(2 * 1024 * 1024), // 2 MB + num_indices: Some(3), + num_fragments: Some(2), + }; + + let result = QueryResult::TableInfo(table_info); + let output = formatter.format(&result).unwrap(); + + let expected = " Table \"users\" + Column | Type | Nullable +------------+-----------+---------- + id | INT | + email | TEXT | + created_at | TIMESTAMP | + score | DOUBLE | YES + +Rows: 1500 +Size: 2.0 MB +"; + + assert_eq!(output, expected); + } + + #[test] + fn test_data_type_formatting() { + let formatter = ResultFormatter::new(OutputFormat::Table); + + struct TestCase { + name: &'static str, + field: Field, + data: ArrayRef, + expected_values: Vec<&'static str>, + } + + let test_cases = vec![ + TestCase { + name: "timestamp", + field: Field::new( + "ts", + DataType::Timestamp(arrow_schema::TimeUnit::Microsecond, None), + false, + ), + data: Arc::new(arrow_array::TimestampMicrosecondArray::from(vec![ + 1609459200000000, // 2021-01-01 00:00:00 UTC + 1625097600000000, // 2021-07-01 00:00:00 UTC + ])) as ArrayRef, + expected_values: vec!["2021-01-01", "2021-07-01"], + }, + TestCase { + name: "date", + field: Field::new("date", DataType::Date64, false), + data: Arc::new(arrow_array::Date64Array::from(vec![ + 1622505600000, // 2021-06-01 + 1622592000000, // 2021-06-02 + ])) as ArrayRef, + expected_values: vec!["2021-06-01", "2021-06-02"], + }, + TestCase { + name: "largeutf8", + field: Field::new("text", DataType::LargeUtf8, false), + data: Arc::new(arrow_array::LargeStringArray::from(vec![ + "Hello, World!", + "Large UTF-8 String", + ])) as ArrayRef, + expected_values: vec!["Hello, World!", "Large UTF-8 String"], + }, + TestCase { + name: "utf8view", + field: Field::new("view", DataType::Utf8View, false), + data: Arc::new(arrow_array::StringViewArray::from(vec![ + "View String 1", + "View String 2", + ])) as ArrayRef, + expected_values: vec!["View String 1", "View String 2"], + }, + TestCase { + name: "struct", + field: Field::new( + "data", + DataType::Struct( + vec![ + Field::new("name", DataType::Utf8, false), + Field::new("age", DataType::Int32, false), + ] + .into(), + ), + false, + ), + data: Arc::new(arrow_array::StructArray::from(vec![ + ( + Arc::new(Field::new("name", DataType::Utf8, false)), + Arc::new(StringArray::from(vec!["Alice", "Bob"])) as ArrayRef, + ), + ( + Arc::new(Field::new("age", DataType::Int32, false)), + Arc::new(Int32Array::from(vec![30, 25])) as ArrayRef, + ), + ])) as ArrayRef, + expected_values: vec!["{name: Alice, age: 30}", "{name: Bob, age: 25}"], + }, + ]; + + for test_case in test_cases { + let schema = Arc::new(Schema::new(vec![test_case.field])); + let batch = RecordBatch::try_new(schema, vec![test_case.data]).unwrap(); + let result = QueryResult::SqlResult(SqlResult::Query(vec![batch])); + let output = formatter.format(&result).unwrap(); + + for expected in test_case.expected_values { + assert!( + output.contains(expected), + "Test case '{}' failed: expected output to contain '{}', but got:\n{}", + test_case.name, + expected, + output + ); + } + } + } + + #[test] + fn marked_columns_render_in_full() { + let formatter = ResultFormatter::new(OutputFormat::Table).with_terminal_width(500); + let long_string = format!("j1_{}", "a".repeat(300)); + + let schema = Arc::new(Schema::new(vec![ + Field::new("job_id", DataType::Utf8, false), + Field::new("state", DataType::Utf8, false), + ])); + let batch = RecordBatch::try_new( + schema, + vec![ + Arc::new(StringArray::from(vec![long_string.as_str()])) as ArrayRef, + Arc::new(StringArray::from(vec![long_string.as_str()])) as ArrayRef, + ], + ) + .unwrap(); + + let marked = mark_no_truncate(SqlResult::Query(vec![batch]), &["job_id"]); + let output = formatter.format(&QueryResult::SqlResult(marked)).unwrap(); + + assert!( + output.contains(&long_string), + "a marked column must render in full: {output}" + ); + assert!( + output.contains("...(303 chars)"), + "an unmarked column must still be abbreviated: {output}" + ); + } + + fn job_ids(result: &SqlResult) -> Vec { + let SqlResult::Query(batches) = result else { + panic!("expected a query result"); + }; + batches + .iter() + .flat_map(|batch| { + batch + .column(0) + .as_any() + .downcast_ref::() + .unwrap() + .values() + .to_vec() + }) + .collect() + } + + #[test] + fn taking_rows_cuts_across_batch_boundaries() { + let first = arrow_array::record_batch!(("job_id", Int32, [1, 2, 3])).unwrap(); + let second = arrow_array::record_batch!(("job_id", Int32, [4, 5, 6])).unwrap(); + let batches = vec![first, second]; + + assert_eq!(row_count(&SqlResult::Query(batches.clone())), 6); + + // A cut inside the second batch keeps the first whole and slices the + // second; a limit at or past the total is a no-op. + let capped = take_rows(SqlResult::Query(batches.clone()), 4); + assert_eq!(job_ids(&capped), vec![1, 2, 3, 4]); + assert_eq!( + job_ids(&take_rows(SqlResult::Query(batches.clone()), 6)), + vec![1, 2, 3, 4, 5, 6] + ); + assert_eq!( + job_ids(&take_rows(SqlResult::Query(batches), 99)), + vec![1, 2, 3, 4, 5, 6] + ); + } + + #[test] + fn taking_rows_leaves_non_query_results_alone() { + assert!(matches!( + take_rows(SqlResult::Success, 0), + SqlResult::Success + )); + assert_eq!(row_count(&SqlResult::Success), 0); + } + + #[test] + fn test_string_elision() { + let formatter = ResultFormatter::new(OutputFormat::Table); + + // Create a long string that should be elided + let long_string = "a".repeat(100); + let short_string = "short string"; + + let schema = Arc::new(Schema::new(vec![ + Field::new("short", DataType::Utf8, false), + Field::new("long", DataType::Utf8, false), + Field::new("long_large", DataType::LargeUtf8, false), + ])); + + let short_array = StringArray::from(vec![short_string]); + let long_array = StringArray::from(vec![long_string.as_str()]); + let long_large_array = arrow_array::LargeStringArray::from(vec![long_string.as_str()]); + + let batch = RecordBatch::try_new( + schema, + vec![ + Arc::new(short_array) as ArrayRef, + Arc::new(long_array) as ArrayRef, + Arc::new(long_large_array) as ArrayRef, + ], + ) + .unwrap(); + + let result = QueryResult::SqlResult(SqlResult::Query(vec![batch])); + let output = formatter.format(&result).unwrap(); + + // Short string should appear in full + assert!(output.contains(short_string)); + + // Long strings should be truncated with char count + assert!(output.contains("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...(100 chars)")); + assert!(!output.contains(&long_string)); // Full string should not appear + } + + #[test] + fn test_string_truncation_with_multibyte_utf8() { + let formatter = ResultFormatter::new(OutputFormat::Table); + + // The en-dash '–' is 3 bytes in UTF-8. Place it so byte index 50 falls + // inside the character, which would panic with naive &value[..50]. + // 48 ASCII bytes + '–' (3 bytes) + padding = well over 50 bytes + let multibyte_string = format!("{}{}", "x".repeat(48), "–".repeat(20)); + + let schema = Arc::new(Schema::new(vec![ + Field::new("text", DataType::Utf8, false), + Field::new("text_large", DataType::LargeUtf8, false), + Field::new("text_view", DataType::Utf8View, false), + ])); + + let utf8_array = StringArray::from(vec![multibyte_string.as_str()]); + let large_array = arrow_array::LargeStringArray::from(vec![multibyte_string.as_str()]); + let view_array = arrow_array::StringViewArray::from(vec![multibyte_string.as_str()]); + + let batch = RecordBatch::try_new( + schema, + vec![ + Arc::new(utf8_array) as ArrayRef, + Arc::new(large_array) as ArrayRef, + Arc::new(view_array) as ArrayRef, + ], + ) + .unwrap(); + + let result = QueryResult::SqlResult(SqlResult::Query(vec![batch])); + let output = formatter.format(&result).unwrap(); + + // Should not panic, and should contain truncation indicator + // 48 ASCII chars + 20 en-dashes = 68 chars total + assert!(output.contains("...(68 chars)")); + // The truncated prefix should end at a char boundary (48 x's, not mid en-dash) + assert!(output.contains(&"x".repeat(48))); + } + + #[test] + fn test_struct_formatting() { + let formatter = ResultFormatter::new(OutputFormat::Table).with_terminal_width(200); + + // Create a struct with multiple fields + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new( + "user_info", + DataType::Struct( + vec![ + Field::new("name", DataType::Utf8, false), + Field::new("age", DataType::Int32, false), + Field::new("email", DataType::Utf8, true), + ] + .into(), + ), + false, + ), + ])); + + let id_array = Int32Array::from(vec![1, 2]); + let name_array = StringArray::from(vec!["Alice", "Bob"]); + let age_array = Int32Array::from(vec![30, 25]); + let email_array = StringArray::from(vec![Some("alice@example.com"), None]); + + let struct_array = arrow_array::StructArray::from(vec![ + ( + Arc::new(Field::new("name", DataType::Utf8, false)), + Arc::new(name_array) as ArrayRef, + ), + ( + Arc::new(Field::new("age", DataType::Int32, false)), + Arc::new(age_array) as ArrayRef, + ), + ( + Arc::new(Field::new("email", DataType::Utf8, true)), + Arc::new(email_array) as ArrayRef, + ), + ]); + + let batch = RecordBatch::try_new( + schema, + vec![ + Arc::new(id_array) as ArrayRef, + Arc::new(struct_array) as ArrayRef, + ], + ) + .unwrap(); + + let result = QueryResult::SqlResult(SqlResult::Query(vec![batch])); + let output = formatter.format(&result).unwrap(); + + // Check the exact literal output for proper struct formatting + let expected = " id | user_info +----+-------------------------------------------------- + 1 | {name: Alice, age: 30, email: alice@example.com} + 2 | {name: Bob, age: 25, email: NULL} +(2 rows) +"; + + assert_eq!(output, expected); + } + + #[test] + fn test_index_list_detailed_formatting() { + // Pin a wide terminal so the table renders columnar ("hotdog") rather + // than auto-expanding ("hamburger") in a narrow terminal — keeps the + // golden output independent of the ambient terminal width. + let formatter = ResultFormatter::new(OutputFormat::Table).with_terminal_width(200); + let indices = vec![ + ( + IndexInfo { + name: "idx_embeddings".to_string(), + table_name: "documents".to_string(), + index_type: "IVF_PQ".to_string(), + columns: vec!["embedding".to_string()], + }, + Some(IndexStats { + num_indexed_rows: 10000, + num_unindexed_rows: 500, + distance_type: Some("L2".to_string()), + }), + ), + ( + IndexInfo { + name: "idx_created_at".to_string(), + table_name: "documents".to_string(), + index_type: "BTREE".to_string(), + columns: vec!["created_at".to_string()], + }, + None, + ), + ]; + let result = QueryResult::IndexListDetailed(indices); + + let output = formatter.format(&result).unwrap(); + + let expected = " List of relations + Name | Type | Distance | Indexed Rows | Unindexed Rows | Table +----------------+--------+----------+--------------+----------------+----------- + idx_embeddings | IVF_PQ | L2 | 10000 | 500 | documents + idx_created_at | BTREE | | | | documents +(2 rows) +"; + + assert_eq!(output, expected); + } +} diff --git a/rust/lancedb/cli/src/lib.rs b/rust/lancedb/cli/src/lib.rs new file mode 100644 index 000000000..46a32122e --- /dev/null +++ b/rust/lancedb/cli/src/lib.rs @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +pub mod auth; +pub mod cli; +pub mod client; +pub mod dml_result; +pub mod formatter; +pub mod mocks; +pub mod profile; +pub mod server_config; +pub mod sql_executor; +pub mod table_writer; + +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +pub use cli::{run, Args, Commands}; +pub(crate) fn format_host_port(host: &str, port: impl std::fmt::Display) -> String { + match host.parse::() { + Ok(std::net::IpAddr::V6(_)) => format!("[{host}]:{port}"), + _ => format!("{host}:{port}"), + } +} + +pub(crate) fn lancedb_client_config( + tls: &server_config::TlsOptions, + authentication: &server_config::Authentication, +) -> Option { + if tls == &server_config::TlsOptions::default() + && matches!(authentication, server_config::Authentication::ApiKey(_)) + { + return None; + } + let mut config = lancedb::remote::ClientConfig::default(); + if tls != &server_config::TlsOptions::default() { + config.tls_config = Some(lancedb::remote::TlsConfig { + ssl_ca_cert: tls.ca_cert.clone(), + cert_file: tls.client_cert.clone(), + key_file: tls.client_key.clone(), + ..Default::default() + }); + } + if !matches!(authentication, server_config::Authentication::ApiKey(_)) { + config.header_provider = Some(std::sync::Arc::new(authentication.clone())); + } + Some(config) +} + +static BUILD_VERSION: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new(); +/// Set the version displayed by shared command handlers when embedded in another binary. +pub fn set_build_version(version: &'static str) { + let _ = BUILD_VERSION.set(version); +} +pub fn build_version() -> &'static str { + BUILD_VERSION.get().copied().unwrap_or(VERSION) +} diff --git a/rust/lancedb/cli/src/main.rs b/rust/lancedb/cli/src/main.rs new file mode 100644 index 000000000..01f65e386 --- /dev/null +++ b/rust/lancedb/cli/src/main.rs @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use clap::Parser; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let _ = rustls::crypto::ring::default_provider().install_default(); + lancedb_cli::run(lancedb_cli::Args::parse()).await +} diff --git a/rust/lancedb/cli/src/mocks.rs b/rust/lancedb/cli/src/mocks.rs new file mode 100644 index 000000000..ac8a66e7b --- /dev/null +++ b/rust/lancedb/cli/src/mocks.rs @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +/// Mock implementations for testing +#[cfg(test)] +pub mod test_mocks { + use crate::sql_executor::{SqlQueryExecutor, SqlResult}; + use anyhow::Result; + use arrow_array::RecordBatch; + use arrow_schema::{DataType, Field, Schema}; + use async_trait::async_trait; + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + /// Mock SQL executor for testing + pub struct MockSqlExecutor { + pub is_connected: bool, + pub queries: Arc>>, + pub query_results: HashMap>, + /// URLs passed to `connect`, in call order. + pub connected_urls: Arc>>, + } + + impl Default for MockSqlExecutor { + fn default() -> Self { + Self::new() + } + } + + impl MockSqlExecutor { + pub fn new() -> Self { + Self { + is_connected: false, + queries: Arc::new(Mutex::new(Vec::new())), + query_results: HashMap::new(), + connected_urls: Arc::new(Mutex::new(Vec::new())), + } + } + + pub fn set_query_result(&mut self, query: &str, result: Vec) { + self.query_results.insert(query.to_string(), result); + } + + pub fn get_executed_queries(&self) -> Vec { + self.queries.lock().unwrap().clone() + } + + pub fn get_connected_urls(&self) -> Vec { + self.connected_urls.lock().unwrap().clone() + } + } + + #[async_trait] + impl SqlQueryExecutor for MockSqlExecutor { + async fn execute_query(&mut self, query: &str) -> Result { + self.queries.lock().unwrap().push(query.to_string()); + + if let Some(result) = self.query_results.get(query) { + Ok(SqlResult::Query(result.clone())) + } else { + // Return empty result by default + Ok(SqlResult::Query(vec![])) + } + } + + async fn is_connected(&self) -> bool { + self.is_connected + } + + fn connection_info(&self) -> Option { + if self.is_connected { + Some("Mock SQL connection".to_string()) + } else { + None + } + } + + async fn connect(&mut self, server_url: &str) -> Result<()> { + self.connected_urls + .lock() + .unwrap() + .push(server_url.to_string()); + self.is_connected = true; + Ok(()) + } + + fn set_database(&mut self, _database: String) { + // Mock implementation - no-op + } + } + + /// Helper function to create a simple test schema + pub fn create_test_schema() -> Arc { + Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), + Field::new("name", DataType::Utf8, false), + Field::new("value", DataType::Float64, true), + ])) + } + + /// Helper function to create test RecordBatch + pub fn create_test_batch() -> RecordBatch { + use arrow_array::{Float64Array, Int64Array, StringArray}; + + let id_array = Int64Array::from(vec![1, 2, 3]); + let name_array = StringArray::from(vec!["Alice", "Bob", "Charlie"]); + let value_array = Float64Array::from(vec![Some(10.5), None, Some(20.0)]); + + RecordBatch::try_new( + create_test_schema(), + vec![ + Arc::new(id_array), + Arc::new(name_array), + Arc::new(value_array), + ], + ) + .unwrap() + } +} diff --git a/rust/lancedb/cli/src/profile.rs b/rust/lancedb/cli/src/profile.rs new file mode 100644 index 000000000..55e854c68 --- /dev/null +++ b/rust/lancedb/cli/src/profile.rs @@ -0,0 +1,494 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use std::borrow::Cow; +use std::io::{self, Write}; + +use anyhow::{anyhow, Result}; +use clap::{Args, Subcommand}; +use rustyline::highlight::Highlighter; +use rustyline::{Editor, Helper}; + +use crate::server_config::{Config, Profile}; + +#[derive(Args, Debug)] +pub struct ProfileArgs { + #[command(subcommand)] + pub command: ProfileCommand, +} + +/// The settable fields of a profile, shared by `create` and `edit`. +/// +/// `create` writes them all; `edit` applies only the ones that were passed. +#[derive(Args, Debug, Default)] +pub struct ProfileFields { + /// SQL server URL (Flight SQL endpoint), as host:port or grpc(s)://host:port + #[arg(long)] + pub sql_server_url: Option, + + /// HTTP server URL (REST endpoint), as host:port or http(s)://host:port + #[arg(long)] + pub http_server_url: Option, + + /// API key for authentication + #[arg(long, conflicts_with = "oidc_access_token_file")] + pub api_key: Option, + + /// Path to a file containing an OIDC access token + #[arg(long, value_name = "PATH", conflicts_with = "api_key")] + pub oidc_access_token_file: Option, + + /// Default database name + #[arg(long, short)] + pub database: Option, + + /// Azure storage account name for Azure-backed databases + #[arg(long)] + pub azure_storage_account_name: Option, + + /// Path to a PEM CA certificate trusted in addition to the platform roots + #[arg(long, value_name = "PATH")] + pub ca_cert: Option, + + /// Path to a PEM client certificate presented for mTLS + #[arg(long, value_name = "PATH")] + pub client_cert: Option, + + /// Path to the PKCS#8 PEM private key for the client certificate + #[arg(long, value_name = "PATH")] + pub client_key: Option, +} + +impl ProfileFields { + /// Whether any field was supplied, which is what makes `create` + /// non-interactive. Destructured so a new field cannot be forgotten here. + fn any_set(&self) -> bool { + let Self { + sql_server_url, + http_server_url, + api_key, + oidc_access_token_file, + database, + azure_storage_account_name, + ca_cert, + client_cert, + client_key, + } = self; + [ + sql_server_url, + http_server_url, + api_key, + oidc_access_token_file, + database, + azure_storage_account_name, + ca_cert, + client_cert, + client_key, + ] + .iter() + .any(|field| field.is_some()) + } +} + +impl From<&ProfileFields> for Profile { + fn from(fields: &ProfileFields) -> Self { + Self { + sql_server_url: fields.sql_server_url.clone(), + http_server_url: fields.http_server_url.clone(), + api_key: fields.api_key.clone(), + oidc_access_token_file: fields.oidc_access_token_file.clone(), + oidc: None, + database: fields.database.clone(), + azure_storage_account_name: fields.azure_storage_account_name.clone(), + ca_cert: fields.ca_cert.clone(), + client_cert: fields.client_cert.clone(), + client_key: fields.client_key.clone(), + } + } +} + +#[derive(Subcommand, Debug)] +pub enum ProfileCommand { + /// List all configured profiles + List, + + /// Show details of a specific profile + Show { + /// Name of the profile to show (uses default if not specified) + name: Option, + }, + + /// Create a new profile (interactive by default) + Create { + /// Name for the new profile + name: String, + + #[command(flatten)] + fields: ProfileFields, + }, + + /// Set the default profile + #[command(name = "set")] + SetDefault { + /// Name of the profile to set as default + name: String, + }, + + /// Delete a profile + Delete { + /// Name of the profile to delete + name: String, + }, + + /// Edit an existing profile + Edit { + /// Name of the profile to edit + name: String, + + #[command(flatten)] + fields: ProfileFields, + }, +} + +pub async fn run_profile_command(args: ProfileArgs) -> Result<()> { + match args.command { + ProfileCommand::List => list_profiles(), + ProfileCommand::Show { name } => show_profile(name), + ProfileCommand::Create { name, fields } => { + // If any flags are provided, use non-interactive mode + if fields.any_set() { + create_profile(&name, Profile::from(&fields)) + } else { + create_profile_interactive(&name) + } + } + ProfileCommand::SetDefault { name } => set_default_profile(&name), + ProfileCommand::Delete { name } => delete_profile(&name), + ProfileCommand::Edit { name, fields } => edit_profile(&name, Profile::from(&fields)), + } +} + +fn list_profiles() -> Result<()> { + let config = Config::load_default()?; + let default_profile = config.default_profile.as_deref().unwrap_or("local"); + + println!("Configured profiles:"); + + // Always show "local" as it's built-in + let is_default = default_profile == "local"; + let default_marker = if is_default { "*" } else { " " }; + let in_config = if config.profiles.contains_key("local") { + "" + } else { + " [built-in]" + }; + println!(" {} local{}", default_marker, in_config); + + // Show configured profiles + let mut profile_names: Vec<_> = config.profiles.keys().collect(); + profile_names.sort(); + + for name in profile_names { + if name == "local" { + continue; // Already shown above + } + let is_default = default_profile == name; + let default_marker = if is_default { "*" } else { " " }; + println!(" {} {}", default_marker, name); + } + + if config.profiles.is_empty() + || (config.profiles.len() == 1 && config.profiles.contains_key("local")) + { + println!(); + println!("No custom profiles configured. Use 'lancedb profile create ' to add one."); + } + + Ok(()) +} + +fn show_profile(name: Option) -> Result<()> { + let config = Config::load_default()?; + let profile_name = name + .as_deref() + .or(config.default_profile.as_deref()) + .unwrap_or("local"); + + let profile = config.get_profile(Some(profile_name)); + let is_builtin = !config.profiles.contains_key(profile_name) && profile_name == "local"; + + let builtin_marker = if is_builtin { " [built-in]" } else { "" }; + println!("Profile: {}{}", profile_name, builtin_marker); + println!( + " sql_server_url: {}", + profile.sql_server_url.as_deref().unwrap_or("") + ); + println!( + " http_server_url: {}", + profile.http_server_url.as_deref().unwrap_or("") + ); + println!( + " api_key: {}", + profile + .api_key + .as_ref() + .map(|_| "********") + .unwrap_or("") + ); + println!( + " oidc_access_token_file: {}", + profile + .oidc_access_token_file + .as_deref() + .unwrap_or("") + ); + if let Some(oidc) = &profile.oidc { + println!(" oidc: {}", toml::to_string(oidc)?); + } + println!( + " database: {}", + profile.database.as_deref().unwrap_or("") + ); + println!( + " azure_storage_account_name: {}", + profile + .azure_storage_account_name + .as_deref() + .unwrap_or("") + ); + println!( + " ca_cert: {}", + profile.ca_cert.as_deref().unwrap_or("") + ); + println!( + " client_cert: {}", + profile.client_cert.as_deref().unwrap_or("") + ); + println!( + " client_key: {}", + profile.client_key.as_deref().unwrap_or("") + ); + + Ok(()) +} + +fn create_profile(name: &str, profile: Profile) -> Result<()> { + let mut config = Config::load_default()?; + + if config.profiles.contains_key(name) { + return Err(anyhow!( + "Profile '{}' already exists. Use 'lancedb profile edit {}' to modify it.", + name, + name + )); + } + + config.set_profile(name, profile); + config.save_default()?; + + println!("Created profile '{}'", name); + Ok(()) +} + +fn create_profile_interactive(name: &str) -> Result<()> { + let mut config = Config::load_default()?; + + if config.profiles.contains_key(name) { + return Err(anyhow!( + "Profile '{}' already exists. Use 'lancedb profile edit {}' to modify it.", + name, + name + )); + } + + println!("Creating profile '{}'", name); + println!("Press Enter to skip optional fields."); + println!(); + + let sql_server_url = + prompt_optional("SQL server URL (host:port, or grpcs://host:port for TLS)")?; + let http_server_url = + prompt_optional("HTTP server URL (host:port, or https://host:port for TLS)")?; + let api_key = prompt_secret("API key")?; + let oidc_access_token_file = if api_key.is_none() { + prompt_optional("OIDC access token file")? + } else { + None + }; + let database = prompt_optional("Default database")?; + let azure_storage_account_name = prompt_optional("Azure storage account name")?; + let ca_cert = prompt_optional("CA certificate path (PEM, for a private CA)")?; + let client_cert = prompt_optional("Client certificate path (PEM, for mTLS)")?; + let client_key = prompt_optional("Client key path (PKCS#8 PEM, for mTLS)")?; + + let profile = Profile { + sql_server_url, + http_server_url, + api_key, + oidc_access_token_file, + oidc: None, + database, + azure_storage_account_name, + ca_cert, + client_cert, + client_key, + }; + config.set_profile(name, profile); + config.save_default()?; + + println!(); + println!("Created profile '{}'", name); + Ok(()) +} + +fn set_default_profile(name: &str) -> Result<()> { + let mut config = Config::load_default()?; + + // Verify profile exists (or is built-in "local") + if name != "local" && !config.profiles.contains_key(name) { + return Err(anyhow!( + "Profile '{}' does not exist. Use 'lancedb profile list' to see available profiles.", + name + )); + } + + // Persist the addresses when localhost is explicitly selected, so a later + // default SQL invocation can distinguish this choice from an unset endpoint. + if name == "local" && !config.profiles.contains_key(name) { + config.set_profile(name, Profile::local_default()); + } + config.set_default_profile(name); + config.save_default()?; + + println!("Set '{}' as the default profile", name); + Ok(()) +} + +fn delete_profile(name: &str) -> Result<()> { + if name == "local" { + return Err(anyhow!("Cannot delete the built-in 'local' profile")); + } + + let mut config = Config::load_default()?; + + if !config.profiles.contains_key(name) { + return Err(anyhow!("Profile '{}' does not exist", name)); + } + + config.remove_profile(name); + + // If we deleted the default profile, reset to "local" + if config.default_profile.as_deref() == Some(name) { + config.set_default_profile("local"); + println!("Default profile reset to 'local'"); + } + + config.save_default()?; + + println!("Deleted profile '{}'", name); + Ok(()) +} + +fn edit_profile(name: &str, updates: Profile) -> Result<()> { + let mut config = Config::load_default()?; + + // Get existing profile or create from built-in + let mut profile = if config.profiles.contains_key(name) { + config.profiles.get(name).cloned().unwrap() + } else if name == "local" { + Profile::local_default() + } else { + return Err(anyhow!( + "Profile '{}' does not exist. Use 'lancedb profile create {}' to create it.", + name, + name + )); + }; + + // Update only the fields that were provided + let Profile { + sql_server_url, + http_server_url, + api_key, + oidc_access_token_file, + oidc, + database, + azure_storage_account_name, + ca_cert, + client_cert, + client_key, + } = updates; + profile.sql_server_url = sql_server_url.or(profile.sql_server_url); + profile.http_server_url = http_server_url.or(profile.http_server_url); + if let Some(api_key) = api_key { + profile.oidc = None; + profile.api_key = Some(api_key); + profile.oidc_access_token_file = None; + } else if let Some(oidc_access_token_file) = oidc_access_token_file { + profile.oidc = None; + profile.api_key = None; + profile.oidc_access_token_file = Some(oidc_access_token_file); + } + if let Some(oidc) = oidc { + profile.api_key = None; + profile.oidc_access_token_file = None; + profile.oidc = Some(oidc); + } + profile.database = database.or(profile.database); + profile.azure_storage_account_name = + azure_storage_account_name.or(profile.azure_storage_account_name); + profile.ca_cert = ca_cert.or(profile.ca_cert); + profile.client_cert = client_cert.or(profile.client_cert); + profile.client_key = client_key.or(profile.client_key); + + config.set_profile(name, profile); + config.save_default()?; + + println!("Updated profile '{}'", name); + Ok(()) +} + +fn prompt_optional(field: &str) -> Result> { + print!("{}: ", field); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + let value = input.trim(); + if value.is_empty() { + Ok(None) + } else { + Ok(Some(value.to_string())) + } +} + +fn prompt_secret(field: &str) -> Result> { + struct MaskHelper; + impl Highlighter for MaskHelper { + fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> { + Cow::Owned("*".repeat(line.len())) + } + fn highlight_char(&self, _line: &str, _pos: usize, _forced: bool) -> bool { + true + } + } + impl rustyline::completion::Completer for MaskHelper { + type Candidate = String; + } + impl rustyline::hint::Hinter for MaskHelper { + type Hint = String; + } + impl rustyline::validate::Validator for MaskHelper {} + impl Helper for MaskHelper {} + + let mut editor = Editor::new()?; + editor.set_helper(Some(MaskHelper)); + let input = editor.readline(&format!("{}: ", field))?; + let value = input.trim(); + if value.is_empty() { + Ok(None) + } else { + Ok(Some(value.to_string())) + } +} diff --git a/rust/lancedb/cli/src/server_config.rs b/rust/lancedb/cli/src/server_config.rs new file mode 100644 index 000000000..eb483c44e --- /dev/null +++ b/rust/lancedb/cli/src/server_config.rs @@ -0,0 +1,1310 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use anyhow::{anyhow, bail, Context, Result}; +use clap::Args; +use directories::UserDirs; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; +use std::path::{Path, PathBuf}; + +/// The default profile name used when no profile is specified +pub const DEFAULT_PROFILE: &str = "local"; + +/// Port assumed for a Flight SQL address that carries neither scheme nor port. +pub const DEFAULT_SQL_PORT: u16 = 10025; + +/// Port assumed for a REST address that carries neither scheme nor port. +pub const DEFAULT_HTTP_PORT: u16 = 10024; + +const DEFAULT_API_KEY: &str = "sk_localtest"; +const API_KEY_ENV: &str = "LANCEDB_API_KEY"; +const OIDC_ACCESS_TOKEN_ENV: &str = "LANCEDB_OIDC_ACCESS_TOKEN"; +const OIDC_ACCESS_TOKEN_FILE_ENV: &str = "LANCEDB_OIDC_ACCESS_TOKEN_FILE"; + +/// Authentication material selected for a CLI connection. +#[derive(Clone, PartialEq, Eq)] +pub enum Authentication { + ApiKey(String), + OidcAccessToken(String), + OidcAccessTokenFile(PathBuf), + ManagedOidc(std::sync::Arc), +} + +impl std::fmt::Debug for Authentication { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ApiKey(_) => f.write_str("ApiKey()"), + Self::OidcAccessToken(_) => f.write_str("OidcAccessToken()"), + Self::OidcAccessTokenFile(path) => { + f.debug_tuple("OidcAccessTokenFile").field(path).finish() + } + Self::ManagedOidc(session) => f.debug_tuple("ManagedOidc").field(session).finish(), + } + } +} + +/// Transport scheme of a server address. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scheme { + Http, + Https, +} + +impl Scheme { + pub fn as_str(&self) -> &'static str { + match self { + Self::Http => "http", + Self::Https => "https", + } + } + + /// Port to use when the address names this scheme but no port. + fn default_port(&self) -> u16 { + match self { + Self::Http => 80, + Self::Https => 443, + } + } +} + +impl std::fmt::Display for Scheme { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A server address split into the parts needed to rebuild a connection URL. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Endpoint { + pub scheme: Scheme, + /// Host without IPv6 brackets, as it would appear in a certificate. + pub host: String, + pub port: u16, +} + +impl Endpoint { + /// `host:port`, bracketing IPv6 literals. + pub fn host_port(&self) -> String { + crate::format_host_port(&self.host, self.port) + } + + /// Full `scheme://host:port` URL. + pub fn url(&self) -> String { + format!("{}://{}", self.scheme, self.host_port()) + } + + pub fn is_tls(&self) -> bool { + self.scheme == Scheme::Https + } +} + +impl std::fmt::Display for Endpoint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.url()) + } +} + +/// Parse a user-supplied REST address into scheme, host, and port. +/// +/// An explicit `https://` prefix is how a user asks for TLS; without a scheme +/// the address is assumed to be plaintext. `default_port` applies only when the +/// address names neither a scheme nor a port, so `https://host` still resolves +/// to 443 rather than to a LanceDB-specific default. +pub fn parse_endpoint(addr: &str, default_port: u16) -> Result { + parse_endpoint_with_schemes(addr, default_port, &["http", "https"]) +} + +/// Parse a user-supplied Flight SQL address, which additionally accepts the +/// gRPC scheme names. `grpc://` and `grpcs://` are aliases for `http://` and +/// `https://`: gRPC runs over the same HTTP/2 transport, and only the TLS +/// distinction reaches the connection. +pub fn parse_grpc_endpoint(addr: &str, default_port: u16) -> Result { + parse_endpoint_with_schemes(addr, default_port, &["http", "https", "grpc", "grpcs"]) +} + +fn parse_endpoint_with_schemes( + addr: &str, + default_port: u16, + accepted: &[&str], +) -> Result { + let addr = addr.trim(); + + let (scheme, rest, scheme_was_explicit) = match addr.split_once("://") { + Some((scheme, rest)) => { + let scheme = scheme.to_ascii_lowercase(); + if !accepted.contains(&scheme.as_str()) { + bail!( + "Unsupported scheme '{scheme}' in server address '{addr}'; expected one of {}", + accepted.join(", ") + ); + } + let scheme = match scheme.as_str() { + "https" | "grpcs" => Scheme::Https, + _ => Scheme::Http, + }; + (scheme, rest, true) + } + None => (Scheme::Http, addr, false), + }; + + // Drop any path, query, or fragment: only the authority is dialed. + let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); + if authority.is_empty() { + bail!("Server address '{addr}' is missing a host"); + } + + let (host, port) = split_host_port(authority, addr)?; + let port = port.unwrap_or(if scheme_was_explicit { + scheme.default_port() + } else { + default_port + }); + + Ok(Endpoint { scheme, host, port }) +} + +/// Split an authority into host and optional port, accepting bracketed IPv6 +/// (`[::1]:10025`) and bare IPv6 literals (`::1`). +fn split_host_port(authority: &str, addr: &str) -> Result<(String, Option)> { + let parse_port = |port: &str| -> Result { + port.parse::() + .with_context(|| format!("Invalid port '{port}' in server address '{addr}'")) + }; + + if let Some(rest) = authority.strip_prefix('[') { + let (host, rest) = rest + .split_once(']') + .ok_or_else(|| anyhow!("Unterminated IPv6 address in server address '{addr}'"))?; + let port = match rest { + "" => None, + _ => Some(parse_port(rest.strip_prefix(':').ok_or_else(|| { + anyhow!("Expected ':port' after IPv6 address in server address '{addr}'") + })?)?), + }; + return Ok((host.to_string(), port)); + } + + // More than one colon means a bare IPv6 literal; a port would require brackets. + match authority.split_once(':') { + Some((host, port)) if !port.contains(':') => { + Ok((host.to_string(), Some(parse_port(port)?))) + } + _ => Ok((authority.to_string(), None)), + } +} + +/// Client-side TLS material, shared by every transport the CLI dials. +/// +/// The two settings are independent: `ca_cert` decides whether the client +/// trusts the *server's* certificate, while `client_cert`/`client_key` are what +/// the client presents so the *server* can authenticate it (mTLS). A deployment +/// using a private CA for both directions needs both. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TlsOptions { + /// PEM CA certificate trusted in addition to the platform roots. + pub ca_cert: Option, + /// PEM client certificate presented for mTLS. + pub client_cert: Option, + /// PKCS#8 PEM private key for `client_cert`. + pub client_key: Option, +} + +impl TlsOptions { + /// The client certificate and key, read from disk. `None` when no client + /// identity is configured. + pub fn read_client_identity(&self) -> Result, Vec)>> { + let (Some(cert), Some(key)) = (&self.client_cert, &self.client_key) else { + return Ok(None); + }; + let cert_pem = fs::read(cert) + .with_context(|| format!("Failed to read client certificate '{cert}'"))?; + let key_pem = + fs::read(key).with_context(|| format!("Failed to read client key '{key}'"))?; + Ok(Some((cert_pem, key_pem))) + } + + /// The CA certificate, read from disk. + pub fn read_ca_cert(&self) -> Result>> { + let Some(ca_cert) = &self.ca_cert else { + return Ok(None); + }; + let pem = fs::read(ca_cert) + .with_context(|| format!("Failed to read CA certificate '{ca_cert}'"))?; + Ok(Some(pem)) + } +} + +/// Resolved connection settings with guaranteed values. +/// +/// This is returned by `ConnectionArgs::resolve()` after applying +/// the precedence rules: CLI/env args > profile > defaults. +#[derive(Debug, Clone)] +pub struct ResolvedConnection { + pub sql_server_url: String, + pub http_server_url: String, + pub database: String, + pub authentication: Authentication, + pub azure_storage_account_name: Option, + pub tls: TlsOptions, + pub profile_name: String, +} + +/// Common connection arguments that can be embedded in command structs. +/// +/// Use `#[command(flatten)]` to embed these in your command's args struct. +#[derive(Args, Debug, Clone, Default)] +pub struct ConnectionArgs { + /// Connection profile to use (from ~/.lancedb/config.toml) + #[arg(short = 'p', long, value_name = "PROFILE")] + pub profile: Option, + + /// SQL Server URL (Flight SQL endpoint), as host:port or grpc(s)://host:port - overrides profile + #[arg(long, value_name = "URL")] + pub sql_server_url: Option, + + /// HTTP Server URL, as host:port or http(s)://host:port - overrides profile + #[arg(long, value_name = "URL")] + pub http_server_url: Option, + + /// Database name - overrides profile + #[arg(short = 'd', long)] + pub database: Option, + + /// API key - overrides profile and authentication environment variables + /// + /// Without explicit credential flags, environment precedence is LANCEDB_API_KEY, + /// LANCEDB_OIDC_ACCESS_TOKEN, then LANCEDB_OIDC_ACCESS_TOKEN_FILE. + #[arg(long, conflicts_with = "oidc_access_token_file")] + pub api_key: Option, + + /// File containing an OIDC access token - overrides profile and authentication environment variables + #[arg(long, value_name = "PATH", conflicts_with = "api_key")] + pub oidc_access_token_file: Option, + + /// Azure storage account name - overrides profile + #[arg(long, env = "AZURE_STORAGE_ACCOUNT_NAME")] + pub azure_storage_account_name: Option, + + /// Path to a PEM CA certificate that signed the server's TLS certificate, + /// trusted in addition to the platform roots - overrides profile + #[arg(long, value_name = "PATH", env = "LANCEDB_CA_CERT")] + pub ca_cert: Option, + + /// Path to a PEM client certificate to present for mTLS; requires + /// --client-key - overrides profile + #[arg(long, value_name = "PATH", env = "LANCEDB_CLIENT_CERT")] + pub client_cert: Option, + + /// Path to the PKCS#8 PEM private key for --client-cert - overrides profile + #[arg(long, value_name = "PATH", env = "LANCEDB_CLIENT_KEY")] + pub client_key: Option, +} + +/// The database a connection uses when none is configured: the system username. +pub fn default_database_name() -> String { + std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_else(|_| "default".to_string()) +} + +impl ConnectionArgs { + /// Resolve connection settings from profile with a custom default database. + /// + /// Precedence: CLI/env args > profile > defaults + pub fn resolve(&self, default_database: Option<&str>) -> Result { + let config = Config::load_default()?; + self.resolve_with_config(&config, default_database) + } + + /// Resolve SQL only when an endpoint was supplied by a flag or saved + /// profile. The built-in localhost endpoint requires `--profile local`. + pub(crate) fn resolve_sql(&self) -> Result> { + self.resolve_sql_with_config(&Config::load_default()?) + } + + fn resolve_sql_with_config(&self, config: &Config) -> Result> { + let profile_name = self + .profile + .as_deref() + .or(config.default_profile.as_deref()) + .unwrap_or(DEFAULT_PROFILE); + let endpoint = self.sql_server_url.as_deref().or_else(|| { + config + .profiles + .get(profile_name) + .and_then(|profile| profile.sql_server_url.as_deref()) + }); + if let Some(endpoint) = endpoint { + if endpoint.trim().is_empty() { + bail!("Flight SQL endpoint cannot be empty"); + } + } else if self.profile.as_deref() != Some(DEFAULT_PROFILE) { + return Ok(None); + } + self.resolve_with_config(config, None).map(Some) + } + + fn resolve_with_config( + &self, + config: &Config, + default_database: Option<&str>, + ) -> Result { + // Determine the effective profile name + let profile_name = self + .profile + .clone() + .or_else(|| config.default_profile.clone()) + .unwrap_or_else(|| DEFAULT_PROFILE.to_string()); + + let profile = config.get_profile(self.profile.as_deref()); + let authentication = self.resolve_authentication(&profile, &profile_name)?; + + let sql_server_url = self + .sql_server_url + .clone() + .or(profile.sql_server_url) + .unwrap_or_else(|| "localhost:10025".to_string()); + + let http_server_url = self + .http_server_url + .clone() + .or(profile.http_server_url) + .unwrap_or_else(|| "localhost:10024".to_string()); + + let database = self + .database + .clone() + .or(profile.database) + .unwrap_or_else(|| { + default_database + .map(|s| s.to_string()) + .unwrap_or_else(default_database_name) + }); + + let azure_storage_account_name = self + .azure_storage_account_name + .clone() + .or(profile.azure_storage_account_name); + + // A client certificate is unusable without its key, so resolve the pair + // together rather than letting one side come from the profile and the + // other from a flag. + let (client_cert, client_key) = match (&self.client_cert, &self.client_key) { + (None, None) => (profile.client_cert, profile.client_key), + (cert, key) => (cert.clone(), key.clone()), + }; + if client_cert.is_some() != client_key.is_some() { + bail!("--client-cert and --client-key must be provided together"); + } + + let tls = TlsOptions { + ca_cert: self.ca_cert.clone().or(profile.ca_cert), + client_cert, + client_key, + }; + + Ok(ResolvedConnection { + sql_server_url, + http_server_url, + database, + authentication, + azure_storage_account_name, + tls, + profile_name, + }) + } + + fn resolve_authentication( + &self, + profile: &Profile, + profile_name: &str, + ) -> Result { + self.resolve_authentication_with_env(profile, profile_name, |name| std::env::var_os(name)) + } + + fn resolve_authentication_with_env( + &self, + profile: &Profile, + profile_name: &str, + getenv: impl Fn(&str) -> Option, + ) -> Result { + if self.api_key.is_some() && self.oidc_access_token_file.is_some() { + bail!("--api-key and --oidc-access-token-file are mutually exclusive"); + } + profile.validate_authentication()?; + + if let Some(api_key) = &self.api_key { + return Ok(Authentication::ApiKey(nonempty_secret(api_key, "API key")?)); + } + if let Some(path) = &self.oidc_access_token_file { + return Ok(Authentication::OidcAccessTokenFile(path.into())); + } + if let Some(api_key) = getenv(API_KEY_ENV) { + return Ok(Authentication::ApiKey(nonempty_os_secret( + api_key, + API_KEY_ENV, + )?)); + } + if let Some(token) = getenv(OIDC_ACCESS_TOKEN_ENV) { + return Ok(Authentication::OidcAccessToken(nonempty_os_secret( + token, + OIDC_ACCESS_TOKEN_ENV, + )?)); + } + if let Some(path) = getenv(OIDC_ACCESS_TOKEN_FILE_ENV) { + let path = path + .into_string() + .map_err(|_| anyhow!("{OIDC_ACCESS_TOKEN_FILE_ENV} is not valid UTF-8"))?; + return Ok(Authentication::OidcAccessTokenFile(path.into())); + } + if let Some(api_key) = &profile.api_key { + return Ok(Authentication::ApiKey(nonempty_secret(api_key, "API key")?)); + } + if let Some(path) = &profile.oidc_access_token_file { + return Ok(Authentication::OidcAccessTokenFile(path.into())); + } + if let Some(oidc) = &profile.oidc { + return Ok(Authentication::ManagedOidc(std::sync::Arc::new( + crate::auth::ManagedOidc::for_profile(profile_name, oidc.clone())?, + ))); + } + Ok(Authentication::ApiKey(DEFAULT_API_KEY.to_string())) + } +} + +fn nonempty_secret(value: &str, description: &str) -> Result { + if value.is_empty() { + bail!("{description} cannot be empty"); + } + Ok(value.to_string()) +} + +fn nonempty_os_secret(value: std::ffi::OsString, name: &str) -> Result { + let value = value + .into_string() + .map_err(|_| anyhow!("{name} is not valid UTF-8"))?; + nonempty_secret(&value, name) +} + +/// A connection profile for the LanceDB CLI +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Profile { + /// SQL server URL (Flight SQL endpoint) + #[serde(skip_serializing_if = "Option::is_none")] + pub sql_server_url: Option, + /// HTTP server URL (REST endpoint) + #[serde(skip_serializing_if = "Option::is_none")] + pub http_server_url: Option, + /// API key for authentication + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key: Option, + /// Path to a file containing an OIDC access token. + #[serde(skip_serializing_if = "Option::is_none")] + pub oidc_access_token_file: Option, + /// Managed OAuth authentication using the LanceDB SDK. + #[serde(skip_serializing_if = "Option::is_none")] + pub oidc: Option, + /// Default database name + #[serde(skip_serializing_if = "Option::is_none")] + pub database: Option, + /// Azure storage account name for Azure-backed databases + #[serde(skip_serializing_if = "Option::is_none")] + pub azure_storage_account_name: Option, + /// Path to a PEM CA certificate trusted in addition to the platform roots + #[serde(skip_serializing_if = "Option::is_none")] + pub ca_cert: Option, + /// Path to a PEM client certificate presented for mTLS + #[serde(skip_serializing_if = "Option::is_none")] + pub client_cert: Option, + /// Path to the PKCS#8 PEM private key for `client_cert` + #[serde(skip_serializing_if = "Option::is_none")] + pub client_key: Option, +} + +impl Profile { + pub(crate) fn validate_authentication(&self) -> Result<()> { + if self.api_key.is_some() && self.oidc_access_token_file.is_some() { + bail!("profile cannot specify both api_key and oidc_access_token_file"); + } + if self.oidc.is_some() && (self.api_key.is_some() || self.oidc_access_token_file.is_some()) + { + bail!("profile cannot combine oidc with api_key or oidc_access_token_file"); + } + Ok(()) + } + + /// Create the built-in profile for a local SQL/HTTP deployment. + pub fn local_default() -> Self { + Self { + sql_server_url: Some("localhost:10025".to_string()), + http_server_url: Some("localhost:10024".to_string()), + api_key: None, + oidc_access_token_file: None, + oidc: None, + database: None, + azure_storage_account_name: None, + ca_cert: None, + client_cert: None, + client_key: None, + } + } +} + +/// Configuration file structure for ~/.lancedb/config.toml +#[derive(Debug, Serialize, Deserialize)] +pub struct Config { + /// Data directory for LanceDB server + #[serde(skip_serializing_if = "Option::is_none")] + pub data_dir: Option, + + /// Default profile name to use when none is specified + #[serde(skip_serializing_if = "Option::is_none")] + pub default_profile: Option, + + /// Named connection profiles + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub profiles: HashMap, +} + +impl Default for Config { + fn default() -> Self { + Self { + data_dir: None, + default_profile: Some(DEFAULT_PROFILE.to_string()), + profiles: HashMap::new(), + } + } +} + +fn create_config_directory(path: &Path) -> Result<()> { + let mut builder = fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + builder.mode(0o700); + builder + .create(path) + .with_context(|| format!("Failed to create config directory: {:?}", path)) +} + +impl Config { + /// The database a client uses without `--database`: the default profile's, else the username. + pub fn default_database(&self) -> String { + self.get_profile(None) + .database + .unwrap_or_else(default_database_name) + } + + /// Get the default config file path (~/.lancedb/config.toml) + pub fn default_config_path() -> Result { + let user_dirs = + UserDirs::new().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?; + + let config_dir = user_dirs.home_dir().join(".lancedb"); + Ok(config_dir.join("config.toml")) + } + + /// Get the default data directory path (~/.lancedb/databases) + pub fn default_data_dir() -> Result { + let user_dirs = + UserDirs::new().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?; + + let data_dir = user_dirs.home_dir().join(".lancedb").join("databases"); + Ok(data_dir.to_string_lossy().to_string()) + } + + /// Load config from the default location, or return default config if not found + pub fn load_default() -> Result { + let config_path = Self::default_config_path()?; + + if !config_path.exists() { + return Ok(Self::default()); + } + + Self::load_from_path(&config_path) + } + + /// Load config from the default location, returning None if not found + pub fn load_default_optional() -> Result> { + let config_path = Self::default_config_path()?; + + if !config_path.exists() { + return Ok(None); + } + + Self::load_from_path(&config_path).map(Some) + } + + /// Load config from a specific path + pub fn load_from_path(path: &Path) -> Result { + let (config, unused) = Self::load_from_path_impl(path)?; + for unused_key in unused { + println!( + "While parsing {}, ignored unused configuration key: {}", + path.to_string_lossy(), + unused_key + ); + } + Ok(config) + } + + /// Load config and collect unused keys. Parse errors include a location + /// when available, without source text, values, or the original parser cause. + pub fn load_from_path_impl(path: &Path) -> Result<(Self, Vec)> { + let contents = fs::read_to_string(path) + .with_context(|| format!("Failed to read config file: {:?}", path))?; + + let mut unused = Vec::new(); + match serde_ignored::deserialize(toml::de::Deserializer::new(&contents), |path| { + unused.push(path.to_string()) + }) { + Err(err) => { + // Both Display and message() can contain credentials. Keep only + // the numeric location, and never attach the parser as a cause. + let location = err + .span() + .and_then(|span| contents.get(..span.start)) + .map(|prefix| { + let line = prefix.bytes().filter(|&b| b == b'\n').count() + 1; + let column = prefix.rsplit('\n').next().unwrap_or("").chars().count() + 1; + format!(" at line {line}, column {column}") + }) + .unwrap_or_default(); + bail!( + "Failed to parse config file: {path:?}{location}: invalid TOML or configuration value" + ) + } + Ok(config) => Ok((config, unused)), + } + } + + /// Save config to the default location. On Unix, restrict the app-owned + /// directory to mode 0700, including when it already exists. + pub fn save_default(&self) -> Result<()> { + let config_path = Self::default_config_path()?; + #[cfg(unix)] + { + let parent = config_path.parent().context("Config path has no parent")?; + create_config_directory(parent)?; + fs::set_permissions(parent, fs::Permissions::from_mode(0o700)) + .with_context(|| format!("Failed to restrict config directory: {:?}", parent))?; + } + self.save_to_path(&config_path) + } + + /// Atomically replace config at a specific path. On Unix, the new file is + /// mode 0600, even when replacing a permissive file. Newly created parent + /// directories are private; existing custom parent directories are unchanged. + pub fn save_to_path(&self, path: &Path) -> Result<()> { + let contents = toml::to_string_pretty(self).context("Failed to serialize config")?; + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + create_config_directory(parent)?; + + // tempfile creates Unix files with mode 0600 from the outset. Set it + // explicitly before writing secrets so an owner-masking umask also works. + let mut file = tempfile::NamedTempFile::new_in(parent) + .with_context(|| format!("Failed to create private config file in {:?}", parent))?; + #[cfg(unix)] + file.as_file() + .set_permissions(fs::Permissions::from_mode(0o600)) + .with_context(|| format!("Failed to restrict config file: {:?}", path))?; + file.write_all(contents.as_bytes()) + .with_context(|| format!("Failed to write config file: {:?}", path))?; + file.persist(path) + .map_err(|error| error.error) + .with_context(|| format!("Failed to replace config file: {:?}", path))?; + + Ok(()) + } + + /// Get a profile by name, falling back to built-in "local" profile + pub fn get_profile(&self, name: Option<&str>) -> Profile { + let profile_name = name + .map(|s| s.to_string()) + .or_else(|| self.default_profile.clone()) + .unwrap_or_else(|| DEFAULT_PROFILE.to_string()); + + self.profiles + .get(&profile_name) + .cloned() + .unwrap_or_else(|| { + if profile_name == DEFAULT_PROFILE { + Profile::local_default() + } else { + Profile::default() + } + }) + } + + /// Add or update a profile + pub fn set_profile(&mut self, name: &str, profile: Profile) { + self.profiles.insert(name.to_string(), profile); + } + + /// Remove a profile + pub fn remove_profile(&mut self, name: &str) -> Option { + self.profiles.remove(name) + } + + /// List all profile names + pub fn list_profiles(&self) -> Vec<&str> { + self.profiles.keys().map(|s| s.as_str()).collect() + } + + /// Set the default profile name + pub fn set_default_profile(&mut self, name: &str) { + self.default_profile = Some(name.to_string()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn sql_resolution_does_not_invent_an_endpoint() { + let args = ConnectionArgs { + api_key: Some("test-key".into()), + http_server_url: Some("http://api.example.com".into()), + ..Default::default() + }; + let mut config = Config::default(); + assert!(args.resolve_sql_with_config(&config).unwrap().is_none()); + config.set_profile("cloud", Profile::default()); + config.set_default_profile("cloud"); + assert!(args.resolve_sql_with_config(&config).unwrap().is_none()); + + let local = ConnectionArgs { + profile: Some("local".into()), + ..args + }; + assert_eq!( + local + .resolve_sql_with_config(&config) + .unwrap() + .unwrap() + .sql_server_url, + "localhost:10025" + ); + } + + #[test] + fn sql_resolution_uses_a_saved_endpoint_or_an_explicit_override() { + let args = ConnectionArgs { + api_key: Some("test-key".into()), + ..Default::default() + }; + let mut config = Config::default(); + config.set_profile( + "cloud", + Profile { + sql_server_url: Some("grpcs://sql.example.com:10025".into()), + ..Default::default() + }, + ); + config.set_default_profile("cloud"); + assert_eq!( + args.resolve_sql_with_config(&config) + .unwrap() + .unwrap() + .sql_server_url, + "grpcs://sql.example.com:10025" + ); + let override_args = ConnectionArgs { + sql_server_url: Some("localhost:12345".into()), + ..args + }; + assert_eq!( + override_args + .resolve_sql_with_config(&config) + .unwrap() + .unwrap() + .sql_server_url, + "localhost:12345" + ); + } + + #[test] + fn sql_resolution_rejects_an_empty_configured_endpoint() { + let args = ConnectionArgs { + sql_server_url: Some(" ".into()), + ..Default::default() + }; + let error = args + .resolve_sql_with_config(&Config::default()) + .unwrap_err(); + assert!(error + .to_string() + .contains("Flight SQL endpoint cannot be empty")); + } + + #[test] + fn test_parse_endpoint_defaults_to_plaintext() { + let endpoint = parse_endpoint("localhost:10024", DEFAULT_HTTP_PORT).unwrap(); + assert_eq!(endpoint.scheme, Scheme::Http); + assert_eq!(endpoint.url(), "http://localhost:10024"); + assert!(!endpoint.is_tls()); + } + + #[test] + fn test_parse_endpoint_applies_default_port_without_scheme() { + let endpoint = parse_endpoint("localhost", DEFAULT_SQL_PORT).unwrap(); + assert_eq!(endpoint.url(), "http://localhost:10025"); + } + + #[test] + fn test_parse_endpoint_preserves_https() { + let endpoint = parse_endpoint("https://db.example.com:8443", DEFAULT_HTTP_PORT).unwrap(); + assert_eq!(endpoint.scheme, Scheme::Https); + assert_eq!(endpoint.url(), "https://db.example.com:8443"); + assert!(endpoint.is_tls()); + } + + #[test] + fn test_parse_endpoint_scheme_implies_standard_port() { + assert_eq!( + parse_endpoint("https://db.example.com", DEFAULT_HTTP_PORT) + .unwrap() + .url(), + "https://db.example.com:443" + ); + assert_eq!( + parse_endpoint("http://db.example.com", DEFAULT_HTTP_PORT) + .unwrap() + .url(), + "http://db.example.com:80" + ); + } + + #[test] + fn test_parse_endpoint_ignores_trailing_path() { + let endpoint = parse_endpoint("https://db.example.com:8443/", DEFAULT_HTTP_PORT).unwrap(); + assert_eq!(endpoint.url(), "https://db.example.com:8443"); + } + + #[test] + fn test_parse_endpoint_ipv6() { + let bracketed = parse_endpoint("https://[fd00::1]:8443", DEFAULT_HTTP_PORT).unwrap(); + assert_eq!(bracketed.host, "fd00::1"); + assert_eq!(bracketed.url(), "https://[fd00::1]:8443"); + + // A bare IPv6 literal has no port; the default applies. + let bare = parse_endpoint("::1", DEFAULT_HTTP_PORT).unwrap(); + assert_eq!(bare.host, "::1"); + assert_eq!(bare.url(), "http://[::1]:10024"); + + let bracketed_no_port = parse_endpoint("[::1]", DEFAULT_HTTP_PORT).unwrap(); + assert_eq!(bracketed_no_port.url(), "http://[::1]:10024"); + } + + #[test] + fn test_parse_grpc_endpoint_maps_grpc_schemes() { + // grpc:// is plaintext HTTP/2; grpcs:// is the TLS form. + assert_eq!( + parse_grpc_endpoint("grpc://lancedb-query-node:10025", DEFAULT_SQL_PORT) + .unwrap() + .url(), + "http://lancedb-query-node:10025" + ); + let secure = + parse_grpc_endpoint("grpcs://sql.example.com:10025", DEFAULT_SQL_PORT).unwrap(); + assert!(secure.is_tls()); + assert_eq!(secure.url(), "https://sql.example.com:10025"); + } + + #[test] + fn test_parse_endpoint_rejects_invalid_addresses() { + // gRPC schemes belong to the Flight SQL address only. + assert!(parse_endpoint("grpc://localhost:10025", DEFAULT_SQL_PORT).is_err()); + assert!(parse_endpoint("ftp://localhost:10025", DEFAULT_SQL_PORT).is_err()); + assert!(parse_grpc_endpoint("ftp://localhost:10025", DEFAULT_SQL_PORT).is_err()); + assert!(parse_endpoint("localhost:notaport", DEFAULT_SQL_PORT).is_err()); + assert!(parse_endpoint("https://", DEFAULT_HTTP_PORT).is_err()); + assert!(parse_endpoint("[::1:10024", DEFAULT_HTTP_PORT).is_err()); + } + + #[test] + fn config_parse_errors_do_not_expose_credentials_or_parser_causes() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + for (contents, secret, location) in [ + ( + "[profiles.cloud]\napi_key = \"sk_test_private\",\n", + "sk_test_private", + "line 2, column 28", + ), + ( + "[profiles.cloud]\r\napi_key = \"sk_test_prívate\",\r\n", + "sk_test_prívate", + "line 2, column 28", + ), + ( + "[profiles.cloud.oidc]\nflow = \"sk_test_private\"\n", + "sk_test_private", + "line 2, column 8", + ), + ] { + fs::write(&path, contents).unwrap(); + let err = Config::load_from_path_impl(&path).unwrap_err(); + for diagnostic in [ + format!("{err}"), + format!("{err:#}"), + format!("{err:?}"), + format!("{err:#?}"), + ] { + assert!(!diagnostic.contains(secret)); + assert!(!diagnostic.contains("api_key =")); + assert!(diagnostic.contains("Failed to parse config file")); + assert!(diagnostic.contains("invalid TOML or configuration value")); + assert!(diagnostic.contains(location), "{diagnostic}"); + assert!(diagnostic.contains("config.toml")); + } + assert_eq!(err.chain().count(), 1); + assert!(err.downcast_ref::().is_none()); + } + } + + #[test] + fn test_save_and_load_config() { + let dir = tempdir().unwrap(); + let config_path = dir.path().join("config.toml"); + + let mut config = Config { + data_dir: Some("/test/data".to_string()), + ..Config::default() + }; + config.set_profile( + "test", + Profile { + sql_server_url: Some("http://test.example.com".to_string()), + http_server_url: Some("http://test.example.com".to_string()), + api_key: Some("test-key".to_string()), + database: Some("testdb".to_string()), + azure_storage_account_name: Some("teststorageacct".to_string()), + ..Profile::default() + }, + ); + + // Save config + config.save_to_path(&config_path).unwrap(); + + // Load config + let loaded = Config::load_from_path(&config_path).unwrap(); + assert_eq!(loaded.data_dir, config.data_dir); + assert!(loaded.profiles.contains_key("test")); + assert_eq!( + loaded + .profiles + .get("test") + .unwrap() + .azure_storage_account_name, + Some("teststorageacct".to_string()) + ); + } + + #[cfg(unix)] + #[test] + fn config_saves_are_private_without_changing_custom_parent_modes() { + let dir = tempdir().unwrap(); + let parent = dir.path().join("custom"); + let path = parent.join("config.toml"); + let mut config = Config::default(); + config.set_profile( + "cloud", + Profile { + api_key: Some("initial-key".into()), + ..Profile::default() + }, + ); + config.save_to_path(&path).unwrap(); + assert_eq!( + fs::metadata(&parent).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + + fs::set_permissions(&parent, fs::Permissions::from_mode(0o755)).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + config.profiles.get_mut("cloud").unwrap().api_key = Some("updated-key".into()); + config.save_to_path(&path).unwrap(); + + assert_eq!( + fs::metadata(&parent).unwrap().permissions().mode() & 0o777, + 0o755 + ); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + Config::load_from_path(&path) + .unwrap() + .get_profile(Some("cloud")) + .api_key + .as_deref(), + Some("updated-key") + ); + } + + #[test] + fn test_config_serialization() { + let config = Config { + data_dir: Some("/test/data".to_string()), + ..Config::default() + }; + + let serialized = toml::to_string_pretty(&config).unwrap(); + println!("Config TOML output:\n{}", serialized); + assert!(serialized.contains("data_dir = \"/test/data\"")); + + let deserialized: Config = toml::from_str(&serialized).unwrap(); + assert_eq!(deserialized.data_dir, config.data_dir); + } + + #[test] + fn test_profile_management() { + let mut config = Config::default(); + + // Test default profile (local) + let profile = config.get_profile(None); + assert_eq!(profile.sql_server_url, Some("localhost:10025".to_string())); + + // Add a custom profile + config.set_profile( + "production", + Profile { + sql_server_url: Some("http://sql.example.com".to_string()), + http_server_url: Some("http://api.example.com".to_string()), + api_key: Some("prod-key".to_string()), + database: Some("proddb".to_string()), + azure_storage_account_name: Some("prodstorageacct".to_string()), + ..Profile::default() + }, + ); + + // Get custom profile + let prod_profile = config.get_profile(Some("production")); + assert_eq!( + prod_profile.sql_server_url, + Some("http://sql.example.com".to_string()) + ); + assert_eq!(prod_profile.api_key, Some("prod-key".to_string())); + assert_eq!( + prod_profile.azure_storage_account_name, + Some("prodstorageacct".to_string()) + ); + + // Set as default + config.set_default_profile("production"); + let default_profile = config.get_profile(None); + assert_eq!( + default_profile.sql_server_url, + Some("http://sql.example.com".to_string()) + ); + + // List profiles + let profiles = config.list_profiles(); + assert!(profiles.contains(&"production")); + + // Remove profile + config.remove_profile("production"); + assert!(!config.profiles.contains_key("production")); + } + + #[test] + fn test_backward_compatible_config() { + // Test that old config format (just data_dir) still loads + let old_config = r#"data_dir = "/old/data/path""#; + let config: Config = toml::from_str(old_config).unwrap(); + assert_eq!(config.data_dir, Some("/old/data/path".to_string())); + assert!(config.profiles.is_empty()); + } + + #[test] + fn test_full_config_format() { + let full_config = r#" +data_dir = "/data/lancedb" +default_profile = "dev" + +[profiles.dev] +sql_server_url = "http://localhost:8080" +http_server_url = "http://localhost:8081" +database = "devdb" +unknown_config_key = "blah" + +[profiles.prod] +sql_server_url = "http://sql.prod.example.com" +http_server_url = "http://api.prod.example.com" +api_key = "secret-key" +database = "proddb" +azure_storage_account_name = "prodstorageacct" +"#; + let dir = tempdir().unwrap(); + let config_path = dir.path().join("config.toml"); + fs::write(&config_path, full_config).unwrap(); + let (config, unused) = Config::load_from_path_impl(&config_path).unwrap(); + assert_eq!(vec!["profiles.dev.unknown_config_key"], unused); + assert_eq!(config.data_dir, Some("/data/lancedb".to_string())); + assert_eq!(config.default_profile, Some("dev".to_string())); + assert_eq!(config.profiles.len(), 2); + + let dev = config.get_profile(Some("dev")); + assert_eq!( + dev.sql_server_url, + Some("http://localhost:8080".to_string()) + ); + assert_eq!(dev.api_key, None); + + let prod = config.get_profile(Some("prod")); + assert_eq!(prod.api_key, Some("secret-key".to_string())); + assert_eq!( + prod.azure_storage_account_name, + Some("prodstorageacct".to_string()) + ); + } + + #[test] + fn authentication_resolution_follows_flag_env_profile_order() { + let dir = tempdir().unwrap(); + let flag_token = dir.path().join("flag-token"); + let env_token = dir.path().join("env-token"); + fs::write(&flag_token, "flag-oidc\n").unwrap(); + fs::write(&env_token, "env-file-oidc\n").unwrap(); + + let profile = Profile { + api_key: Some("profile-api-key".to_string()), + ..Profile::default() + }; + let args = ConnectionArgs { + api_key: Some("flag-api-key".to_string()), + ..ConnectionArgs::default() + }; + let all_env = |name: &str| match name { + API_KEY_ENV => Some("env-api-key".into()), + OIDC_ACCESS_TOKEN_ENV => Some("env-oidc".into()), + OIDC_ACCESS_TOKEN_FILE_ENV => Some(env_token.as_os_str().to_owned()), + _ => None, + }; + assert_eq!( + args.resolve_authentication_with_env(&profile, DEFAULT_PROFILE, all_env) + .unwrap(), + Authentication::ApiKey("flag-api-key".to_string()) + ); + + let args = ConnectionArgs { + oidc_access_token_file: Some(flag_token.to_string_lossy().into_owned()), + ..ConnectionArgs::default() + }; + assert_eq!( + args.resolve_authentication_with_env(&profile, DEFAULT_PROFILE, all_env) + .unwrap(), + Authentication::OidcAccessTokenFile(flag_token.clone()) + ); + + let args = ConnectionArgs::default(); + assert_eq!( + args.resolve_authentication_with_env(&profile, DEFAULT_PROFILE, all_env) + .unwrap(), + Authentication::ApiKey("env-api-key".to_string()) + ); + assert_eq!( + args.resolve_authentication_with_env(&profile, DEFAULT_PROFILE, |name| match name { + OIDC_ACCESS_TOKEN_ENV => Some("env-oidc".into()), + OIDC_ACCESS_TOKEN_FILE_ENV => Some(env_token.as_os_str().to_owned()), + _ => None, + }) + .unwrap(), + Authentication::OidcAccessToken("env-oidc".to_string()) + ); + assert_eq!( + args.resolve_authentication_with_env(&profile, DEFAULT_PROFILE, |name| match name { + OIDC_ACCESS_TOKEN_FILE_ENV => Some(env_token.as_os_str().to_owned()), + _ => None, + }) + .unwrap(), + Authentication::OidcAccessTokenFile(env_token.clone()) + ); + assert_eq!( + args.resolve_authentication_with_env(&profile, DEFAULT_PROFILE, |_| None) + .unwrap(), + Authentication::ApiKey("profile-api-key".to_string()) + ); + assert_eq!( + args.resolve_authentication_with_env(&Profile::default(), DEFAULT_PROFILE, |_| None) + .unwrap(), + Authentication::ApiKey(DEFAULT_API_KEY.to_string()) + ); + } + + #[test] + fn profile_cannot_contain_two_authentication_methods() { + let profile = Profile { + api_key: Some("sk_test".to_string()), + oidc_access_token_file: Some("token-file".to_string()), + ..Profile::default() + }; + let err = ConnectionArgs::default() + .resolve_authentication_with_env(&profile, DEFAULT_PROFILE, |_| None) + .unwrap_err(); + assert_eq!( + err.to_string(), + "profile cannot specify both api_key and oidc_access_token_file" + ); + } + #[test] + fn native_oidc_profile_and_credential_overrides() { + let config: Config = toml::from_str( + r#" +[profiles.automation.oidc] +flow = "client_credentials" +issuer_url = "https://identity.example.com" +client_id = "automation-client" +scopes = ["lancedb"] +client_secret_file = "/run/secrets/lancedb" +"#, + ) + .unwrap(); + let profile = config.get_profile(Some("automation")); + let args = ConnectionArgs::default(); + assert!(matches!( + args.resolve_authentication_with_env(&profile, "automation", |_| None) + .unwrap(), + Authentication::ManagedOidc(_) + )); + let oidc = args + .resolve_authentication_with_env(&profile, "automation", |name| { + (name == OIDC_ACCESS_TOKEN_ENV).then(|| "external-token".into()) + }) + .unwrap(); + assert_eq!( + oidc, + Authentication::OidcAccessToken("external-token".into()) + ); + let key = args + .resolve_authentication_with_env(&profile, "automation", |name| match name { + API_KEY_ENV => Some("api-key".into()), + OIDC_ACCESS_TOKEN_ENV => Some("external-token".into()), + _ => None, + }) + .unwrap(); + assert_eq!(key, Authentication::ApiKey("api-key".into())); + let invalid = Profile { + api_key: Some("key".into()), + ..profile + }; + assert!(args + .resolve_authentication_with_env(&invalid, "automation", |_| None) + .is_err()); + } +} diff --git a/rust/lancedb/cli/src/sql_executor.rs b/rust/lancedb/cli/src/sql_executor.rs new file mode 100644 index 000000000..87559d88e --- /dev/null +++ b/rust/lancedb/cli/src/sql_executor.rs @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use crate::dml_result::DmlResult; +use anyhow::Result; +use arrow_array::RecordBatch; +use async_trait::async_trait; + +#[async_trait] +pub trait SqlQueryExecutor: Send + Sync { + /// Execute a SQL query and return a structured result. + async fn execute_query(&mut self, query: &str) -> Result; + + /// Check if the executor is connected and ready + async fn is_connected(&self) -> bool; + + /// Get the connection info for display purposes + fn connection_info(&self) -> Option; + + /// Connect to the server. `server_url` may carry an `http://` or `https://` + /// scheme; a bare `host:port` is treated as plaintext. + async fn connect(&mut self, server_url: &str) -> Result<()>; + + /// Set the current database + fn set_database(&mut self, database: String); +} + +/// Result of a SQL execution +#[derive(Debug)] +pub enum SqlResult { + /// Query returned data + Query(Vec), + /// DML result (INSERT, DELETE, UPDATE) + Dml(DmlResult), + /// DDL statement (CREATE, DROP, etc.) completed + Success, +} + +impl From> for SqlResult { + fn from(batches: Vec) -> Self { + match batches.first().and_then(DmlResult::try_from_batch) { + Some(dml) => Self::Dml(dml), + None => Self::Query(batches), + } + } +} + +/// SQL execution through the public SDK's asynchronous query lifecycle. +pub struct SdkSqlExecutor { + connection: Option, + server_url: String, + database: String, + authentication: crate::server_config::Authentication, + tls: crate::server_config::TlsOptions, +} + +impl SdkSqlExecutor { + pub fn new_with_authentication( + server_url: String, + authentication: crate::server_config::Authentication, + ) -> Self { + Self { + connection: None, + server_url, + database: "test".into(), + authentication, + tls: Default::default(), + } + } + + pub fn new_with_api_key(server_url: String, api_key: String) -> Self { + Self::new_with_authentication( + server_url, + crate::server_config::Authentication::ApiKey(api_key), + ) + } + + pub fn with_tls(mut self, tls: crate::server_config::TlsOptions) -> Self { + self.tls = tls; + self + } +} + +#[async_trait] +impl SqlQueryExecutor for SdkSqlExecutor { + async fn execute_query(&mut self, query: &str) -> Result { + use futures::TryStreamExt; + let connection = self + .connection + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Flight SQL client not connected"))?; + let mut builder = connection.execute_query_async(query); + if let Ok(namespace) = std::env::var("LANCEDB_NAMESPACE_PATH") { + builder = builder.default_namespace_path(namespace.split('$')); + } + let query = builder.execute().await?; + let batches: Vec = query.reader().await?.try_collect().await?; + Ok(batches.into()) + } + + async fn is_connected(&self) -> bool { + self.connection.is_some() + } + + fn connection_info(&self) -> Option { + self.connection + .as_ref() + .map(|_| format!("Connected to {}", self.server_url)) + } + + async fn connect(&mut self, server_url: &str) -> Result<()> { + use crate::server_config::{parse_grpc_endpoint, Authentication, DEFAULT_SQL_PORT}; + self.connection = None; + self.server_url = server_url.to_owned(); + let endpoint = parse_grpc_endpoint(server_url, DEFAULT_SQL_PORT)?.url(); + let mut builder = lancedb::connect(&format!("db://{}", self.database)) + .region("self-hosted") + .sql_host_override(&endpoint); + builder = match &self.authentication { + Authentication::ApiKey(key) => builder.api_key(key), + _ => builder.api_key(""), + }; + let mut config = + crate::lancedb_client_config(&self.tls, &self.authentication).unwrap_or_default(); + // Preserve the CLI's bounded startup probe, including the TLS handshake. + config.timeout_config.connect_timeout = Some(std::time::Duration::from_secs(5)); + builder = builder.client_config(config); + let connection = builder.execute().await?; + connection.check_sql_connection().await?; + self.connection = Some(connection); + Ok(()) + } + + fn set_database(&mut self, database: String) { + if self.database != database { + self.connection = None; + } + self.database = database; + } +} diff --git a/rust/lancedb/cli/src/table_writer.rs b/rust/lancedb/cli/src/table_writer.rs new file mode 100644 index 000000000..bcd146d1b --- /dev/null +++ b/rust/lancedb/cli/src/table_writer.rs @@ -0,0 +1,380 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +/* +MIT License + +Copyright (c) Foxglove Technologies Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +// Ported from +// https://github.com/foxglove/foxglove-cli/blob/main/foxglove/util/tablewriter/tablewriter.go + +use std::io::{self, Write}; +use std::process::Command; +use unicode_width::UnicodeWidthStr; + +pub struct TableWriter; + +impl TableWriter { + pub fn print_table( + writer: &mut W, + headers: &[String], + data: &[Vec], + term_width: Option, + ) -> io::Result<()> { + let term_width = term_width.unwrap_or_else(get_terminal_width); + let table_width = compute_hotdog_table_width(headers, data); + + if term_width < table_width { + Self::print_hamburger(writer, term_width, headers, data) + } else { + Self::print_hotdog(writer, headers, data) + } + } + + pub fn print_table_with_title( + writer: &mut W, + title: &str, + headers: &[String], + data: &[Vec], + term_width: Option, + ) -> io::Result<()> { + let term_width = term_width.unwrap_or_else(get_terminal_width); + let table_width = compute_hotdog_table_width(headers, data); + + if term_width < table_width { + // For hamburger mode, just print the title normally + writeln!(writer, "{}", title)?; + Self::print_hamburger(writer, term_width, headers, data) + } else { + // For hotdog mode, center the title over the table + let title_padding = if table_width > title.width() { + (table_width - title.width()) / 2 + } else { + 0 + }; + writeln!(writer, "{}{}", " ".repeat(title_padding), title)?; + Self::print_hotdog(writer, headers, data) + } + } + + /// Prints table in horizontal "hotdog" format (PostgreSQL style) + fn print_hotdog( + writer: &mut W, + headers: &[String], + data: &[Vec], + ) -> io::Result<()> { + let cell_widths = compute_hotdog_cell_widths(headers, data); + let column_alignments = compute_column_alignments(data); + + // Write headers + write!(writer, " ")?; + for (i, header) in headers.iter().enumerate() { + if i > 0 { + write!(writer, " | ")?; + } + let padding = cell_widths[i] - header.width(); + write!(writer, "{}{}", header, " ".repeat(padding))?; + } + writeln!(writer)?; + + // Write separator with + at junctions + write!(writer, "-")?; // Leading edge + for (i, &width) in cell_widths.iter().enumerate() { + write!(writer, "{}", "-".repeat(width))?; + if i < cell_widths.len() - 1 { + write!(writer, "-+-")?; // Junction between columns + } + } + writeln!(writer, "-")?; // Trailing edge + + // Write data rows + for row in data { + write!(writer, " ")?; + for (i, col) in row.iter().enumerate() { + if i > 0 { + write!(writer, " | ")?; + } + let padding = cell_widths[i] - col.width(); + + // Right-align numeric columns, left-align others + if i < column_alignments.len() && column_alignments[i] { + write!(writer, "{}{}", " ".repeat(padding), col)?; + } else { + write!(writer, "{}{}", col, " ".repeat(padding))?; + } + } + writeln!(writer)?; + } + + Ok(()) + } + + /// Prints table in vertical "hamburger" format (psql expanded display style) + fn print_hamburger( + writer: &mut W, + term_width: usize, + headers: &[String], + data: &[Vec], + ) -> io::Result<()> { + let mut max_header_width = 0; + let mut max_record_width = 0; + + // Compute max header width + for header in headers { + if header.width() > max_header_width { + max_header_width = header.width(); + } + } + + // Compute max record width (cap at reasonable limit) + const MAX_VALUE_DISPLAY_WIDTH: usize = 80; // Reasonable max for value display + for row in data { + for col in row { + let col_width = col.width().min(MAX_VALUE_DISPLAY_WIDTH); + if col_width > max_record_width { + max_record_width = col_width; + } + } + } + + // Ensure sufficient room for record headers + let longest_record_header = format!("-[ RECORD {} ]", data.len()); + if longest_record_header.len() > max_header_width { + max_header_width = longest_record_header.len(); + } + + // Calculate right dash extent - aim for a reasonable width + let desired_dash_extent = 60; // Reasonable default width for dashes + let mut dashes_right_extent = desired_dash_extent.min(max_record_width + 15); + let max_allowed_extent = term_width.saturating_sub(max_header_width + 3); // +3 for "+-" and space + if dashes_right_extent > max_allowed_extent { + dashes_right_extent = max_allowed_extent; + } + let right_dashes = "-".repeat(dashes_right_extent); + + // Print records + for (i, row) in data.iter().enumerate() { + let record_header = format!("-[ RECORD {} ]", i + 1); + let header_line = format!( + "{}{}+{}", + record_header, + "-".repeat(max_header_width - record_header.len()), + right_dashes + ); + writeln!(writer, "{}", header_line)?; + + for (j, col) in row.iter().enumerate() { + writeln!( + writer, + "{}{}| {}", + headers[j], + " ".repeat(max_header_width - headers[j].width()), + col + )?; + } + } + + Ok(()) + } +} + +fn compute_hotdog_cell_widths(headers: &[String], data: &[Vec]) -> Vec { + let mut cell_widths = vec![0; headers.len()]; + + // Initialize with header widths + for (i, header) in headers.iter().enumerate() { + cell_widths[i] = header.width(); + } + + // Check data column widths + for row in data { + for (i, col) in row.iter().enumerate() { + if i < cell_widths.len() && cell_widths[i] < col.width() { + cell_widths[i] = col.width(); + } + } + } + + cell_widths +} + +fn compute_hotdog_table_width(headers: &[String], data: &[Vec]) -> usize { + let cell_widths = compute_hotdog_cell_widths(headers, data); + // Account for " | " separators between columns (3 chars each) + leading space + trailing space + let separator_width = if headers.len() > 1 { + (headers.len() - 1) * 3 + } else { + 0 + }; + 2 + cell_widths.iter().sum::() + separator_width +} + +fn compute_column_alignments(data: &[Vec]) -> Vec { + if data.is_empty() { + return vec![]; + } + + let num_cols = data[0].len(); + let mut is_numeric = vec![true; num_cols]; + + // Check each column to see if all non-empty values are numeric + for col_idx in 0..num_cols { + for row in data { + if col_idx < row.len() && !row[col_idx].is_empty() { + let value = &row[col_idx]; + // Check if the value is numeric (integer or float) + // Also handle NULL values + if value != "NULL" && !is_numeric_value(value) { + is_numeric[col_idx] = false; + break; + } + } + } + } + + is_numeric +} + +fn is_numeric_value(s: &str) -> bool { + // Try to parse as integer or float + s.parse::().is_ok() || s.parse::().is_ok() +} + +fn get_terminal_width() -> usize { + if let Ok(output) = Command::new("stty").arg("size").output() { + if let Ok(output_str) = String::from_utf8(output.stdout) { + let parts: Vec<&str> = output_str.split_whitespace().collect(); + if parts.len() >= 2 { + if let Ok(cols) = parts[1].parse::() { + return cols; + } + } + } + } + + // Fallback: try termsize crate + if let Some(size) = termsize::get() { + return size.cols as usize; + } + + // Final fallback + 80 +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn test_print_hotdog() { + let mut output = Cursor::new(Vec::new()); + let headers = vec!["ID".to_string(), "Name".to_string(), "Age".to_string()]; + let data = vec![ + vec!["1".to_string(), "Alice".to_string(), "30".to_string()], + vec!["2".to_string(), "Bob".to_string(), "25".to_string()], + vec!["3".to_string(), "Charlie".to_string(), "35".to_string()], + ]; + + TableWriter::print_hotdog(&mut output, &headers, &data).unwrap(); + let result = String::from_utf8(output.into_inner()).unwrap(); + assert_eq!(result, expected_output()); + } + + #[test] + fn test_unicode_table_padding_uses_terminal_columns() { + let headers = vec!["µ".to_string(), "Type".to_string()]; + let data = vec![ + vec!["µ".to_string(), "TEXT".to_string()], + vec!["漢".to_string(), "TEXT".to_string()], + vec!["e\u{301}".to_string(), "TEXT".to_string()], + ]; + let mut output = Vec::new(); + TableWriter::print_table(&mut output, &headers, &data, Some(11)).unwrap(); + assert_eq!( + String::from_utf8(output).unwrap(), + " µ | Type\n----+------\n µ | TEXT\n 漢 | TEXT\n e\u{301} | TEXT\n" + ); + } + + fn expected_output() -> String { + let mut output = String::new(); + output.push_str(" ID | Name | Age\n"); + output.push_str("----+---------+-----\n"); + output.push_str(" 1 | Alice | 30\n"); + output.push_str(" 2 | Bob | 25\n"); + output.push_str(" 3 | Charlie | 35\n"); + output + } + + #[test] + fn test_print_hamburger() { + let mut output = Cursor::new(Vec::new()); + let headers = vec!["ID".to_string(), "Name".to_string(), "Age".to_string()]; + let data = vec![ + vec!["1".to_string(), "Alice".to_string(), "30".to_string()], + vec!["2".to_string(), "Bob".to_string(), "25".to_string()], + vec!["3".to_string(), "Charlie".to_string(), "35".to_string()], + ]; + + // Test with a narrow terminal width + TableWriter::print_hamburger(&mut output, 80, &headers, &data).unwrap(); + let result = String::from_utf8(output.into_inner()).unwrap(); + assert_eq!(result, expected_hamburger_output()); + } + + fn expected_hamburger_output() -> String { + let mut output = String::new(); + output.push_str("-[ RECORD 1 ]+----------------------\n"); + output.push_str("ID | 1\n"); + output.push_str("Name | Alice\n"); + output.push_str("Age | 30\n"); + output.push_str("-[ RECORD 2 ]+----------------------\n"); + output.push_str("ID | 2\n"); + output.push_str("Name | Bob\n"); + output.push_str("Age | 25\n"); + output.push_str("-[ RECORD 3 ]+----------------------\n"); + output.push_str("ID | 3\n"); + output.push_str("Name | Charlie\n"); + output.push_str("Age | 35\n"); + output + } + + #[test] + fn test_print_table_with_title() { + let mut output = Cursor::new(Vec::new()); + let headers = vec!["ID".to_string(), "Name".to_string()]; + let data = vec![ + vec!["1".to_string(), "Alice".to_string()], + vec!["2".to_string(), "Bob".to_string()], + ]; + + TableWriter::print_table_with_title(&mut output, "User List", &headers, &data, Some(200)) + .unwrap(); + let result = String::from_utf8(output.into_inner()).unwrap(); + + // Should center the title and print hotdog format + let expected = " User List\n ID | Name \n----+-------\n 1 | Alice\n 2 | Bob \n"; + assert_eq!(result, expected); + } +} diff --git a/rust/lancedb/cli/tests/command_surface.rs b/rust/lancedb/cli/tests/command_surface.rs new file mode 100644 index 000000000..9d2f9c50e --- /dev/null +++ b/rust/lancedb/cli/tests/command_surface.rs @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +use std::process::Command; + +#[test] +fn an_unconfigured_default_sql_invocation_prints_usage() { + use lancedb_cli::server_config::{Config, Profile}; + + for has_http_profile in [false, true] { + let home = tempfile::tempdir().unwrap(); + if has_http_profile { + let mut config = Config::default(); + config.set_profile( + "cloud", + Profile { + http_server_url: Some("http://api.example.com".into()), + ..Default::default() + }, + ); + config.set_default_profile("cloud"); + config + .save_to_path(&home.path().join(".lancedb/config.toml")) + .unwrap(); + } + let output = Command::new(env!("CARGO_BIN_EXE_lancedb")) + .env("HOME", home.path()) + .env("LANCEDB_API_KEY", "secret-that-must-not-appear") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Usage:"), "{stdout}"); + assert!( + stdout.contains("No Flight SQL endpoint is configured"), + "{stdout}" + ); + assert!(!stdout.contains("secret-that-must-not-appear")); + assert!( + output.stderr.is_empty(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + if !has_http_profile { + assert!(!home.path().join(".lancedb").exists()); + } + } +} + +#[test] +fn invalid_config_diagnostics_do_not_print_credentials() { + for (contents, location) in [ + ( + "[profiles.cloud]\napi_key = \"sk_test_private\",\n", + "line 2, column 28", + ), + ( + "[profiles.cloud.oidc]\nflow = \"sk_test_private\"\n", + "line 2, column 8", + ), + ] { + let home = tempfile::tempdir().unwrap(); + let config_dir = home.path().join(".lancedb"); + std::fs::create_dir(&config_dir).unwrap(); + std::fs::write(config_dir.join("config.toml"), contents).unwrap(); + for args in [vec!["profile", "list"], vec!["sql"], vec![]] { + let output = Command::new(env!("CARGO_BIN_EXE_lancedb")) + .args(args) + .env("HOME", home.path()) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.contains("sk_test_private")); + assert!(!stderr.contains("api_key =")); + assert!(stderr.contains("Failed to parse config file"), "{stderr}"); + assert!(stderr.contains("invalid TOML or configuration value")); + assert!(stderr.contains(location), "{stderr}"); + assert!(stderr.contains("config.toml")); + assert!(output.stdout.is_empty()); + } + } +} + +#[test] +fn explicit_sql_requests_require_a_configured_endpoint() { + for args in [ + vec!["sql"], + vec!["--command", "SELECT 1"], + vec!["--file", "queries.sql"], + vec!["--profile", "missing"], + ] { + let home = tempfile::tempdir().unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_lancedb")) + .args(&args) + .env("HOME", home.path()) + .output() + .unwrap(); + assert!(!output.status.success(), "{args:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("No Flight SQL endpoint is configured"), + "{args:?}: {stderr}" + ); + assert!(stderr.contains("--sql-server-url"), "{stderr}"); + assert!(stderr.contains("--profile local"), "{stderr}"); + assert!(!home.path().join(".lancedb").exists()); + } +} + +#[test] +fn selecting_local_as_default_persists_its_endpoint() { + use lancedb_cli::server_config::{Config, Profile}; + + for existing_endpoint in [None, Some("localhost:12345")] { + let home = tempfile::tempdir().unwrap(); + let path = home.path().join(".lancedb/config.toml"); + if let Some(endpoint) = existing_endpoint { + let mut config = Config::default(); + config.set_profile( + "local", + Profile { + sql_server_url: Some(endpoint.into()), + ..Default::default() + }, + ); + config.save_to_path(&path).unwrap(); + } + let output = Command::new(env!("CARGO_BIN_EXE_lancedb")) + .args(["profile", "set", "local"]) + .env("HOME", home.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let config = Config::load_from_path(&path).unwrap(); + assert_eq!(config.default_profile.as_deref(), Some("local")); + assert_eq!( + config + .profiles + .get("local") + .unwrap() + .sql_server_url + .as_deref(), + Some(existing_endpoint.unwrap_or("localhost:10025")) + ); + } +} + +#[test] +fn help_lists_commands_without_creating_configuration() { + let home = tempfile::tempdir().unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_lancedb")) + .arg("--help") + .env("HOME", home.path()) + .env("LANCEDB_API_KEY", "secret-that-must-not-appear") + .output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("Usage:")); + for command in ["auth", "profile", "sql"] { + assert!(stdout.contains(command)); + } + for command in ["acl", "load", "job"] { + assert!(!stdout.contains(&format!(" {command} ")), "{stdout}"); + } + assert!(!stdout.contains("secret-that-must-not-appear")); + assert!(!home.path().join(".lancedb").exists()); +} + +#[test] +fn excluded_commands_and_mixed_sql_options_are_rejected() { + for args in [ + vec!["server"], + vec!["management"], + vec!["job", "--help"], + vec!["job", "list", "--help"], + vec!["job", "usage"], + vec!["acl"], + vec!["acl", "--help"], + vec!["acl", "list"], + vec!["acl", "add"], + vec!["acl", "delete"], + vec!["load"], + vec!["load", "--help"], + vec!["load", "--source", "lance", "--dataset", "data.lance"], + vec!["--command", "SELECT 1", "profile", "list"], + vec!["--file", "queries.sql", "profile", "list"], + vec!["profile", "list", "--command", "SELECT 1"], + vec!["profile", "list", "--file", "queries.sql"], + ] { + let home = tempfile::tempdir().unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_lancedb")) + .args(&args) + .env("HOME", home.path()) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2), "{args:?}"); + assert!(output.stdout.is_empty(), "{args:?}"); + assert!(!home.path().join(".lancedb").exists(), "{args:?}"); + } +} + +#[test] +fn sql_options_do_not_appear_in_other_subcommand_help() { + let output = Command::new(env!("CARGO_BIN_EXE_lancedb")) + .args(["profile", "--help"]) + .output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + for option in ["--command", "--file", "--format", "--sql-server-url"] { + assert!(!stdout.contains(option), "{stdout}"); + } +} + +#[test] +fn sql_environment_options_do_not_change_profile_commands() { + let home = tempfile::tempdir().unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_lancedb")) + .args(["profile", "list"]) + .env("HOME", home.path()) + .env("LANCEDB_CA_CERT", "/not-used-by-profile/ca.pem") + .env("LANCEDB_CLIENT_CERT", "/not-used-by-profile/client.pem") + .env("LANCEDB_CLIENT_KEY", "/not-used-by-profile/key.pem") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Configured profiles:"), "{stdout}"); +} + +#[test] +fn profile_updates_preserve_enterprise_data_directory() { + use lancedb_cli::server_config::{Config, Profile}; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write( + &path, + "data_dir = '/enterprise/data'\ndefault_profile = 'local'\n", + ) + .unwrap(); + let mut config = Config::load_from_path(&path).unwrap(); + config.set_profile( + "cloud", + Profile { + database: Some("analytics".into()), + ..Default::default() + }, + ); + config.save_to_path(&path).unwrap(); + let saved = Config::load_from_path(&path).unwrap(); + assert_eq!(saved.data_dir.as_deref(), Some("/enterprise/data")); + assert_eq!( + saved.get_profile(Some("cloud")).database.as_deref(), + Some("analytics") + ); +} + +#[cfg(unix)] +#[test] +fn api_key_profiles_are_private_under_permissive_umasks_and_after_edits() { + use lancedb_cli::server_config::Config; + use std::fs; + use std::os::unix::fs::PermissionsExt; + + for mask in ["022", "000"] { + let home = tempfile::tempdir().unwrap(); + let config_dir = home.path().join(".lancedb"); + let path = config_dir.join("config.toml"); + let run = |command: &str, key: &str| { + // Set umask only in the child, leaving other test threads unaffected. + let output = Command::new("/bin/sh") + .args([ + "-c", + r#"umask "$1"; shift; exec "$@""#, + "profile-permissions", + mask, + env!("CARGO_BIN_EXE_lancedb"), + "profile", + command, + "cloud", + "--api-key", + key, + "--database", + "analytics", + ]) + .env("HOME", home.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "{command} under umask {mask}: {}", + String::from_utf8_lossy(&output.stderr) + ); + }; + let assert_private = || { + assert_eq!( + fs::metadata(&config_dir).unwrap().permissions().mode() & 0o777, + 0o700, + "config directory under umask {mask}" + ); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600, + "config file under umask {mask}" + ); + }; + + run("create", "initial-key"); + assert_private(); + + fs::set_permissions(&config_dir, fs::Permissions::from_mode(0o755)).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + run("edit", "replacement-key"); + assert_private(); + let saved = Config::load_from_path(&path).unwrap(); + assert_eq!( + saved.get_profile(Some("cloud")).api_key.as_deref(), + Some("replacement-key") + ); + assert_eq!( + saved.get_profile(Some("cloud")).database.as_deref(), + Some("analytics") + ); + } +} diff --git a/rust/lancedb/cli/tests/flight_sql_tls.rs b/rust/lancedb/cli/tests/flight_sql_tls.rs new file mode 100644 index 000000000..40c34409c --- /dev/null +++ b/rust/lancedb/cli/tests/flight_sql_tls.rs @@ -0,0 +1,643 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +//! End-to-end TLS and SQL file-execution coverage for the Flight SQL transport. +//! +//! A throwaway CA signs a server certificate for `127.0.0.1`, a real Flight +//! server serves it over TLS, and the CLI's executor connects with the CA +//! supplied through `--ca-cert`. The negative cases prove verification is +//! genuinely happening rather than being skipped. + +use std::net::SocketAddr; +use std::pin::Pin; + +use arrow_array::{Int32Array, RecordBatch}; +use arrow_flight::encode::FlightDataEncoderBuilder; +use arrow_flight::flight_service_server::{FlightService, FlightServiceServer}; +use arrow_flight::sql::{Any, CommandStatementQuery}; +use arrow_flight::{ + Action, ActionType, Criteria, Empty, FlightData, FlightDescriptor, FlightEndpoint, FlightInfo, + HandshakeRequest, HandshakeResponse, PollInfo, PutResult, SchemaResult, Ticket, +}; +use arrow_schema::{DataType, Field, Schema}; +use futures::{Stream, TryStreamExt}; +use lancedb_cli::server_config::TlsOptions; +use lancedb_cli::sql_executor::SdkSqlExecutor as FlightSqlExecutor; +use lancedb_cli::sql_executor::{SqlQueryExecutor, SqlResult}; +use prost::Message; +use std::sync::{Arc, Mutex}; +use tokio::net::TcpListener; +use tokio_stream::wrappers::TcpListenerStream; +use tonic::transport::{Identity, Server, ServerTlsConfig}; +use tonic::{Request, Response, Status, Streaming}; + +/// The single column the stub server returns, asserted by the tests. +const ANSWER: i32 = 42; + +/// tonic's server-side TLS needs a process-level rustls crypto provider; unlike +/// the client path it has no built-in fallback. +fn init_crypto_provider() { + static INIT: std::sync::Once = std::sync::Once::new(); + INIT.call_once(|| { + let _ = rustls::crypto::ring::default_provider().install_default(); + }); +} + +type BoxStream = Pin> + Send + 'static>>; + +/// Minimal Flight service: enough for `execute_query` to complete a round trip. +/// Everything the CLI does not call is left unimplemented on purpose. +#[derive(Clone, Default)] +struct StubFlightService { + queries: Option>>>, +} + +/// The SDK wraps a Flight SQL command in a ticket's field 2. +#[derive(Clone, PartialEq, Message)] +struct CommandTicket { + #[prost(bytes = "vec", tag = "2")] + command: Vec, +} + +impl StubFlightService { + fn batch() -> RecordBatch { + let schema = Arc::new(Schema::new(vec![Field::new( + "answer", + DataType::Int32, + false, + )])); + RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![ANSWER]))]).unwrap() + } +} + +#[tonic::async_trait] +impl FlightService for StubFlightService { + type HandshakeStream = BoxStream; + type ListFlightsStream = BoxStream; + type DoGetStream = BoxStream; + type DoPutStream = BoxStream; + type DoExchangeStream = BoxStream; + type DoActionStream = BoxStream; + type ListActionsStream = BoxStream; + + async fn get_flight_info( + &self, + _request: Request, + ) -> Result, Status> { + let endpoint = FlightEndpoint::new().with_ticket(Ticket::new("stub")); + Ok(Response::new(FlightInfo::new().with_endpoint(endpoint))) + } + + async fn do_get( + &self, + request: Request, + ) -> Result, Status> { + if let Some(queries) = &self.queries { + let ticket = CommandTicket::decode(request.into_inner().ticket).unwrap(); + let command = Any::decode(ticket.command.as_slice()) + .unwrap() + .unpack::() + .unwrap() + .unwrap(); + queries.lock().unwrap().push(command.query); + } + let batch = Self::batch(); + let stream = FlightDataEncoderBuilder::new() + .build(futures::stream::once(async move { Ok(batch) })) + .map_err(|err| Status::internal(err.to_string())); + Ok(Response::new(Box::pin(stream))) + } + + async fn handshake( + &self, + _request: Request>, + ) -> Result, Status> { + Err(Status::unimplemented("handshake")) + } + + async fn list_flights( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented("list_flights")) + } + + async fn poll_flight_info( + &self, + _request: Request, + ) -> Result, Status> { + let endpoint = FlightEndpoint::new().with_ticket(Ticket::new("stub")); + let info = FlightInfo::new() + .with_endpoint(endpoint) + .try_with_schema(&Self::batch().schema()) + .unwrap(); + Ok(Response::new(PollInfo { + info: Some(info), + flight_descriptor: None, + progress: Some(1.0), + expiration_time: None, + })) + } + + async fn get_schema( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented("get_schema")) + } + + async fn do_put( + &self, + _request: Request>, + ) -> Result, Status> { + Err(Status::unimplemented("do_put")) + } + + async fn do_exchange( + &self, + _request: Request>, + ) -> Result, Status> { + Err(Status::unimplemented("do_exchange")) + } + + async fn do_action( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented("do_action")) + } + + async fn list_actions( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented("list_actions")) + } +} + +/// A CA plus the server and client certificates it signed. The server +/// certificate is valid for `127.0.0.1`. +struct TestCerts { + ca_pem: String, + server_cert_pem: String, + server_key_pem: String, + client_cert_pem: String, + client_key_pem: String, +} + +fn generate_certs() -> TestCerts { + use rcgen::{BasicConstraints, CertificateParams, IsCa, KeyPair, PKCS_ECDSA_P256_SHA256}; + + let mut ca_params = CertificateParams::new(vec!["Test CA".to_string()]); + ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + ca_params.key_pair = Some(KeyPair::generate(&PKCS_ECDSA_P256_SHA256).unwrap()); + let ca_cert = rcgen::Certificate::from_params(ca_params).unwrap(); + + // The SANs must cover the address the client dials, or the handshake fails + // hostname verification even with the right CA. + let mut server_params = + CertificateParams::new(vec!["localhost".to_string(), "127.0.0.1".to_string()]); + server_params.key_pair = Some(KeyPair::generate(&PKCS_ECDSA_P256_SHA256).unwrap()); + let server_cert = rcgen::Certificate::from_params(server_params).unwrap(); + + let mut client_params = CertificateParams::new(vec!["test-client".to_string()]); + client_params.key_pair = Some(KeyPair::generate(&PKCS_ECDSA_P256_SHA256).unwrap()); + let client_cert = rcgen::Certificate::from_params(client_params).unwrap(); + + TestCerts { + ca_pem: ca_cert.serialize_pem().unwrap(), + server_cert_pem: server_cert.serialize_pem_with_signer(&ca_cert).unwrap(), + server_key_pem: server_cert.serialize_private_key_pem(), + client_cert_pem: client_cert.serialize_pem_with_signer(&ca_cert).unwrap(), + client_key_pem: client_cert.serialize_private_key_pem(), + } +} + +/// Start the stub Flight server over TLS on an ephemeral port. The server stops +/// when the returned shutdown sender is dropped. +async fn start_tls_server(certs: &TestCerts) -> (SocketAddr, tokio::sync::oneshot::Sender<()>) { + start_server(certs, false).await +} + +/// Same, but the server demands a client certificate signed by the test CA. +async fn start_mtls_server(certs: &TestCerts) -> (SocketAddr, tokio::sync::oneshot::Sender<()>) { + start_server(certs, true).await +} + +async fn start_server( + certs: &TestCerts, + require_client_cert: bool, +) -> (SocketAddr, tokio::sync::oneshot::Sender<()>) { + init_crypto_provider(); + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let identity = Identity::from_pem(&certs.server_cert_pem, &certs.server_key_pem); + let mut tls = ServerTlsConfig::new().identity(identity); + if require_client_cert { + tls = tls.client_ca_root(tonic::transport::Certificate::from_pem(&certs.ca_pem)); + } + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + + tokio::spawn(async move { + Server::builder() + .tls_config(tls) + .unwrap() + .add_service(FlightServiceServer::new(StubFlightService::default())) + .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }); + + (addr, shutdown_tx) +} + +fn write_pem(dir: &tempfile::TempDir, name: &str, contents: &str) -> String { + let path = dir.path().join(name); + std::fs::write(&path, contents).unwrap(); + path.to_str().unwrap().to_string() +} + +/// Exercise argument parsing, file reading, script execution, and SQL transport. +/// The stub records SQL without executing it against a database. +async fn run_sql_cli(args: &[&str], script: Option<&str>) -> (std::process::Output, Vec) { + let queries = Arc::new(Mutex::new(Vec::new())); + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let sql_endpoint = format!("http://{}", listener.local_addr().unwrap()); + let service = StubFlightService { + queries: Some(queries.clone()), + }; + let sql_server = tokio::spawn(async move { + Server::builder() + .add_service(FlightServiceServer::new(service)) + .serve_with_incoming(TcpListenerStream::new(listener)) + .await + .unwrap(); + }); + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let http_endpoint = format!("http://{}", listener.local_addr().unwrap()); + let app = + axum::Router::new().fallback(|| async { axum::Json(serde_json::json!({"tables": []})) }); + let http_server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("script.sql"); + if let Some(script) = script { + std::fs::write(&path, script).unwrap(); + } + let mut config = lancedb_cli::server_config::Config::default(); + config.set_profile( + "test", + lancedb_cli::server_config::Profile { + sql_server_url: Some(sql_endpoint), + http_server_url: Some(http_endpoint), + database: Some("test".into()), + api_key: Some("test-key".into()), + ..Default::default() + }, + ); + config.set_default_profile("test"); + config + .save_to_path(&dir.path().join(".lancedb/config.toml")) + .unwrap(); + let output = tokio::process::Command::new(env!("CARGO_BIN_EXE_lancedb")) + .args(args) + .current_dir(dir.path()) + .stdin(std::process::Stdio::null()) + .env("HOME", dir.path()) + .env("XDG_DATA_HOME", dir.path().join("data")) + .env("XDG_CONFIG_HOME", dir.path().join("config")) + .env_remove("LANCEDB_API_KEY") + .env_remove("LANCEDB_OIDC_ACCESS_TOKEN") + .env_remove("LANCEDB_OIDC_ACCESS_TOKEN_FILE") + .env_remove("LANCEDB_CA_CERT") + .env_remove("LANCEDB_CLIENT_CERT") + .env_remove("LANCEDB_CLIENT_KEY") + .output() + .await + .unwrap(); + sql_server.abort(); + http_server.abort(); + let recorded = queries.lock().unwrap().clone(); + (output, recorded) +} + +async fn run_sql_file(script: &str) -> (std::process::Output, Vec) { + run_sql_cli( + &["sql", "--file", "script.sql", "--format", "json"], + Some(script), + ) + .await +} + +#[tokio::test] +async fn no_subcommand_opens_the_sql_shell() { + for args in [vec![], vec!["sql"]] { + let (output, queries) = run_sql_cli(&args, None).await; + assert!( + output.status.success(), + "{args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("LanceDB CLI v"), "{stdout}"); + assert!( + stdout.contains("Type \\? for help or \\q to quit"), + "{stdout}" + ); + assert!(queries.is_empty()); + } +} + +#[tokio::test] +async fn sql_options_work_with_and_without_the_subcommand() { + for prefix in [vec![], vec!["sql"]] { + let mut args = prefix.clone(); + args.extend(["--command", "SELECT 42", "--format", "json"]); + let (output, queries) = run_sql_cli(&args, None).await; + assert!( + output.status.success(), + "{args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(queries, ["SELECT 42"]); + let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(value, serde_json::json!([{"answer": ANSWER}])); + + let mut args = prefix; + args.extend(["--file", "script.sql", "--format", "json"]); + let (output, queries) = run_sql_cli(&args, Some("SELECT 'semi;colon';")).await; + assert!( + output.status.success(), + "{args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(queries, ["SELECT 'semi;colon'"]); + } +} + +#[tokio::test] +async fn sql_files_preserve_comments_and_quoted_semicolons() { + for (script, expected) in [ + ( + "SHOW JOBS; CANCEL JOB 'job-123';", + vec!["SHOW JOBS", "CANCEL JOB 'job-123'"], + ), + ( + "SELECT 1 -- documentation; DELETE FROM customers\n;", + vec!["SELECT 1 -- documentation; DELETE FROM customers"], + ), + ( + "SELECT 1 /* documentation; DELETE FROM customers */; SELECT 2;", + vec![ + "SELECT 1 /* documentation; DELETE FROM customers */", + "SELECT 2", + ], + ), + ( + "/* outer; /* nested; DELETE FROM customers */ still a comment; */ SELECT 3;", + vec!["/* outer; /* nested; DELETE FROM customers */ still a comment; */ SELECT 3"], + ), + ( + "SELECT 1 /*! ; DELETE FROM customers */;", + vec!["SELECT 1 /*! ; DELETE FROM customers */"], + ), + ( + r#"SELECT 'it''s; unchanged', "col;""name", `back;``tick`; SELECT 'next'"#, + vec![ + r#"SELECT 'it''s; unchanged', "col;""name", `back;``tick`"#, + "SELECT 'next'", + ], + ), + ( + "SELECT $$text; -- still text$$, $tag$more; /* text */$tag$; SELECT 3;", + vec![ + "SELECT $$text; -- still text$$, $tag$more; /* text */$tag$", + "SELECT 3", + ], + ), + ( + r"SELECT E'it\'s; unchanged'; SELECT 2;", + vec![r"SELECT E'it\'s; unchanged'", "SELECT 2"], + ), + ( + "SELECT 'text; -- still a string /* not a comment */'; SELECT 2;", + vec![ + "SELECT 'text; -- still a string /* not a comment */'", + "SELECT 2", + ], + ), + ( + "SELECT 'café; 🦀';\r\n-- comment; DELETE FROM customers\r\nSELECT 2; ;", + vec![ + "SELECT 'café; 🦀'", + "-- comment; DELETE FROM customers\r\nSELECT 2", + ], + ), + ( + "; -- documentation; DELETE FROM customers\n; /* comment; SELECT 2 */; ;", + vec![], + ), + ] { + let (output, queries) = run_sql_file(script).await; + assert!( + output.status.success(), + "script {script:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(queries, expected, "script: {script:?}"); + } +} + +#[tokio::test] +async fn sql_files_with_unterminated_tokens_submit_no_statements() { + for script in [ + "SELECT 1; SELECT 'unterminated; DELETE FROM customers", + "SELECT 1; SELECT \"unterminated; DELETE FROM customers", + "SELECT 1; SELECT `unterminated; DELETE FROM customers", + "SELECT 1; SELECT $tag$unterminated; DELETE FROM customers", + "SELECT 1; /* unterminated; DELETE FROM customers", + ] { + let (output, queries) = run_sql_file(script).await; + assert!(!output.status.success(), "script: {script:?}"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("Failed to tokenize SQL script"), + "unexpected error: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + queries.is_empty(), + "submitted SQL for {script:?}: {queries:?}" + ); + } +} + +#[tokio::test] +async fn ca_cert_enables_a_tls_flight_sql_query() { + let certs = generate_certs(); + let dir = tempfile::tempdir().unwrap(); + let ca_path = write_pem(&dir, "ca.pem", &certs.ca_pem); + let (addr, _shutdown) = start_tls_server(&certs).await; + + let mut executor = FlightSqlExecutor::new_with_api_key(String::new(), "sk_test".to_string()) + .with_tls(TlsOptions { + ca_cert: Some(ca_path), + ..TlsOptions::default() + }); + + executor + .connect(&format!("https://{addr}")) + .await + .expect("TLS connect with the signing CA should succeed"); + assert!(executor.is_connected().await); + + let result = executor + .execute_query("SELECT 1") + .await + .expect("query over TLS should succeed"); + match result { + SqlResult::Query(batches) => { + let rows: Vec = batches + .iter() + .flat_map(|batch| { + batch + .column(0) + .as_any() + .downcast_ref::() + .unwrap() + .values() + .to_vec() + }) + .collect(); + assert_eq!(rows, vec![ANSWER]); + } + other => panic!("expected a query result, got {other:?}"), + } +} + +#[tokio::test] +async fn tls_connect_fails_without_the_signing_ca() { + let certs = generate_certs(); + let (addr, _shutdown) = start_tls_server(&certs).await; + + // No CA configured: the server's certificate chains to a CA that is not in + // the platform trust store, so verification must reject it. + let mut executor = FlightSqlExecutor::new_with_api_key(String::new(), "sk_test".to_string()); + + assert!( + executor.connect(&format!("https://{addr}")).await.is_err(), + "an untrusted server certificate must not be accepted" + ); + assert!(!executor.is_connected().await); +} + +#[tokio::test] +async fn tls_connect_fails_with_an_unrelated_ca() { + let certs = generate_certs(); + // A second, unrelated CA: right file shape, wrong signer. + let other = generate_certs(); + let dir = tempfile::tempdir().unwrap(); + let unrelated_ca = write_pem(&dir, "other-ca.pem", &other.ca_pem); + let (addr, _shutdown) = start_tls_server(&certs).await; + + let mut executor = FlightSqlExecutor::new_with_api_key(String::new(), "sk_test".to_string()) + .with_tls(TlsOptions { + ca_cert: Some(unrelated_ca), + ..TlsOptions::default() + }); + + assert!( + executor.connect(&format!("https://{addr}")).await.is_err(), + "a certificate signed by a different CA must not be accepted" + ); +} + +#[tokio::test] +async fn a_missing_ca_cert_file_is_reported() { + let mut executor = FlightSqlExecutor::new_with_api_key(String::new(), "sk_test".to_string()) + .with_tls(TlsOptions { + ca_cert: Some("/nonexistent/ca.pem".to_string()), + ..TlsOptions::default() + }); + + let err = executor + .connect("https://127.0.0.1:1") + .await + .expect_err("a missing CA file must fail loudly"); + assert!( + err.to_string().contains("/nonexistent/ca.pem"), + "error should name the missing file, got: {err}" + ); +} + +#[tokio::test] +async fn a_client_certificate_satisfies_an_mtls_server() { + let certs = generate_certs(); + let dir = tempfile::tempdir().unwrap(); + let ca_path = write_pem(&dir, "ca.pem", &certs.ca_pem); + let cert_path = write_pem(&dir, "client.pem", &certs.client_cert_pem); + let key_path = write_pem(&dir, "client-key.pem", &certs.client_key_pem); + let (addr, _shutdown) = start_mtls_server(&certs).await; + + let mut executor = FlightSqlExecutor::new_with_api_key(String::new(), "sk_test".to_string()) + .with_tls(TlsOptions { + ca_cert: Some(ca_path), + client_cert: Some(cert_path), + client_key: Some(key_path), + }); + + executor + .connect(&format!("https://{addr}")) + .await + .expect("mTLS connect with a CA-signed client certificate should succeed"); + executor + .execute_query("SELECT 1") + .await + .expect("query over mTLS should succeed"); +} + +#[tokio::test] +async fn an_mtls_server_rejects_a_client_with_no_certificate() { + let certs = generate_certs(); + let dir = tempfile::tempdir().unwrap(); + let ca_path = write_pem(&dir, "ca.pem", &certs.ca_pem); + let (addr, _shutdown) = start_mtls_server(&certs).await; + + // Trusts the server, but presents no identity of its own. + let mut executor = FlightSqlExecutor::new_with_api_key(String::new(), "sk_test".to_string()) + .with_tls(TlsOptions { + ca_cert: Some(ca_path), + ..TlsOptions::default() + }); + + // TLS 1.3 sends the client certificate after the client considers the + // handshake done, so the rejection can surface at connect or at the first + // request. Either way the exchange must not succeed. + let rejected = match executor.connect(&format!("https://{addr}")).await { + Err(_) => true, + Ok(()) => executor.execute_query("SELECT 1").await.is_err(), + }; + assert!( + rejected, + "an mTLS server must reject a client with no certificate" + ); +} + +#[tokio::test] +async fn a_client_certificate_without_its_key_is_rejected_at_resolve() { + use lancedb_cli::server_config::ConnectionArgs; + + let args = ConnectionArgs { + client_cert: Some("/tmp/client.pem".to_string()), + ..ConnectionArgs::default() + }; + let err = args + .resolve(None) + .expect_err("a client certificate without a key must be rejected"); + assert!( + err.to_string().contains("--client-key"), + "error should name the missing flag, got: {err}" + ); +} diff --git a/rust/lancedb/src/connection.rs b/rust/lancedb/src/connection.rs index 065eb3084..20bb0b360 100644 --- a/rust/lancedb/src/connection.rs +++ b/rust/lancedb/src/connection.rs @@ -528,6 +528,12 @@ impl Connection { ExecuteQueryAsyncBuilder::new(self.internal.clone(), query.into()) } + /// Establish the remote SQL transport without executing a statement. + /// This checks connectivity and TLS; authentication is checked by subsequent requests. + pub async fn check_sql_connection(&self) -> Result<()> { + self.internal.check_sql_connection().await + } + /// Describe a submitted SQL query by its connection-scoped id. /// /// This performs one bounded status poll using state retained by this diff --git a/rust/lancedb/src/database.rs b/rust/lancedb/src/database.rs index 939861b88..54d51c24f 100644 --- a/rust/lancedb/src/database.rs +++ b/rust/lancedb/src/database.rs @@ -579,6 +579,12 @@ pub trait Database: async fn resume_job(&self, _job_id: &str) -> Result { job_op_not_supported("resume_job") } + /// Establish the SQL transport without executing a statement. + async fn check_sql_connection(&self) -> Result<()> { + Err(crate::Error::NotSupported { + message: "SQL is not supported by this database".into(), + }) + } /// Start executing a SQL statement on a LanceDB Distributed database. async fn execute_query_async( &self, diff --git a/rust/lancedb/src/job.rs b/rust/lancedb/src/job.rs index 5c27587dc..0e88a7432 100644 --- a/rust/lancedb/src/job.rs +++ b/rust/lancedb/src/job.rs @@ -92,6 +92,7 @@ impl TerminalResult { } } + #[cfg(feature = "remote")] pub(crate) fn remote(value: Option, request_id: String) -> Self { Self { value, @@ -242,6 +243,7 @@ impl Job<()> { /// A handle whose record the caller has already fetched, so the detail /// accessors answer without a second round trip. + #[cfg(feature = "remote")] pub(crate) fn opened(handle: Box, description: JobDescription) -> Self { let job = Self::new(handle); { diff --git a/rust/lancedb/src/remote/db.rs b/rust/lancedb/src/remote/db.rs index 4f97c16e8..5c85d0792 100644 --- a/rust/lancedb/src/remote/db.rs +++ b/rust/lancedb/src/remote/db.rs @@ -1357,6 +1357,16 @@ impl Database for RemoteDatabase { }) } + async fn check_sql_connection(&self) -> Result<()> { + self.sql_client + .as_ref() + .ok_or_else(|| Error::NotSupported { + message: "SQL is unavailable for this remote database client".into(), + })? + .check_connection() + .await + } + async fn execute_query_async(&self, request: ExecuteQueryRequest) -> Result { let client = self .sql_client diff --git a/rust/lancedb/src/remote/sql.rs b/rust/lancedb/src/remote/sql.rs index 207a0cbec..880f61045 100644 --- a/rust/lancedb/src/remote/sql.rs +++ b/rust/lancedb/src/remote/sql.rs @@ -170,6 +170,13 @@ impl std::fmt::Debug for SqlClient { } impl SqlClient { + pub(super) async fn check_connection(&self) -> Result<()> { + self.inner + .connection(&Uuid::new_v4().to_string()) + .await + .map(|_| ()) + } + pub(super) fn new( database: String, database_prefix: Option, @@ -1428,7 +1435,18 @@ async fn connect_channel( tokio::time::timeout(connect_timeout, endpoint.connect()) .await .map_err(|_| sql_error(request_id, "SQL connection timed out"))? - .map_err(|err| sql_error(request_id, err)) + .map_err(|err| { + // tonic's Display only says "transport error"; preserve the source chain + // so callers can distinguish DNS, refused connections, and TLS failures. + let mut message = err.to_string(); + let mut source = std::error::Error::source(&err); + while let Some(error) = source { + message.push_str(": "); + message.push_str(&error.to_string()); + source = error.source(); + } + sql_error(request_id, message) + }) } fn tls_config(config: Option<&TlsConfig>) -> Result {