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 {