feat: init
1afa378f
35 file(s) · +10821 −0
| 1 | + | # This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist |
|
| 2 | + | # |
|
| 3 | + | # Copyright 2022-2024, axodotdev |
|
| 4 | + | # SPDX-License-Identifier: MIT or Apache-2.0 |
|
| 5 | + | # |
|
| 6 | + | # CI that: |
|
| 7 | + | # |
|
| 8 | + | # * checks for a Git Tag that looks like a release |
|
| 9 | + | # * builds artifacts with dist (archives, installers, hashes) |
|
| 10 | + | # * uploads those artifacts to temporary workflow zip |
|
| 11 | + | # * on success, uploads the artifacts to a GitHub Release |
|
| 12 | + | # |
|
| 13 | + | # Note that the GitHub Release will be created with a generated |
|
| 14 | + | # title/body based on your changelogs. |
|
| 15 | + | ||
| 16 | + | name: Release |
|
| 17 | + | permissions: |
|
| 18 | + | "contents": "write" |
|
| 19 | + | ||
| 20 | + | # This task will run whenever you push a git tag that looks like a version |
|
| 21 | + | # like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. |
|
| 22 | + | # Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where |
|
| 23 | + | # PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION |
|
| 24 | + | # must be a Cargo-style SemVer Version (must have at least major.minor.patch). |
|
| 25 | + | # |
|
| 26 | + | # If PACKAGE_NAME is specified, then the announcement will be for that |
|
| 27 | + | # package (erroring out if it doesn't have the given version or isn't dist-able). |
|
| 28 | + | # |
|
| 29 | + | # If PACKAGE_NAME isn't specified, then the announcement will be for all |
|
| 30 | + | # (dist-able) packages in the workspace with that version (this mode is |
|
| 31 | + | # intended for workspaces with only one dist-able package, or with all dist-able |
|
| 32 | + | # packages versioned/released in lockstep). |
|
| 33 | + | # |
|
| 34 | + | # If you push multiple tags at once, separate instances of this workflow will |
|
| 35 | + | # spin up, creating an independent announcement for each one. However, GitHub |
|
| 36 | + | # will hard limit this to 3 tags per commit, as it will assume more tags is a |
|
| 37 | + | # mistake. |
|
| 38 | + | # |
|
| 39 | + | # If there's a prerelease-style suffix to the version, then the release(s) |
|
| 40 | + | # will be marked as a prerelease. |
|
| 41 | + | on: |
|
| 42 | + | pull_request: |
|
| 43 | + | push: |
|
| 44 | + | tags: |
|
| 45 | + | - '**[0-9]+.[0-9]+.[0-9]+*' |
|
| 46 | + | ||
| 47 | + | jobs: |
|
| 48 | + | # Run 'dist plan' (or host) to determine what tasks we need to do |
|
| 49 | + | plan: |
|
| 50 | + | runs-on: "ubuntu-22.04" |
|
| 51 | + | outputs: |
|
| 52 | + | val: ${{ steps.plan.outputs.manifest }} |
|
| 53 | + | tag: ${{ !github.event.pull_request && github.ref_name || '' }} |
|
| 54 | + | tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} |
|
| 55 | + | publishing: ${{ !github.event.pull_request }} |
|
| 56 | + | env: |
|
| 57 | + | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
|
| 58 | + | steps: |
|
| 59 | + | - uses: actions/checkout@v6 |
|
| 60 | + | with: |
|
| 61 | + | persist-credentials: false |
|
| 62 | + | submodules: recursive |
|
| 63 | + | - name: Install dist |
|
| 64 | + | # we specify bash to get pipefail; it guards against the `curl` command |
|
| 65 | + | # failing. otherwise `sh` won't catch that `curl` returned non-0 |
|
| 66 | + | shell: bash |
|
| 67 | + | run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.32.0/cargo-dist-installer.sh | sh" |
|
| 68 | + | - name: Cache dist |
|
| 69 | + | uses: actions/upload-artifact@v7 |
|
| 70 | + | with: |
|
| 71 | + | name: cargo-dist-cache |
|
| 72 | + | path: ~/.cargo/bin/dist |
|
| 73 | + | # sure would be cool if github gave us proper conditionals... |
|
| 74 | + | # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible |
|
| 75 | + | # functionality based on whether this is a pull_request, and whether it's from a fork. |
|
| 76 | + | # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* |
|
| 77 | + | # but also really annoying to build CI around when it needs secrets to work right.) |
|
| 78 | + | - id: plan |
|
| 79 | + | run: | |
|
| 80 | + | dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json |
|
| 81 | + | echo "dist ran successfully" |
|
| 82 | + | cat plan-dist-manifest.json |
|
| 83 | + | echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" |
|
| 84 | + | - name: "Upload dist-manifest.json" |
|
| 85 | + | uses: actions/upload-artifact@v7 |
|
| 86 | + | with: |
|
| 87 | + | name: artifacts-plan-dist-manifest |
|
| 88 | + | path: plan-dist-manifest.json |
|
| 89 | + | ||
| 90 | + | # Build and packages all the platform-specific things |
|
| 91 | + | build-local-artifacts: |
|
| 92 | + | name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) |
|
| 93 | + | # Let the initial task tell us to not run (currently very blunt) |
|
| 94 | + | needs: |
|
| 95 | + | - plan |
|
| 96 | + | if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} |
|
| 97 | + | strategy: |
|
| 98 | + | fail-fast: false |
|
| 99 | + | # Target platforms/runners are computed by dist in create-release. |
|
| 100 | + | # Each member of the matrix has the following arguments: |
|
| 101 | + | # |
|
| 102 | + | # - runner: the github runner |
|
| 103 | + | # - dist-args: cli flags to pass to dist |
|
| 104 | + | # - install-dist: expression to run to install dist on the runner |
|
| 105 | + | # |
|
| 106 | + | # Typically there will be: |
|
| 107 | + | # - 1 "global" task that builds universal installers |
|
| 108 | + | # - N "local" tasks that build each platform's binaries and platform-specific installers |
|
| 109 | + | matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} |
|
| 110 | + | runs-on: ${{ matrix.runner }} |
|
| 111 | + | container: ${{ matrix.container && matrix.container.image || null }} |
|
| 112 | + | env: |
|
| 113 | + | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
|
| 114 | + | BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json |
|
| 115 | + | steps: |
|
| 116 | + | - name: enable windows longpaths |
|
| 117 | + | run: | |
|
| 118 | + | git config --global core.longpaths true |
|
| 119 | + | - uses: actions/checkout@v6 |
|
| 120 | + | with: |
|
| 121 | + | persist-credentials: false |
|
| 122 | + | submodules: recursive |
|
| 123 | + | - name: Install Rust non-interactively if not already installed |
|
| 124 | + | if: ${{ matrix.container }} |
|
| 125 | + | run: | |
|
| 126 | + | if ! command -v cargo > /dev/null 2>&1; then |
|
| 127 | + | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y |
|
| 128 | + | echo "$HOME/.cargo/bin" >> $GITHUB_PATH |
|
| 129 | + | fi |
|
| 130 | + | - name: Install dist |
|
| 131 | + | run: ${{ matrix.install_dist.run }} |
|
| 132 | + | # Get the dist-manifest |
|
| 133 | + | - name: Fetch local artifacts |
|
| 134 | + | uses: actions/download-artifact@v8 |
|
| 135 | + | with: |
|
| 136 | + | pattern: artifacts-* |
|
| 137 | + | path: target/distrib/ |
|
| 138 | + | merge-multiple: true |
|
| 139 | + | - name: Install dependencies |
|
| 140 | + | run: | |
|
| 141 | + | ${{ matrix.packages_install }} |
|
| 142 | + | - name: Build artifacts |
|
| 143 | + | run: | |
|
| 144 | + | # Actually do builds and make zips and whatnot |
|
| 145 | + | dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json |
|
| 146 | + | echo "dist ran successfully" |
|
| 147 | + | - id: cargo-dist |
|
| 148 | + | name: Post-build |
|
| 149 | + | # We force bash here just because github makes it really hard to get values up |
|
| 150 | + | # to "real" actions without writing to env-vars, and writing to env-vars has |
|
| 151 | + | # inconsistent syntax between shell and powershell. |
|
| 152 | + | shell: bash |
|
| 153 | + | run: | |
|
| 154 | + | # Parse out what we just built and upload it to scratch storage |
|
| 155 | + | echo "paths<<EOF" >> "$GITHUB_OUTPUT" |
|
| 156 | + | dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" |
|
| 157 | + | echo "EOF" >> "$GITHUB_OUTPUT" |
|
| 158 | + | ||
| 159 | + | cp dist-manifest.json "$BUILD_MANIFEST_NAME" |
|
| 160 | + | - name: "Upload artifacts" |
|
| 161 | + | uses: actions/upload-artifact@v7 |
|
| 162 | + | with: |
|
| 163 | + | name: artifacts-build-local-${{ join(matrix.targets, '_') }} |
|
| 164 | + | path: | |
|
| 165 | + | ${{ steps.cargo-dist.outputs.paths }} |
|
| 166 | + | ${{ env.BUILD_MANIFEST_NAME }} |
|
| 167 | + | ||
| 168 | + | # Build and package all the platform-agnostic(ish) things |
|
| 169 | + | build-global-artifacts: |
|
| 170 | + | needs: |
|
| 171 | + | - plan |
|
| 172 | + | - build-local-artifacts |
|
| 173 | + | runs-on: "ubuntu-22.04" |
|
| 174 | + | env: |
|
| 175 | + | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
|
| 176 | + | BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json |
|
| 177 | + | steps: |
|
| 178 | + | - uses: actions/checkout@v6 |
|
| 179 | + | with: |
|
| 180 | + | persist-credentials: false |
|
| 181 | + | submodules: recursive |
|
| 182 | + | - name: Install cached dist |
|
| 183 | + | uses: actions/download-artifact@v8 |
|
| 184 | + | with: |
|
| 185 | + | name: cargo-dist-cache |
|
| 186 | + | path: ~/.cargo/bin/ |
|
| 187 | + | - run: chmod +x ~/.cargo/bin/dist |
|
| 188 | + | # Get all the local artifacts for the global tasks to use (for e.g. checksums) |
|
| 189 | + | - name: Fetch local artifacts |
|
| 190 | + | uses: actions/download-artifact@v8 |
|
| 191 | + | with: |
|
| 192 | + | pattern: artifacts-* |
|
| 193 | + | path: target/distrib/ |
|
| 194 | + | merge-multiple: true |
|
| 195 | + | - id: cargo-dist |
|
| 196 | + | shell: bash |
|
| 197 | + | run: | |
|
| 198 | + | dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json |
|
| 199 | + | echo "dist ran successfully" |
|
| 200 | + | ||
| 201 | + | # Parse out what we just built and upload it to scratch storage |
|
| 202 | + | echo "paths<<EOF" >> "$GITHUB_OUTPUT" |
|
| 203 | + | jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" |
|
| 204 | + | echo "EOF" >> "$GITHUB_OUTPUT" |
|
| 205 | + | ||
| 206 | + | cp dist-manifest.json "$BUILD_MANIFEST_NAME" |
|
| 207 | + | - name: "Upload artifacts" |
|
| 208 | + | uses: actions/upload-artifact@v7 |
|
| 209 | + | with: |
|
| 210 | + | name: artifacts-build-global |
|
| 211 | + | path: | |
|
| 212 | + | ${{ steps.cargo-dist.outputs.paths }} |
|
| 213 | + | ${{ env.BUILD_MANIFEST_NAME }} |
|
| 214 | + | # Determines if we should publish/announce |
|
| 215 | + | host: |
|
| 216 | + | needs: |
|
| 217 | + | - plan |
|
| 218 | + | - build-local-artifacts |
|
| 219 | + | - build-global-artifacts |
|
| 220 | + | # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) |
|
| 221 | + | if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} |
|
| 222 | + | env: |
|
| 223 | + | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
|
| 224 | + | runs-on: "ubuntu-22.04" |
|
| 225 | + | outputs: |
|
| 226 | + | val: ${{ steps.host.outputs.manifest }} |
|
| 227 | + | steps: |
|
| 228 | + | - uses: actions/checkout@v6 |
|
| 229 | + | with: |
|
| 230 | + | persist-credentials: false |
|
| 231 | + | submodules: recursive |
|
| 232 | + | - name: Install cached dist |
|
| 233 | + | uses: actions/download-artifact@v8 |
|
| 234 | + | with: |
|
| 235 | + | name: cargo-dist-cache |
|
| 236 | + | path: ~/.cargo/bin/ |
|
| 237 | + | - run: chmod +x ~/.cargo/bin/dist |
|
| 238 | + | # Fetch artifacts from scratch-storage |
|
| 239 | + | - name: Fetch artifacts |
|
| 240 | + | uses: actions/download-artifact@v8 |
|
| 241 | + | with: |
|
| 242 | + | pattern: artifacts-* |
|
| 243 | + | path: target/distrib/ |
|
| 244 | + | merge-multiple: true |
|
| 245 | + | - id: host |
|
| 246 | + | shell: bash |
|
| 247 | + | run: | |
|
| 248 | + | dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json |
|
| 249 | + | echo "artifacts uploaded and released successfully" |
|
| 250 | + | cat dist-manifest.json |
|
| 251 | + | echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" |
|
| 252 | + | - name: "Upload dist-manifest.json" |
|
| 253 | + | uses: actions/upload-artifact@v7 |
|
| 254 | + | with: |
|
| 255 | + | # Overwrite the previous copy |
|
| 256 | + | name: artifacts-dist-manifest |
|
| 257 | + | path: dist-manifest.json |
|
| 258 | + | # Create a GitHub Release while uploading all files to it |
|
| 259 | + | - name: "Download GitHub Artifacts" |
|
| 260 | + | uses: actions/download-artifact@v8 |
|
| 261 | + | with: |
|
| 262 | + | pattern: artifacts-* |
|
| 263 | + | path: artifacts |
|
| 264 | + | merge-multiple: true |
|
| 265 | + | - name: Cleanup |
|
| 266 | + | run: | |
|
| 267 | + | # Remove the granular manifests |
|
| 268 | + | rm -f artifacts/*-dist-manifest.json |
|
| 269 | + | - name: Create GitHub Release |
|
| 270 | + | env: |
|
| 271 | + | PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" |
|
| 272 | + | ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" |
|
| 273 | + | ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" |
|
| 274 | + | RELEASE_COMMIT: "${{ github.sha }}" |
|
| 275 | + | run: | |
|
| 276 | + | # Write and read notes from a file to avoid quoting breaking things |
|
| 277 | + | echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt |
|
| 278 | + | ||
| 279 | + | gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* |
|
| 280 | + | ||
| 281 | + | publish-homebrew-formula: |
|
| 282 | + | needs: |
|
| 283 | + | - plan |
|
| 284 | + | - host |
|
| 285 | + | runs-on: "ubuntu-22.04" |
|
| 286 | + | env: |
|
| 287 | + | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
|
| 288 | + | PLAN: ${{ needs.plan.outputs.val }} |
|
| 289 | + | GITHUB_USER: "axo bot" |
|
| 290 | + | GITHUB_EMAIL: "admin+bot@axo.dev" |
|
| 291 | + | if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} |
|
| 292 | + | steps: |
|
| 293 | + | - uses: actions/checkout@v6 |
|
| 294 | + | with: |
|
| 295 | + | persist-credentials: true |
|
| 296 | + | repository: "stevedylandev/cielago" |
|
| 297 | + | token: ${{ secrets.HOMEBREW_TAP_TOKEN }} |
|
| 298 | + | # So we have access to the formula |
|
| 299 | + | - name: Fetch homebrew formulae |
|
| 300 | + | uses: actions/download-artifact@v8 |
|
| 301 | + | with: |
|
| 302 | + | pattern: artifacts-* |
|
| 303 | + | path: Formula/ |
|
| 304 | + | merge-multiple: true |
|
| 305 | + | # This is extra complex because you can make your Formula name not match your app name |
|
| 306 | + | # so we need to find releases with a *.rb file, and publish with that filename. |
|
| 307 | + | - name: Commit formula files |
|
| 308 | + | run: | |
|
| 309 | + | git config --global user.name "${GITHUB_USER}" |
|
| 310 | + | git config --global user.email "${GITHUB_EMAIL}" |
|
| 311 | + | ||
| 312 | + | for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith(".rb")] | any)'); do |
|
| 313 | + | filename=$(echo "$release" | jq '.artifacts[] | select(endswith(".rb"))' --raw-output) |
|
| 314 | + | name=$(echo "$filename" | sed "s/\.rb$//") |
|
| 315 | + | version=$(echo "$release" | jq .app_version --raw-output) |
|
| 316 | + | ||
| 317 | + | export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH" |
|
| 318 | + | brew update |
|
| 319 | + | # We avoid reformatting user-provided data such as the app description and homepage. |
|
| 320 | + | brew style --except-cops FormulaAudit/Homepage,FormulaAudit/Desc,FormulaAuditStrict --fix "Formula/${filename}" || true |
|
| 321 | + | ||
| 322 | + | git add "Formula/${filename}" |
|
| 323 | + | git commit -m "${name} ${version}" |
|
| 324 | + | done |
|
| 325 | + | git push |
|
| 326 | + | ||
| 327 | + | announce: |
|
| 328 | + | needs: |
|
| 329 | + | - plan |
|
| 330 | + | - host |
|
| 331 | + | - publish-homebrew-formula |
|
| 332 | + | # use "always() && ..." to allow us to wait for all publish jobs while |
|
| 333 | + | # still allowing individual publish jobs to skip themselves (for prereleases). |
|
| 334 | + | # "host" however must run to completion, no skipping allowed! |
|
| 335 | + | if: ${{ always() && needs.host.result == 'success' && (needs.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.result == 'success') }} |
|
| 336 | + | runs-on: "ubuntu-22.04" |
|
| 337 | + | env: |
|
| 338 | + | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
|
| 339 | + | steps: |
|
| 340 | + | - uses: actions/checkout@v6 |
|
| 341 | + | with: |
|
| 342 | + | persist-credentials: false |
|
| 343 | + | submodules: recursive |
| 1 | + | /target |
| 1 | + | # AGENTS.md |
|
| 2 | + | ||
| 3 | + | Notes for whoever (human or agent) works on this codebase next. |
|
| 4 | + | ||
| 5 | + | ## Layout |
|
| 6 | + | ||
| 7 | + | ``` |
|
| 8 | + | src/ |
|
| 9 | + | ├── main.rs # clap CLI: import / list / open (default) / info / edit / rename / delete / path |
|
| 10 | + | ├── lib.rs # re-exports the modules below for the binary + tests |
|
| 11 | + | ├── app.rs # App state, vim mode machine, ratatui run loop |
|
| 12 | + | ├── input.rs # keymap: Normal / Insert / Command modes, popups |
|
| 13 | + | ├── ui.rs # rendering: sidebar, url bar, editor tabs, response, popups |
|
| 14 | + | ├── highlight.rs # JSON/XML tokenizer -> styled ratatui Lines |
|
| 15 | + | ├── model.rs # Collection / SavedRequest / KeyValueRow / FieldDoc / OAuthConfig (serde) |
|
| 16 | + | ├── store.rs # ~/.config/cielago persistence, AppConfig |
|
| 17 | + | ├── openapi/ |
|
| 18 | + | │ ├── loader.rs # load spec from file path or http(s) URL, JSON/YAML |
|
| 19 | + | │ ├── resolve.rs # local `#/...` $ref resolution (cycle-safe) |
|
| 20 | + | │ ├── examples.rs # schema -> example JSON value generation |
|
| 21 | + | │ ├── docs.rs # schema -> FieldDoc (types, enums) for the Docs tab |
|
| 22 | + | │ └── import.rs # Spec -> Collection conversion |
|
| 23 | + | └── http/ |
|
| 24 | + | ├── client.rs # reqwest request building + response capture |
|
| 25 | + | ├── oauth.rs # client-credentials token exchange |
|
| 26 | + | ├── send.rs # send_with_auth: cache/refresh token, 401 retry |
|
| 27 | + | ├── url_input.rs # pasted URL -> origin / path / query (inverse of build_url) |
|
| 28 | + | └── vars.rs # {{name}} + dynamic ({{uuid}}, {{randomInt}}…) substitution |
|
| 29 | + | ``` |
|
| 30 | + | ||
| 31 | + | `tests/` has fixture-driven integration tests: `import_tests.rs` (spec → |
|
| 32 | + | collection), `http_tests.rs`/`app_send_tests.rs` (wiremock-backed HTTP + |
|
| 33 | + | OAuth), `input_tests.rs` (full keymap flows over an in-memory `App`), |
|
| 34 | + | `ui_tests.rs` (draws into a ratatui `TestBackend` and asserts on cell colours |
|
| 35 | + | — the only place rendering is covered). |
|
| 36 | + | ||
| 37 | + | ## Design decisions worth knowing |
|
| 38 | + | ||
| 39 | + | - **The project was renamed `getman` → `stableman` → `manpost` → `cielago`.** |
|
| 40 | + | `store::config_dir` moves a leftover `~/.config/{manpost,stableman,getman}` |
|
| 41 | + | onto `~/.config/cielago` on first use (see `LEGACY_DIR_NAMES`), so existing |
|
| 42 | + | collections survive. Drop those migrations once they've had time to run |
|
| 43 | + | everywhere. |
|
| 44 | + | - **No remote `$ref`s.** `openapi::resolve` only follows local |
|
| 45 | + | `#/components/...` JSON pointers. Specs that split across files aren't |
|
| 46 | + | supported — bundle them first if you hit this. |
|
| 47 | + | - **Secrets are plaintext.** `OAuthConfig.client_secret` is saved as-is in |
|
| 48 | + | the collection JSON (explicit user choice, not an oversight). The |
|
| 49 | + | in-memory `OAuthToken` obtained from it is never persisted. |
|
| 50 | + | - **Tags become sidebar groups**, first tag only; untagged requests land in |
|
| 51 | + | a `default` group. This wasn't asked for explicitly but was cheap and |
|
| 52 | + | matches how most specs are organized. |
|
| 53 | + | - **`Method::parse`, not `FromStr`** — deliberately not the trait, to dodge |
|
| 54 | + | a clippy lint; nothing else depends on `FromStr`. |
|
| 55 | + | - **Vim modes are `Normal` / `Insert` / `Command` / `Search`** — no Visual |
|
| 56 | + | mode. Insert mode is reused for both single-line field edits (`LineEdit`) |
|
| 57 | + | and the body `TextArea`; `app.editing` discriminates which. `Search` is the |
|
| 58 | + | `/` sidebar filter: it re-applies on every keystroke, and `app.filter` (the |
|
| 59 | + | committed query) is deliberately separate from `app.search` (the live |
|
| 60 | + | prompt buffer) so `Esc` can drop the prompt without touching the filter. |
|
| 61 | + | - **Sidebar labels are a view concern.** `SavedRequest` keeps `name` (user |
|
| 62 | + | editable), `summary` and `operation_id` (verbatim from the spec); |
|
| 63 | + | `Collection.label_mode` picks which one renders. `:rename-all` is the only |
|
| 64 | + | thing that overwrites `name`. Import prefers `summary` over `operationId` |
|
| 65 | + | for the initial name — most real specs put a generated controller method |
|
| 66 | + | name in `operationId`. |
|
| 67 | + | - **Switching collections reassigns the whole `App`** (`switch_collection` does |
|
| 68 | + | `*self = App::new(...)`). Everything view-related is derived from the |
|
| 69 | + | collection, so there's nothing to migrate by hand — and dropping the mpsc |
|
| 70 | + | channel and cached `OAuthToken` is a feature, not collateral: a response still |
|
| 71 | + | in flight for the previous collection can no longer land in the new one, and |
|
| 72 | + | the token belonged to the old `auth` config. It deliberately does *not* go |
|
| 73 | + | through a `pending_*` field like `run_external_edit` does; that indirection |
|
| 74 | + | only exists because the editor needs the `&mut Terminal` to suspend raw mode, |
|
| 75 | + | and routing a switch through the run loop would put it out of reach of |
|
| 76 | + | `input_tests.rs`. |
|
| 77 | + | - **Pasting a URL rewrites collection state.** `App::apply_url_input` + |
|
| 78 | + | `http::url_input` split an absolute URL into origin / path / query: the origin |
|
| 79 | + | is added to `servers` (deduped on `trim_end_matches('/')`, since `E`-added |
|
| 80 | + | servers may carry a trailing slash) **and made active**, because the URL bar |
|
| 81 | + | renders `base_url() + path` and would otherwise show something other than what |
|
| 82 | + | was just pasted. Query rows are replaced only when the input actually contained |
|
| 83 | + | a `?` — otherwise fixing a typo'd path would silently wipe the disabled |
|
| 84 | + | optional params an import set up. Two traps the module exists to handle: |
|
| 85 | + | `Url::parse("localhost:8080/x")` *succeeds* with scheme `localhost` (hence the |
|
| 86 | + | http/https + host check), and `{`/`}` are in the crate's path encode set, so |
|
| 87 | + | `/pets/{id}` comes back as `/pets/%7Bid%7D` and needs `restore_braces`. |
|
| 88 | + | - **`path_params` follows the path.** `SavedRequest::sync_path_params` derives |
|
| 89 | + | the rows from `{placeholders}` in `path`, pruning ones that no longer appear — |
|
| 90 | + | `build_url` ignores those anyway, so a stale row only makes the Params tab |
|
| 91 | + | lie. `{{variables}}` are skipped by the scanner. Import does *not* call it: |
|
| 92 | + | spec-declared rows are authoritative there. |
|
| 93 | + | - **`$EDITOR` integration** shells out synchronously, suspending raw mode |
|
| 94 | + | around it (`app::run_external_edit`). It writes/reads a temp file rather |
|
| 95 | + | than piping, so it works with any editor. |
|
| 96 | + | - **Highlighting is hand-rolled** (`highlight.rs`), line-oriented, and never |
|
| 97 | + | drops input: every character comes back out in some span (there's a test). |
|
| 98 | + | A `syntect`-class dependency would be larger than the rest of the binary, |
|
| 99 | + | and JSON/XML/plain is all a request client shows. |
|
| 100 | + | - **The body has two renderers.** `tui-textarea` styles whole lines only, so |
|
| 101 | + | the Body tab renders a highlighted `Paragraph` in Normal mode and the raw |
|
| 102 | + | `TextArea` in Insert mode. The textarea stays the source of truth either |
|
| 103 | + | way; the read-only view scrolls by moving *its* cursor, which is why `j`/`k` |
|
| 104 | + | on the Body tab drive `CursorMove`. |
|
| 105 | + | - **Dynamic variables live in the `{{…}}` namespace**, not a second syntax: |
|
| 106 | + | `{{uuid}}`, `{{randomInt(1,10)}}`, `{{isoTimestamp}}`. A collection variable |
|
| 107 | + | shadows a dynamic one of the same name (`{{$name}}` forces the dynamic one), |
|
| 108 | + | so a fixed `uuid` can be pinned for debugging. Randomness comes from UUID v4 |
|
| 109 | + | bytes and the RFC 3339 formatter is hand-written — both to avoid adding |
|
| 110 | + | `rand`/`chrono` for a handful of values. |
|
| 111 | + | - **Collections carry a saved view.** `last_request` (request id), |
|
| 112 | + | `last_focus` (pane `1`/`2`/`3`) and `last_tab` are written by |
|
| 113 | + | `App::record_view` when `:w` runs, and replayed by `App::new` — it reopens |
|
| 114 | + | the request (expanding its group if `groups_collapsed` would hide it), then |
|
| 115 | + | restores the pane and tab. Two deliberate wrinkles: the view is recorded |
|
| 116 | + | *during* `save` rather than by `select_request`, because marking the |
|
| 117 | + | collection dirty just for moving the sidebar cursor would make `:q` nag |
|
| 118 | + | after a read-only browse (the trade: navigate away, quit clean, and the old |
|
| 119 | + | position stays); and a saved `Response` focus falls back to the editor, |
|
| 120 | + | since responses aren't persisted and pane 3 is empty on open. |
|
| 121 | + | `Focus`/`EditorTab` stay in `app.rs` and gained serde derives, so `model.rs` |
|
| 122 | + | reaches back into `app` for those two types. |
|
| 123 | + | - **CLI names resolve by slug** (`store::match_name`): the file is named after |
|
| 124 | + | `slugify(name)` anyway, so `delete "some api"` and `delete some-api` both hit |
|
| 125 | + | `Some API`. Exact name wins first. `store::resolve_collection` is the entry |
|
| 126 | + | point and produces the "Available: …" error every name-taking command shares. |
|
| 127 | + | - **`cielago edit` edits a temp copy, not the file.** It only writes back after |
|
| 128 | + | the edited text parses as a `Collection`; on a parse error the temp file is |
|
| 129 | + | left in place and its path printed, so a botched edit is recoverable. A `name` |
|
| 130 | + | changed in the editor is a rename (file moves, `config.last_collection` |
|
| 131 | + | follows), and it bails rather than overwriting a different collection whose |
|
| 132 | + | name slugifies the same. |
|
| 133 | + | - **`FieldDoc`s are stored on the request**, not read from the spec on demand: |
|
| 134 | + | a collection's `spec_source` is often a URL that has moved or needs auth by |
|
| 135 | + | the time the collection is opened. Cost is a re-import to refresh them, and |
|
| 136 | + | older collections having none. |
|
| 137 | + | ||
| 138 | + | ## Before committing |
|
| 139 | + | ||
| 140 | + | ```sh |
|
| 141 | + | cargo fmt |
|
| 142 | + | cargo clippy --all-targets # keep this clean, no warnings |
|
| 143 | + | cargo test |
|
| 144 | + | ``` |
|
| 145 | + | ||
| 146 | + | ## Known gaps (intentionally out of scope for v1) |
|
| 147 | + | ||
| 148 | + | Swagger 2.0, non-client-credentials OAuth flows (auth-code, API key), |
|
| 149 | + | collection folders beyond tag grouping, request history/response diffing. |
|
| 150 | + | The Docs tab covers request inputs only — response schemas and status codes |
|
| 151 | + | aren't imported. |
| 1 | + | # This file is automatically @generated by Cargo. |
|
| 2 | + | # It is not intended for manual editing. |
|
| 3 | + | version = 4 |
|
| 4 | + | ||
| 5 | + | [[package]] |
|
| 6 | + | name = "aho-corasick" |
|
| 7 | + | version = "1.1.5" |
|
| 8 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 9 | + | checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" |
|
| 10 | + | dependencies = [ |
|
| 11 | + | "memchr", |
|
| 12 | + | ] |
|
| 13 | + | ||
| 14 | + | [[package]] |
|
| 15 | + | name = "allocator-api2" |
|
| 16 | + | version = "0.2.21" |
|
| 17 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 18 | + | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" |
|
| 19 | + | ||
| 20 | + | [[package]] |
|
| 21 | + | name = "anstream" |
|
| 22 | + | version = "1.0.0" |
|
| 23 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 24 | + | checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" |
|
| 25 | + | dependencies = [ |
|
| 26 | + | "anstyle", |
|
| 27 | + | "anstyle-parse", |
|
| 28 | + | "anstyle-query", |
|
| 29 | + | "anstyle-wincon", |
|
| 30 | + | "colorchoice", |
|
| 31 | + | "is_terminal_polyfill", |
|
| 32 | + | "utf8parse", |
|
| 33 | + | ] |
|
| 34 | + | ||
| 35 | + | [[package]] |
|
| 36 | + | name = "anstyle" |
|
| 37 | + | version = "1.0.14" |
|
| 38 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 39 | + | checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" |
|
| 40 | + | ||
| 41 | + | [[package]] |
|
| 42 | + | name = "anstyle-parse" |
|
| 43 | + | version = "1.0.0" |
|
| 44 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 45 | + | checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" |
|
| 46 | + | dependencies = [ |
|
| 47 | + | "utf8parse", |
|
| 48 | + | ] |
|
| 49 | + | ||
| 50 | + | [[package]] |
|
| 51 | + | name = "anstyle-query" |
|
| 52 | + | version = "1.1.5" |
|
| 53 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 54 | + | checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" |
|
| 55 | + | dependencies = [ |
|
| 56 | + | "windows-sys 0.61.2", |
|
| 57 | + | ] |
|
| 58 | + | ||
| 59 | + | [[package]] |
|
| 60 | + | name = "anstyle-wincon" |
|
| 61 | + | version = "3.0.11" |
|
| 62 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 63 | + | checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" |
|
| 64 | + | dependencies = [ |
|
| 65 | + | "anstyle", |
|
| 66 | + | "once_cell_polyfill", |
|
| 67 | + | "windows-sys 0.61.2", |
|
| 68 | + | ] |
|
| 69 | + | ||
| 70 | + | [[package]] |
|
| 71 | + | name = "anyhow" |
|
| 72 | + | version = "1.0.104" |
|
| 73 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 74 | + | checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" |
|
| 75 | + | ||
| 76 | + | [[package]] |
|
| 77 | + | name = "assert-json-diff" |
|
| 78 | + | version = "2.0.2" |
|
| 79 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 80 | + | checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" |
|
| 81 | + | dependencies = [ |
|
| 82 | + | "serde", |
|
| 83 | + | "serde_json", |
|
| 84 | + | ] |
|
| 85 | + | ||
| 86 | + | [[package]] |
|
| 87 | + | name = "atomic-waker" |
|
| 88 | + | version = "1.1.2" |
|
| 89 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 90 | + | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" |
|
| 91 | + | ||
| 92 | + | [[package]] |
|
| 93 | + | name = "aws-lc-rs" |
|
| 94 | + | version = "1.17.3" |
|
| 95 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 96 | + | checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" |
|
| 97 | + | dependencies = [ |
|
| 98 | + | "aws-lc-sys", |
|
| 99 | + | "zeroize", |
|
| 100 | + | ] |
|
| 101 | + | ||
| 102 | + | [[package]] |
|
| 103 | + | name = "aws-lc-sys" |
|
| 104 | + | version = "0.43.0" |
|
| 105 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 106 | + | checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" |
|
| 107 | + | dependencies = [ |
|
| 108 | + | "cc", |
|
| 109 | + | "cmake", |
|
| 110 | + | "dunce", |
|
| 111 | + | "fs_extra", |
|
| 112 | + | "pkg-config", |
|
| 113 | + | ] |
|
| 114 | + | ||
| 115 | + | [[package]] |
|
| 116 | + | name = "base64" |
|
| 117 | + | version = "0.22.1" |
|
| 118 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 119 | + | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" |
|
| 120 | + | ||
| 121 | + | [[package]] |
|
| 122 | + | name = "bitflags" |
|
| 123 | + | version = "2.13.1" |
|
| 124 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 125 | + | checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" |
|
| 126 | + | ||
| 127 | + | [[package]] |
|
| 128 | + | name = "bumpalo" |
|
| 129 | + | version = "3.20.3" |
|
| 130 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 131 | + | checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" |
|
| 132 | + | ||
| 133 | + | [[package]] |
|
| 134 | + | name = "bytes" |
|
| 135 | + | version = "1.12.1" |
|
| 136 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 137 | + | checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" |
|
| 138 | + | ||
| 139 | + | [[package]] |
|
| 140 | + | name = "cassowary" |
|
| 141 | + | version = "0.3.0" |
|
| 142 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 143 | + | checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" |
|
| 144 | + | ||
| 145 | + | [[package]] |
|
| 146 | + | name = "castaway" |
|
| 147 | + | version = "0.2.4" |
|
| 148 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 149 | + | checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" |
|
| 150 | + | dependencies = [ |
|
| 151 | + | "rustversion", |
|
| 152 | + | ] |
|
| 153 | + | ||
| 154 | + | [[package]] |
|
| 155 | + | name = "cc" |
|
| 156 | + | version = "1.4.1" |
|
| 157 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 158 | + | checksum = "9066c49992464636f92905fa096ec58baaa4d57ec19a5c096c68d3e25ef3d136" |
|
| 159 | + | dependencies = [ |
|
| 160 | + | "find-msvc-tools", |
|
| 161 | + | "jobserver", |
|
| 162 | + | "libc", |
|
| 163 | + | "shlex", |
|
| 164 | + | ] |
|
| 165 | + | ||
| 166 | + | [[package]] |
|
| 167 | + | name = "cfg-if" |
|
| 168 | + | version = "1.0.4" |
|
| 169 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 170 | + | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" |
|
| 171 | + | ||
| 172 | + | [[package]] |
|
| 173 | + | name = "cfg_aliases" |
|
| 174 | + | version = "0.2.2" |
|
| 175 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 176 | + | checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" |
|
| 177 | + | ||
| 178 | + | [[package]] |
|
| 179 | + | name = "chacha20" |
|
| 180 | + | version = "0.10.1" |
|
| 181 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 182 | + | checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" |
|
| 183 | + | dependencies = [ |
|
| 184 | + | "cfg-if", |
|
| 185 | + | "cpufeatures", |
|
| 186 | + | "rand_core", |
|
| 187 | + | ] |
|
| 188 | + | ||
| 189 | + | [[package]] |
|
| 190 | + | name = "cielago" |
|
| 191 | + | version = "0.1.0" |
|
| 192 | + | dependencies = [ |
|
| 193 | + | "anyhow", |
|
| 194 | + | "clap", |
|
| 195 | + | "crossterm", |
|
| 196 | + | "dirs", |
|
| 197 | + | "ratatui", |
|
| 198 | + | "reqwest", |
|
| 199 | + | "serde", |
|
| 200 | + | "serde_json", |
|
| 201 | + | "serde_yaml", |
|
| 202 | + | "tempfile", |
|
| 203 | + | "thiserror", |
|
| 204 | + | "tokio", |
|
| 205 | + | "tui-textarea", |
|
| 206 | + | "url", |
|
| 207 | + | "uuid", |
|
| 208 | + | "wiremock", |
|
| 209 | + | ] |
|
| 210 | + | ||
| 211 | + | [[package]] |
|
| 212 | + | name = "clap" |
|
| 213 | + | version = "4.6.6" |
|
| 214 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 215 | + | checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" |
|
| 216 | + | dependencies = [ |
|
| 217 | + | "clap_builder", |
|
| 218 | + | "clap_derive", |
|
| 219 | + | ] |
|
| 220 | + | ||
| 221 | + | [[package]] |
|
| 222 | + | name = "clap_builder" |
|
| 223 | + | version = "4.6.6" |
|
| 224 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 225 | + | checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" |
|
| 226 | + | dependencies = [ |
|
| 227 | + | "anstream", |
|
| 228 | + | "anstyle", |
|
| 229 | + | "clap_lex", |
|
| 230 | + | "strsim", |
|
| 231 | + | ] |
|
| 232 | + | ||
| 233 | + | [[package]] |
|
| 234 | + | name = "clap_derive" |
|
| 235 | + | version = "4.6.4" |
|
| 236 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 237 | + | checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" |
|
| 238 | + | dependencies = [ |
|
| 239 | + | "heck", |
|
| 240 | + | "proc-macro2", |
|
| 241 | + | "quote", |
|
| 242 | + | "syn 3.0.3", |
|
| 243 | + | ] |
|
| 244 | + | ||
| 245 | + | [[package]] |
|
| 246 | + | name = "clap_lex" |
|
| 247 | + | version = "1.1.0" |
|
| 248 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 249 | + | checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" |
|
| 250 | + | ||
| 251 | + | [[package]] |
|
| 252 | + | name = "cmake" |
|
| 253 | + | version = "0.1.58" |
|
| 254 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 255 | + | checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" |
|
| 256 | + | dependencies = [ |
|
| 257 | + | "cc", |
|
| 258 | + | ] |
|
| 259 | + | ||
| 260 | + | [[package]] |
|
| 261 | + | name = "colorchoice" |
|
| 262 | + | version = "1.0.5" |
|
| 263 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 264 | + | checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" |
|
| 265 | + | ||
| 266 | + | [[package]] |
|
| 267 | + | name = "combine" |
|
| 268 | + | version = "4.6.7" |
|
| 269 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 270 | + | checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" |
|
| 271 | + | dependencies = [ |
|
| 272 | + | "bytes", |
|
| 273 | + | "memchr", |
|
| 274 | + | ] |
|
| 275 | + | ||
| 276 | + | [[package]] |
|
| 277 | + | name = "compact_str" |
|
| 278 | + | version = "0.8.2" |
|
| 279 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 280 | + | checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" |
|
| 281 | + | dependencies = [ |
|
| 282 | + | "castaway", |
|
| 283 | + | "cfg-if", |
|
| 284 | + | "itoa", |
|
| 285 | + | "rustversion", |
|
| 286 | + | "ryu", |
|
| 287 | + | "static_assertions", |
|
| 288 | + | ] |
|
| 289 | + | ||
| 290 | + | [[package]] |
|
| 291 | + | name = "core-foundation" |
|
| 292 | + | version = "0.10.1" |
|
| 293 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 294 | + | checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" |
|
| 295 | + | dependencies = [ |
|
| 296 | + | "core-foundation-sys", |
|
| 297 | + | "libc", |
|
| 298 | + | ] |
|
| 299 | + | ||
| 300 | + | [[package]] |
|
| 301 | + | name = "core-foundation-sys" |
|
| 302 | + | version = "0.8.7" |
|
| 303 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 304 | + | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" |
|
| 305 | + | ||
| 306 | + | [[package]] |
|
| 307 | + | name = "cpufeatures" |
|
| 308 | + | version = "0.3.0" |
|
| 309 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 310 | + | checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" |
|
| 311 | + | dependencies = [ |
|
| 312 | + | "libc", |
|
| 313 | + | ] |
|
| 314 | + | ||
| 315 | + | [[package]] |
|
| 316 | + | name = "crossterm" |
|
| 317 | + | version = "0.28.1" |
|
| 318 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 319 | + | checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" |
|
| 320 | + | dependencies = [ |
|
| 321 | + | "bitflags", |
|
| 322 | + | "crossterm_winapi", |
|
| 323 | + | "mio", |
|
| 324 | + | "parking_lot", |
|
| 325 | + | "rustix 0.38.44", |
|
| 326 | + | "signal-hook", |
|
| 327 | + | "signal-hook-mio", |
|
| 328 | + | "winapi", |
|
| 329 | + | ] |
|
| 330 | + | ||
| 331 | + | [[package]] |
|
| 332 | + | name = "crossterm_winapi" |
|
| 333 | + | version = "0.9.1" |
|
| 334 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 335 | + | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" |
|
| 336 | + | dependencies = [ |
|
| 337 | + | "winapi", |
|
| 338 | + | ] |
|
| 339 | + | ||
| 340 | + | [[package]] |
|
| 341 | + | name = "darling" |
|
| 342 | + | version = "0.24.0" |
|
| 343 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 344 | + | checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23" |
|
| 345 | + | dependencies = [ |
|
| 346 | + | "darling_core", |
|
| 347 | + | "darling_macro", |
|
| 348 | + | ] |
|
| 349 | + | ||
| 350 | + | [[package]] |
|
| 351 | + | name = "darling_core" |
|
| 352 | + | version = "0.24.0" |
|
| 353 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 354 | + | checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4" |
|
| 355 | + | dependencies = [ |
|
| 356 | + | "ident_case", |
|
| 357 | + | "proc-macro2", |
|
| 358 | + | "quote", |
|
| 359 | + | "strsim", |
|
| 360 | + | "syn 3.0.3", |
|
| 361 | + | ] |
|
| 362 | + | ||
| 363 | + | [[package]] |
|
| 364 | + | name = "darling_macro" |
|
| 365 | + | version = "0.24.0" |
|
| 366 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 367 | + | checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e" |
|
| 368 | + | dependencies = [ |
|
| 369 | + | "darling_core", |
|
| 370 | + | "quote", |
|
| 371 | + | "syn 3.0.3", |
|
| 372 | + | ] |
|
| 373 | + | ||
| 374 | + | [[package]] |
|
| 375 | + | name = "deadpool" |
|
| 376 | + | version = "0.12.3" |
|
| 377 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 378 | + | checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" |
|
| 379 | + | dependencies = [ |
|
| 380 | + | "deadpool-runtime", |
|
| 381 | + | "lazy_static", |
|
| 382 | + | "num_cpus", |
|
| 383 | + | "tokio", |
|
| 384 | + | ] |
|
| 385 | + | ||
| 386 | + | [[package]] |
|
| 387 | + | name = "deadpool-runtime" |
|
| 388 | + | version = "0.1.4" |
|
| 389 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 390 | + | checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" |
|
| 391 | + | ||
| 392 | + | [[package]] |
|
| 393 | + | name = "dirs" |
|
| 394 | + | version = "6.0.0" |
|
| 395 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 396 | + | checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" |
|
| 397 | + | dependencies = [ |
|
| 398 | + | "dirs-sys", |
|
| 399 | + | ] |
|
| 400 | + | ||
| 401 | + | [[package]] |
|
| 402 | + | name = "dirs-sys" |
|
| 403 | + | version = "0.5.0" |
|
| 404 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 405 | + | checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" |
|
| 406 | + | dependencies = [ |
|
| 407 | + | "libc", |
|
| 408 | + | "option-ext", |
|
| 409 | + | "redox_users", |
|
| 410 | + | "windows-sys 0.61.2", |
|
| 411 | + | ] |
|
| 412 | + | ||
| 413 | + | [[package]] |
|
| 414 | + | name = "displaydoc" |
|
| 415 | + | version = "0.2.7" |
|
| 416 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 417 | + | checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" |
|
| 418 | + | dependencies = [ |
|
| 419 | + | "proc-macro2", |
|
| 420 | + | "quote", |
|
| 421 | + | "syn 3.0.3", |
|
| 422 | + | ] |
|
| 423 | + | ||
| 424 | + | [[package]] |
|
| 425 | + | name = "dunce" |
|
| 426 | + | version = "1.0.5" |
|
| 427 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 428 | + | checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" |
|
| 429 | + | ||
| 430 | + | [[package]] |
|
| 431 | + | name = "either" |
|
| 432 | + | version = "1.17.0" |
|
| 433 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 434 | + | checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" |
|
| 435 | + | ||
| 436 | + | [[package]] |
|
| 437 | + | name = "equivalent" |
|
| 438 | + | version = "1.0.2" |
|
| 439 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 440 | + | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" |
|
| 441 | + | ||
| 442 | + | [[package]] |
|
| 443 | + | name = "errno" |
|
| 444 | + | version = "0.3.14" |
|
| 445 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 446 | + | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" |
|
| 447 | + | dependencies = [ |
|
| 448 | + | "libc", |
|
| 449 | + | "windows-sys 0.61.2", |
|
| 450 | + | ] |
|
| 451 | + | ||
| 452 | + | [[package]] |
|
| 453 | + | name = "fastrand" |
|
| 454 | + | version = "2.5.0" |
|
| 455 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 456 | + | checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" |
|
| 457 | + | ||
| 458 | + | [[package]] |
|
| 459 | + | name = "find-msvc-tools" |
|
| 460 | + | version = "0.1.10" |
|
| 461 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 462 | + | checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" |
|
| 463 | + | ||
| 464 | + | [[package]] |
|
| 465 | + | name = "fnv" |
|
| 466 | + | version = "1.0.7" |
|
| 467 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 468 | + | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" |
|
| 469 | + | ||
| 470 | + | [[package]] |
|
| 471 | + | name = "foldhash" |
|
| 472 | + | version = "0.1.5" |
|
| 473 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 474 | + | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" |
|
| 475 | + | ||
| 476 | + | [[package]] |
|
| 477 | + | name = "form_urlencoded" |
|
| 478 | + | version = "1.2.2" |
|
| 479 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 480 | + | checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" |
|
| 481 | + | dependencies = [ |
|
| 482 | + | "percent-encoding", |
|
| 483 | + | ] |
|
| 484 | + | ||
| 485 | + | [[package]] |
|
| 486 | + | name = "fs_extra" |
|
| 487 | + | version = "1.3.0" |
|
| 488 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 489 | + | checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" |
|
| 490 | + | ||
| 491 | + | [[package]] |
|
| 492 | + | name = "futures" |
|
| 493 | + | version = "0.3.33" |
|
| 494 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 495 | + | checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" |
|
| 496 | + | dependencies = [ |
|
| 497 | + | "futures-channel", |
|
| 498 | + | "futures-core", |
|
| 499 | + | "futures-executor", |
|
| 500 | + | "futures-io", |
|
| 501 | + | "futures-sink", |
|
| 502 | + | "futures-task", |
|
| 503 | + | "futures-util", |
|
| 504 | + | ] |
|
| 505 | + | ||
| 506 | + | [[package]] |
|
| 507 | + | name = "futures-channel" |
|
| 508 | + | version = "0.3.33" |
|
| 509 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 510 | + | checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" |
|
| 511 | + | dependencies = [ |
|
| 512 | + | "futures-core", |
|
| 513 | + | "futures-sink", |
|
| 514 | + | ] |
|
| 515 | + | ||
| 516 | + | [[package]] |
|
| 517 | + | name = "futures-core" |
|
| 518 | + | version = "0.3.33" |
|
| 519 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 520 | + | checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" |
|
| 521 | + | ||
| 522 | + | [[package]] |
|
| 523 | + | name = "futures-executor" |
|
| 524 | + | version = "0.3.33" |
|
| 525 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 526 | + | checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" |
|
| 527 | + | dependencies = [ |
|
| 528 | + | "futures-core", |
|
| 529 | + | "futures-task", |
|
| 530 | + | "futures-util", |
|
| 531 | + | ] |
|
| 532 | + | ||
| 533 | + | [[package]] |
|
| 534 | + | name = "futures-io" |
|
| 535 | + | version = "0.3.33" |
|
| 536 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 537 | + | checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" |
|
| 538 | + | ||
| 539 | + | [[package]] |
|
| 540 | + | name = "futures-macro" |
|
| 541 | + | version = "0.3.33" |
|
| 542 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 543 | + | checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" |
|
| 544 | + | dependencies = [ |
|
| 545 | + | "proc-macro2", |
|
| 546 | + | "quote", |
|
| 547 | + | "syn 2.0.119", |
|
| 548 | + | ] |
|
| 549 | + | ||
| 550 | + | [[package]] |
|
| 551 | + | name = "futures-sink" |
|
| 552 | + | version = "0.3.33" |
|
| 553 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 554 | + | checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" |
|
| 555 | + | ||
| 556 | + | [[package]] |
|
| 557 | + | name = "futures-task" |
|
| 558 | + | version = "0.3.33" |
|
| 559 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 560 | + | checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" |
|
| 561 | + | ||
| 562 | + | [[package]] |
|
| 563 | + | name = "futures-util" |
|
| 564 | + | version = "0.3.33" |
|
| 565 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 566 | + | checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" |
|
| 567 | + | dependencies = [ |
|
| 568 | + | "futures-channel", |
|
| 569 | + | "futures-core", |
|
| 570 | + | "futures-io", |
|
| 571 | + | "futures-macro", |
|
| 572 | + | "futures-sink", |
|
| 573 | + | "futures-task", |
|
| 574 | + | "memchr", |
|
| 575 | + | "pin-project-lite", |
|
| 576 | + | "slab", |
|
| 577 | + | ] |
|
| 578 | + | ||
| 579 | + | [[package]] |
|
| 580 | + | name = "getrandom" |
|
| 581 | + | version = "0.2.17" |
|
| 582 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 583 | + | checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" |
|
| 584 | + | dependencies = [ |
|
| 585 | + | "cfg-if", |
|
| 586 | + | "js-sys", |
|
| 587 | + | "libc", |
|
| 588 | + | "wasi", |
|
| 589 | + | "wasm-bindgen", |
|
| 590 | + | ] |
|
| 591 | + | ||
| 592 | + | [[package]] |
|
| 593 | + | name = "getrandom" |
|
| 594 | + | version = "0.4.3" |
|
| 595 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 596 | + | checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" |
|
| 597 | + | dependencies = [ |
|
| 598 | + | "cfg-if", |
|
| 599 | + | "js-sys", |
|
| 600 | + | "libc", |
|
| 601 | + | "r-efi", |
|
| 602 | + | "rand_core", |
|
| 603 | + | "wasm-bindgen", |
|
| 604 | + | ] |
|
| 605 | + | ||
| 606 | + | [[package]] |
|
| 607 | + | name = "h2" |
|
| 608 | + | version = "0.4.15" |
|
| 609 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 610 | + | checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" |
|
| 611 | + | dependencies = [ |
|
| 612 | + | "atomic-waker", |
|
| 613 | + | "bytes", |
|
| 614 | + | "fnv", |
|
| 615 | + | "futures-core", |
|
| 616 | + | "futures-sink", |
|
| 617 | + | "http", |
|
| 618 | + | "indexmap", |
|
| 619 | + | "slab", |
|
| 620 | + | "tokio", |
|
| 621 | + | "tokio-util", |
|
| 622 | + | "tracing", |
|
| 623 | + | ] |
|
| 624 | + | ||
| 625 | + | [[package]] |
|
| 626 | + | name = "hashbrown" |
|
| 627 | + | version = "0.15.5" |
|
| 628 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 629 | + | checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" |
|
| 630 | + | dependencies = [ |
|
| 631 | + | "allocator-api2", |
|
| 632 | + | "equivalent", |
|
| 633 | + | "foldhash", |
|
| 634 | + | ] |
|
| 635 | + | ||
| 636 | + | [[package]] |
|
| 637 | + | name = "hashbrown" |
|
| 638 | + | version = "0.17.1" |
|
| 639 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 640 | + | checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" |
|
| 641 | + | ||
| 642 | + | [[package]] |
|
| 643 | + | name = "heck" |
|
| 644 | + | version = "0.5.0" |
|
| 645 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 646 | + | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" |
|
| 647 | + | ||
| 648 | + | [[package]] |
|
| 649 | + | name = "hermit-abi" |
|
| 650 | + | version = "0.5.2" |
|
| 651 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 652 | + | checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" |
|
| 653 | + | ||
| 654 | + | [[package]] |
|
| 655 | + | name = "http" |
|
| 656 | + | version = "1.5.0" |
|
| 657 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 658 | + | checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" |
|
| 659 | + | dependencies = [ |
|
| 660 | + | "bytes", |
|
| 661 | + | "itoa", |
|
| 662 | + | ] |
|
| 663 | + | ||
| 664 | + | [[package]] |
|
| 665 | + | name = "http-body" |
|
| 666 | + | version = "1.1.0" |
|
| 667 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 668 | + | checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" |
|
| 669 | + | dependencies = [ |
|
| 670 | + | "bytes", |
|
| 671 | + | "http", |
|
| 672 | + | ] |
|
| 673 | + | ||
| 674 | + | [[package]] |
|
| 675 | + | name = "http-body-util" |
|
| 676 | + | version = "0.1.4" |
|
| 677 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 678 | + | checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" |
|
| 679 | + | dependencies = [ |
|
| 680 | + | "bytes", |
|
| 681 | + | "futures-core", |
|
| 682 | + | "http", |
|
| 683 | + | "http-body", |
|
| 684 | + | "pin-project-lite", |
|
| 685 | + | ] |
|
| 686 | + | ||
| 687 | + | [[package]] |
|
| 688 | + | name = "httparse" |
|
| 689 | + | version = "1.10.1" |
|
| 690 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 691 | + | checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" |
|
| 692 | + | ||
| 693 | + | [[package]] |
|
| 694 | + | name = "httpdate" |
|
| 695 | + | version = "1.0.3" |
|
| 696 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 697 | + | checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" |
|
| 698 | + | ||
| 699 | + | [[package]] |
|
| 700 | + | name = "hyper" |
|
| 701 | + | version = "1.11.0" |
|
| 702 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 703 | + | checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" |
|
| 704 | + | dependencies = [ |
|
| 705 | + | "atomic-waker", |
|
| 706 | + | "bytes", |
|
| 707 | + | "futures-channel", |
|
| 708 | + | "futures-core", |
|
| 709 | + | "h2", |
|
| 710 | + | "http", |
|
| 711 | + | "http-body", |
|
| 712 | + | "httparse", |
|
| 713 | + | "httpdate", |
|
| 714 | + | "itoa", |
|
| 715 | + | "pin-project-lite", |
|
| 716 | + | "smallvec", |
|
| 717 | + | "tokio", |
|
| 718 | + | "want", |
|
| 719 | + | ] |
|
| 720 | + | ||
| 721 | + | [[package]] |
|
| 722 | + | name = "hyper-rustls" |
|
| 723 | + | version = "0.27.9" |
|
| 724 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 725 | + | checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" |
|
| 726 | + | dependencies = [ |
|
| 727 | + | "http", |
|
| 728 | + | "hyper", |
|
| 729 | + | "hyper-util", |
|
| 730 | + | "rustls", |
|
| 731 | + | "tokio", |
|
| 732 | + | "tokio-rustls", |
|
| 733 | + | "tower-service", |
|
| 734 | + | ] |
|
| 735 | + | ||
| 736 | + | [[package]] |
|
| 737 | + | name = "hyper-util" |
|
| 738 | + | version = "0.1.20" |
|
| 739 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 740 | + | checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" |
|
| 741 | + | dependencies = [ |
|
| 742 | + | "base64", |
|
| 743 | + | "bytes", |
|
| 744 | + | "futures-channel", |
|
| 745 | + | "futures-util", |
|
| 746 | + | "http", |
|
| 747 | + | "http-body", |
|
| 748 | + | "hyper", |
|
| 749 | + | "ipnet", |
|
| 750 | + | "libc", |
|
| 751 | + | "percent-encoding", |
|
| 752 | + | "pin-project-lite", |
|
| 753 | + | "socket2", |
|
| 754 | + | "tokio", |
|
| 755 | + | "tower-service", |
|
| 756 | + | "tracing", |
|
| 757 | + | ] |
|
| 758 | + | ||
| 759 | + | [[package]] |
|
| 760 | + | name = "icu_collections" |
|
| 761 | + | version = "2.2.0" |
|
| 762 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 763 | + | checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" |
|
| 764 | + | dependencies = [ |
|
| 765 | + | "displaydoc", |
|
| 766 | + | "potential_utf", |
|
| 767 | + | "utf8_iter", |
|
| 768 | + | "yoke", |
|
| 769 | + | "zerofrom", |
|
| 770 | + | "zerovec", |
|
| 771 | + | ] |
|
| 772 | + | ||
| 773 | + | [[package]] |
|
| 774 | + | name = "icu_locale_core" |
|
| 775 | + | version = "2.2.0" |
|
| 776 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 777 | + | checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" |
|
| 778 | + | dependencies = [ |
|
| 779 | + | "displaydoc", |
|
| 780 | + | "litemap", |
|
| 781 | + | "tinystr", |
|
| 782 | + | "writeable", |
|
| 783 | + | "zerovec", |
|
| 784 | + | ] |
|
| 785 | + | ||
| 786 | + | [[package]] |
|
| 787 | + | name = "icu_normalizer" |
|
| 788 | + | version = "2.2.0" |
|
| 789 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 790 | + | checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" |
|
| 791 | + | dependencies = [ |
|
| 792 | + | "icu_collections", |
|
| 793 | + | "icu_normalizer_data", |
|
| 794 | + | "icu_properties", |
|
| 795 | + | "icu_provider", |
|
| 796 | + | "smallvec", |
|
| 797 | + | "zerovec", |
|
| 798 | + | ] |
|
| 799 | + | ||
| 800 | + | [[package]] |
|
| 801 | + | name = "icu_normalizer_data" |
|
| 802 | + | version = "2.2.0" |
|
| 803 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 804 | + | checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" |
|
| 805 | + | ||
| 806 | + | [[package]] |
|
| 807 | + | name = "icu_properties" |
|
| 808 | + | version = "2.2.0" |
|
| 809 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 810 | + | checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" |
|
| 811 | + | dependencies = [ |
|
| 812 | + | "icu_collections", |
|
| 813 | + | "icu_locale_core", |
|
| 814 | + | "icu_properties_data", |
|
| 815 | + | "icu_provider", |
|
| 816 | + | "zerotrie", |
|
| 817 | + | "zerovec", |
|
| 818 | + | ] |
|
| 819 | + | ||
| 820 | + | [[package]] |
|
| 821 | + | name = "icu_properties_data" |
|
| 822 | + | version = "2.2.0" |
|
| 823 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 824 | + | checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" |
|
| 825 | + | ||
| 826 | + | [[package]] |
|
| 827 | + | name = "icu_provider" |
|
| 828 | + | version = "2.2.0" |
|
| 829 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 830 | + | checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" |
|
| 831 | + | dependencies = [ |
|
| 832 | + | "displaydoc", |
|
| 833 | + | "icu_locale_core", |
|
| 834 | + | "writeable", |
|
| 835 | + | "yoke", |
|
| 836 | + | "zerofrom", |
|
| 837 | + | "zerotrie", |
|
| 838 | + | "zerovec", |
|
| 839 | + | ] |
|
| 840 | + | ||
| 841 | + | [[package]] |
|
| 842 | + | name = "ident_case" |
|
| 843 | + | version = "1.0.1" |
|
| 844 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 845 | + | checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" |
|
| 846 | + | ||
| 847 | + | [[package]] |
|
| 848 | + | name = "idna" |
|
| 849 | + | version = "1.1.0" |
|
| 850 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 851 | + | checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" |
|
| 852 | + | dependencies = [ |
|
| 853 | + | "idna_adapter", |
|
| 854 | + | "smallvec", |
|
| 855 | + | "utf8_iter", |
|
| 856 | + | ] |
|
| 857 | + | ||
| 858 | + | [[package]] |
|
| 859 | + | name = "idna_adapter" |
|
| 860 | + | version = "1.2.2" |
|
| 861 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 862 | + | checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" |
|
| 863 | + | dependencies = [ |
|
| 864 | + | "icu_normalizer", |
|
| 865 | + | "icu_properties", |
|
| 866 | + | ] |
|
| 867 | + | ||
| 868 | + | [[package]] |
|
| 869 | + | name = "indexmap" |
|
| 870 | + | version = "2.14.0" |
|
| 871 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 872 | + | checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" |
|
| 873 | + | dependencies = [ |
|
| 874 | + | "equivalent", |
|
| 875 | + | "hashbrown 0.17.1", |
|
| 876 | + | ] |
|
| 877 | + | ||
| 878 | + | [[package]] |
|
| 879 | + | name = "indoc" |
|
| 880 | + | version = "2.0.7" |
|
| 881 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 882 | + | checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" |
|
| 883 | + | dependencies = [ |
|
| 884 | + | "rustversion", |
|
| 885 | + | ] |
|
| 886 | + | ||
| 887 | + | [[package]] |
|
| 888 | + | name = "instability" |
|
| 889 | + | version = "0.3.13" |
|
| 890 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 891 | + | checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" |
|
| 892 | + | dependencies = [ |
|
| 893 | + | "darling", |
|
| 894 | + | "indoc", |
|
| 895 | + | "proc-macro2", |
|
| 896 | + | "quote", |
|
| 897 | + | "syn 3.0.3", |
|
| 898 | + | ] |
|
| 899 | + | ||
| 900 | + | [[package]] |
|
| 901 | + | name = "ipnet" |
|
| 902 | + | version = "2.12.1" |
|
| 903 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 904 | + | checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" |
|
| 905 | + | ||
| 906 | + | [[package]] |
|
| 907 | + | name = "is_terminal_polyfill" |
|
| 908 | + | version = "1.70.2" |
|
| 909 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 910 | + | checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" |
|
| 911 | + | ||
| 912 | + | [[package]] |
|
| 913 | + | name = "itertools" |
|
| 914 | + | version = "0.13.0" |
|
| 915 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 916 | + | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" |
|
| 917 | + | dependencies = [ |
|
| 918 | + | "either", |
|
| 919 | + | ] |
|
| 920 | + | ||
| 921 | + | [[package]] |
|
| 922 | + | name = "itoa" |
|
| 923 | + | version = "1.0.18" |
|
| 924 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 925 | + | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" |
|
| 926 | + | ||
| 927 | + | [[package]] |
|
| 928 | + | name = "jni" |
|
| 929 | + | version = "0.22.4" |
|
| 930 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 931 | + | checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" |
|
| 932 | + | dependencies = [ |
|
| 933 | + | "cfg-if", |
|
| 934 | + | "combine", |
|
| 935 | + | "jni-macros", |
|
| 936 | + | "jni-sys", |
|
| 937 | + | "log", |
|
| 938 | + | "simd_cesu8", |
|
| 939 | + | "thiserror", |
|
| 940 | + | "walkdir", |
|
| 941 | + | "windows-link", |
|
| 942 | + | ] |
|
| 943 | + | ||
| 944 | + | [[package]] |
|
| 945 | + | name = "jni-macros" |
|
| 946 | + | version = "0.22.4" |
|
| 947 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 948 | + | checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" |
|
| 949 | + | dependencies = [ |
|
| 950 | + | "proc-macro2", |
|
| 951 | + | "quote", |
|
| 952 | + | "rustc_version", |
|
| 953 | + | "simd_cesu8", |
|
| 954 | + | "syn 2.0.119", |
|
| 955 | + | ] |
|
| 956 | + | ||
| 957 | + | [[package]] |
|
| 958 | + | name = "jni-sys" |
|
| 959 | + | version = "0.4.1" |
|
| 960 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 961 | + | checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" |
|
| 962 | + | dependencies = [ |
|
| 963 | + | "jni-sys-macros", |
|
| 964 | + | ] |
|
| 965 | + | ||
| 966 | + | [[package]] |
|
| 967 | + | name = "jni-sys-macros" |
|
| 968 | + | version = "0.4.1" |
|
| 969 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 970 | + | checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" |
|
| 971 | + | dependencies = [ |
|
| 972 | + | "quote", |
|
| 973 | + | "syn 2.0.119", |
|
| 974 | + | ] |
|
| 975 | + | ||
| 976 | + | [[package]] |
|
| 977 | + | name = "jobserver" |
|
| 978 | + | version = "0.1.35" |
|
| 979 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 980 | + | checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" |
|
| 981 | + | dependencies = [ |
|
| 982 | + | "getrandom 0.4.3", |
|
| 983 | + | "libc", |
|
| 984 | + | ] |
|
| 985 | + | ||
| 986 | + | [[package]] |
|
| 987 | + | name = "js-sys" |
|
| 988 | + | version = "0.3.103" |
|
| 989 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 990 | + | checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" |
|
| 991 | + | dependencies = [ |
|
| 992 | + | "cfg-if", |
|
| 993 | + | "futures-util", |
|
| 994 | + | "wasm-bindgen", |
|
| 995 | + | ] |
|
| 996 | + | ||
| 997 | + | [[package]] |
|
| 998 | + | name = "lazy_static" |
|
| 999 | + | version = "1.5.0" |
|
| 1000 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1001 | + | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" |
|
| 1002 | + | ||
| 1003 | + | [[package]] |
|
| 1004 | + | name = "libc" |
|
| 1005 | + | version = "0.2.189" |
|
| 1006 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1007 | + | checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" |
|
| 1008 | + | ||
| 1009 | + | [[package]] |
|
| 1010 | + | name = "libredox" |
|
| 1011 | + | version = "0.1.19" |
|
| 1012 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1013 | + | checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" |
|
| 1014 | + | dependencies = [ |
|
| 1015 | + | "libc", |
|
| 1016 | + | ] |
|
| 1017 | + | ||
| 1018 | + | [[package]] |
|
| 1019 | + | name = "linux-raw-sys" |
|
| 1020 | + | version = "0.4.15" |
|
| 1021 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1022 | + | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" |
|
| 1023 | + | ||
| 1024 | + | [[package]] |
|
| 1025 | + | name = "linux-raw-sys" |
|
| 1026 | + | version = "0.12.1" |
|
| 1027 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1028 | + | checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" |
|
| 1029 | + | ||
| 1030 | + | [[package]] |
|
| 1031 | + | name = "litemap" |
|
| 1032 | + | version = "0.8.2" |
|
| 1033 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1034 | + | checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" |
|
| 1035 | + | ||
| 1036 | + | [[package]] |
|
| 1037 | + | name = "lock_api" |
|
| 1038 | + | version = "0.4.14" |
|
| 1039 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1040 | + | checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" |
|
| 1041 | + | dependencies = [ |
|
| 1042 | + | "scopeguard", |
|
| 1043 | + | ] |
|
| 1044 | + | ||
| 1045 | + | [[package]] |
|
| 1046 | + | name = "log" |
|
| 1047 | + | version = "0.4.33" |
|
| 1048 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1049 | + | checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" |
|
| 1050 | + | ||
| 1051 | + | [[package]] |
|
| 1052 | + | name = "lru" |
|
| 1053 | + | version = "0.12.5" |
|
| 1054 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1055 | + | checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" |
|
| 1056 | + | dependencies = [ |
|
| 1057 | + | "hashbrown 0.15.5", |
|
| 1058 | + | ] |
|
| 1059 | + | ||
| 1060 | + | [[package]] |
|
| 1061 | + | name = "lru-slab" |
|
| 1062 | + | version = "0.1.2" |
|
| 1063 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1064 | + | checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" |
|
| 1065 | + | ||
| 1066 | + | [[package]] |
|
| 1067 | + | name = "memchr" |
|
| 1068 | + | version = "2.8.3" |
|
| 1069 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1070 | + | checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" |
|
| 1071 | + | ||
| 1072 | + | [[package]] |
|
| 1073 | + | name = "mio" |
|
| 1074 | + | version = "1.2.2" |
|
| 1075 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1076 | + | checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" |
|
| 1077 | + | dependencies = [ |
|
| 1078 | + | "libc", |
|
| 1079 | + | "log", |
|
| 1080 | + | "wasi", |
|
| 1081 | + | "windows-sys 0.61.2", |
|
| 1082 | + | ] |
|
| 1083 | + | ||
| 1084 | + | [[package]] |
|
| 1085 | + | name = "num_cpus" |
|
| 1086 | + | version = "1.17.0" |
|
| 1087 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1088 | + | checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" |
|
| 1089 | + | dependencies = [ |
|
| 1090 | + | "hermit-abi", |
|
| 1091 | + | "libc", |
|
| 1092 | + | ] |
|
| 1093 | + | ||
| 1094 | + | [[package]] |
|
| 1095 | + | name = "once_cell" |
|
| 1096 | + | version = "1.21.4" |
|
| 1097 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1098 | + | checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" |
|
| 1099 | + | ||
| 1100 | + | [[package]] |
|
| 1101 | + | name = "once_cell_polyfill" |
|
| 1102 | + | version = "1.70.2" |
|
| 1103 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1104 | + | checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" |
|
| 1105 | + | ||
| 1106 | + | [[package]] |
|
| 1107 | + | name = "openssl-probe" |
|
| 1108 | + | version = "0.2.1" |
|
| 1109 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1110 | + | checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" |
|
| 1111 | + | ||
| 1112 | + | [[package]] |
|
| 1113 | + | name = "option-ext" |
|
| 1114 | + | version = "0.2.0" |
|
| 1115 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1116 | + | checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" |
|
| 1117 | + | ||
| 1118 | + | [[package]] |
|
| 1119 | + | name = "parking_lot" |
|
| 1120 | + | version = "0.12.5" |
|
| 1121 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1122 | + | checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" |
|
| 1123 | + | dependencies = [ |
|
| 1124 | + | "lock_api", |
|
| 1125 | + | "parking_lot_core", |
|
| 1126 | + | ] |
|
| 1127 | + | ||
| 1128 | + | [[package]] |
|
| 1129 | + | name = "parking_lot_core" |
|
| 1130 | + | version = "0.9.12" |
|
| 1131 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1132 | + | checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" |
|
| 1133 | + | dependencies = [ |
|
| 1134 | + | "cfg-if", |
|
| 1135 | + | "libc", |
|
| 1136 | + | "redox_syscall", |
|
| 1137 | + | "smallvec", |
|
| 1138 | + | "windows-link", |
|
| 1139 | + | ] |
|
| 1140 | + | ||
| 1141 | + | [[package]] |
|
| 1142 | + | name = "paste" |
|
| 1143 | + | version = "1.0.15" |
|
| 1144 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1145 | + | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" |
|
| 1146 | + | ||
| 1147 | + | [[package]] |
|
| 1148 | + | name = "percent-encoding" |
|
| 1149 | + | version = "2.3.2" |
|
| 1150 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1151 | + | checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" |
|
| 1152 | + | ||
| 1153 | + | [[package]] |
|
| 1154 | + | name = "pin-project-lite" |
|
| 1155 | + | version = "0.2.17" |
|
| 1156 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1157 | + | checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" |
|
| 1158 | + | ||
| 1159 | + | [[package]] |
|
| 1160 | + | name = "pkg-config" |
|
| 1161 | + | version = "0.3.33" |
|
| 1162 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1163 | + | checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" |
|
| 1164 | + | ||
| 1165 | + | [[package]] |
|
| 1166 | + | name = "potential_utf" |
|
| 1167 | + | version = "0.1.5" |
|
| 1168 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1169 | + | checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" |
|
| 1170 | + | dependencies = [ |
|
| 1171 | + | "zerovec", |
|
| 1172 | + | ] |
|
| 1173 | + | ||
| 1174 | + | [[package]] |
|
| 1175 | + | name = "proc-macro2" |
|
| 1176 | + | version = "1.0.107" |
|
| 1177 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1178 | + | checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" |
|
| 1179 | + | dependencies = [ |
|
| 1180 | + | "unicode-ident", |
|
| 1181 | + | ] |
|
| 1182 | + | ||
| 1183 | + | [[package]] |
|
| 1184 | + | name = "quinn" |
|
| 1185 | + | version = "0.11.11" |
|
| 1186 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1187 | + | checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" |
|
| 1188 | + | dependencies = [ |
|
| 1189 | + | "bytes", |
|
| 1190 | + | "cfg_aliases", |
|
| 1191 | + | "pin-project-lite", |
|
| 1192 | + | "quinn-proto", |
|
| 1193 | + | "quinn-udp", |
|
| 1194 | + | "rustc-hash", |
|
| 1195 | + | "rustls", |
|
| 1196 | + | "socket2", |
|
| 1197 | + | "thiserror", |
|
| 1198 | + | "tokio", |
|
| 1199 | + | "tracing", |
|
| 1200 | + | "web-time", |
|
| 1201 | + | ] |
|
| 1202 | + | ||
| 1203 | + | [[package]] |
|
| 1204 | + | name = "quinn-proto" |
|
| 1205 | + | version = "0.11.16" |
|
| 1206 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1207 | + | checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" |
|
| 1208 | + | dependencies = [ |
|
| 1209 | + | "aws-lc-rs", |
|
| 1210 | + | "bytes", |
|
| 1211 | + | "getrandom 0.4.3", |
|
| 1212 | + | "lru-slab", |
|
| 1213 | + | "rand", |
|
| 1214 | + | "rand_pcg", |
|
| 1215 | + | "ring", |
|
| 1216 | + | "rustc-hash", |
|
| 1217 | + | "rustls", |
|
| 1218 | + | "rustls-pki-types", |
|
| 1219 | + | "slab", |
|
| 1220 | + | "thiserror", |
|
| 1221 | + | "tinyvec", |
|
| 1222 | + | "tracing", |
|
| 1223 | + | "web-time", |
|
| 1224 | + | ] |
|
| 1225 | + | ||
| 1226 | + | [[package]] |
|
| 1227 | + | name = "quinn-udp" |
|
| 1228 | + | version = "0.5.15" |
|
| 1229 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1230 | + | checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" |
|
| 1231 | + | dependencies = [ |
|
| 1232 | + | "cfg_aliases", |
|
| 1233 | + | "libc", |
|
| 1234 | + | "once_cell", |
|
| 1235 | + | "socket2", |
|
| 1236 | + | "tracing", |
|
| 1237 | + | "windows-sys 0.61.2", |
|
| 1238 | + | ] |
|
| 1239 | + | ||
| 1240 | + | [[package]] |
|
| 1241 | + | name = "quote" |
|
| 1242 | + | version = "1.0.47" |
|
| 1243 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1244 | + | checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" |
|
| 1245 | + | dependencies = [ |
|
| 1246 | + | "proc-macro2", |
|
| 1247 | + | ] |
|
| 1248 | + | ||
| 1249 | + | [[package]] |
|
| 1250 | + | name = "r-efi" |
|
| 1251 | + | version = "6.0.0" |
|
| 1252 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1253 | + | checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" |
|
| 1254 | + | ||
| 1255 | + | [[package]] |
|
| 1256 | + | name = "rand" |
|
| 1257 | + | version = "0.10.2" |
|
| 1258 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1259 | + | checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" |
|
| 1260 | + | dependencies = [ |
|
| 1261 | + | "chacha20", |
|
| 1262 | + | "getrandom 0.4.3", |
|
| 1263 | + | "rand_core", |
|
| 1264 | + | ] |
|
| 1265 | + | ||
| 1266 | + | [[package]] |
|
| 1267 | + | name = "rand_core" |
|
| 1268 | + | version = "0.10.1" |
|
| 1269 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1270 | + | checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" |
|
| 1271 | + | ||
| 1272 | + | [[package]] |
|
| 1273 | + | name = "rand_pcg" |
|
| 1274 | + | version = "0.10.2" |
|
| 1275 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1276 | + | checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" |
|
| 1277 | + | dependencies = [ |
|
| 1278 | + | "rand_core", |
|
| 1279 | + | ] |
|
| 1280 | + | ||
| 1281 | + | [[package]] |
|
| 1282 | + | name = "ratatui" |
|
| 1283 | + | version = "0.29.0" |
|
| 1284 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1285 | + | checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" |
|
| 1286 | + | dependencies = [ |
|
| 1287 | + | "bitflags", |
|
| 1288 | + | "cassowary", |
|
| 1289 | + | "compact_str", |
|
| 1290 | + | "crossterm", |
|
| 1291 | + | "indoc", |
|
| 1292 | + | "instability", |
|
| 1293 | + | "itertools", |
|
| 1294 | + | "lru", |
|
| 1295 | + | "paste", |
|
| 1296 | + | "strum", |
|
| 1297 | + | "unicode-segmentation", |
|
| 1298 | + | "unicode-truncate", |
|
| 1299 | + | "unicode-width 0.2.0", |
|
| 1300 | + | ] |
|
| 1301 | + | ||
| 1302 | + | [[package]] |
|
| 1303 | + | name = "redox_syscall" |
|
| 1304 | + | version = "0.5.18" |
|
| 1305 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1306 | + | checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" |
|
| 1307 | + | dependencies = [ |
|
| 1308 | + | "bitflags", |
|
| 1309 | + | ] |
|
| 1310 | + | ||
| 1311 | + | [[package]] |
|
| 1312 | + | name = "redox_users" |
|
| 1313 | + | version = "0.5.2" |
|
| 1314 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1315 | + | checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" |
|
| 1316 | + | dependencies = [ |
|
| 1317 | + | "getrandom 0.2.17", |
|
| 1318 | + | "libredox", |
|
| 1319 | + | "thiserror", |
|
| 1320 | + | ] |
|
| 1321 | + | ||
| 1322 | + | [[package]] |
|
| 1323 | + | name = "regex" |
|
| 1324 | + | version = "1.13.1" |
|
| 1325 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1326 | + | checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" |
|
| 1327 | + | dependencies = [ |
|
| 1328 | + | "aho-corasick", |
|
| 1329 | + | "memchr", |
|
| 1330 | + | "regex-automata", |
|
| 1331 | + | "regex-syntax", |
|
| 1332 | + | ] |
|
| 1333 | + | ||
| 1334 | + | [[package]] |
|
| 1335 | + | name = "regex-automata" |
|
| 1336 | + | version = "0.4.18" |
|
| 1337 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1338 | + | checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" |
|
| 1339 | + | dependencies = [ |
|
| 1340 | + | "aho-corasick", |
|
| 1341 | + | "memchr", |
|
| 1342 | + | "regex-syntax", |
|
| 1343 | + | ] |
|
| 1344 | + | ||
| 1345 | + | [[package]] |
|
| 1346 | + | name = "regex-syntax" |
|
| 1347 | + | version = "0.8.11" |
|
| 1348 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1349 | + | checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" |
|
| 1350 | + | ||
| 1351 | + | [[package]] |
|
| 1352 | + | name = "reqwest" |
|
| 1353 | + | version = "0.13.4" |
|
| 1354 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1355 | + | checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" |
|
| 1356 | + | dependencies = [ |
|
| 1357 | + | "base64", |
|
| 1358 | + | "bytes", |
|
| 1359 | + | "futures-core", |
|
| 1360 | + | "http", |
|
| 1361 | + | "http-body", |
|
| 1362 | + | "http-body-util", |
|
| 1363 | + | "hyper", |
|
| 1364 | + | "hyper-rustls", |
|
| 1365 | + | "hyper-util", |
|
| 1366 | + | "js-sys", |
|
| 1367 | + | "log", |
|
| 1368 | + | "percent-encoding", |
|
| 1369 | + | "pin-project-lite", |
|
| 1370 | + | "quinn", |
|
| 1371 | + | "rustls", |
|
| 1372 | + | "rustls-pki-types", |
|
| 1373 | + | "rustls-platform-verifier", |
|
| 1374 | + | "serde", |
|
| 1375 | + | "serde_json", |
|
| 1376 | + | "serde_urlencoded", |
|
| 1377 | + | "sync_wrapper", |
|
| 1378 | + | "tokio", |
|
| 1379 | + | "tokio-rustls", |
|
| 1380 | + | "tower", |
|
| 1381 | + | "tower-http", |
|
| 1382 | + | "tower-service", |
|
| 1383 | + | "url", |
|
| 1384 | + | "wasm-bindgen", |
|
| 1385 | + | "wasm-bindgen-futures", |
|
| 1386 | + | "web-sys", |
|
| 1387 | + | ] |
|
| 1388 | + | ||
| 1389 | + | [[package]] |
|
| 1390 | + | name = "ring" |
|
| 1391 | + | version = "0.17.14" |
|
| 1392 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1393 | + | checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" |
|
| 1394 | + | dependencies = [ |
|
| 1395 | + | "cc", |
|
| 1396 | + | "cfg-if", |
|
| 1397 | + | "getrandom 0.2.17", |
|
| 1398 | + | "libc", |
|
| 1399 | + | "untrusted", |
|
| 1400 | + | "windows-sys 0.52.0", |
|
| 1401 | + | ] |
|
| 1402 | + | ||
| 1403 | + | [[package]] |
|
| 1404 | + | name = "rustc-hash" |
|
| 1405 | + | version = "2.1.3" |
|
| 1406 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1407 | + | checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" |
|
| 1408 | + | ||
| 1409 | + | [[package]] |
|
| 1410 | + | name = "rustc_version" |
|
| 1411 | + | version = "0.4.1" |
|
| 1412 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1413 | + | checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" |
|
| 1414 | + | dependencies = [ |
|
| 1415 | + | "semver", |
|
| 1416 | + | ] |
|
| 1417 | + | ||
| 1418 | + | [[package]] |
|
| 1419 | + | name = "rustix" |
|
| 1420 | + | version = "0.38.44" |
|
| 1421 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1422 | + | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" |
|
| 1423 | + | dependencies = [ |
|
| 1424 | + | "bitflags", |
|
| 1425 | + | "errno", |
|
| 1426 | + | "libc", |
|
| 1427 | + | "linux-raw-sys 0.4.15", |
|
| 1428 | + | "windows-sys 0.59.0", |
|
| 1429 | + | ] |
|
| 1430 | + | ||
| 1431 | + | [[package]] |
|
| 1432 | + | name = "rustix" |
|
| 1433 | + | version = "1.1.4" |
|
| 1434 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1435 | + | checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" |
|
| 1436 | + | dependencies = [ |
|
| 1437 | + | "bitflags", |
|
| 1438 | + | "errno", |
|
| 1439 | + | "libc", |
|
| 1440 | + | "linux-raw-sys 0.12.1", |
|
| 1441 | + | "windows-sys 0.61.2", |
|
| 1442 | + | ] |
|
| 1443 | + | ||
| 1444 | + | [[package]] |
|
| 1445 | + | name = "rustls" |
|
| 1446 | + | version = "0.23.43" |
|
| 1447 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1448 | + | checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" |
|
| 1449 | + | dependencies = [ |
|
| 1450 | + | "aws-lc-rs", |
|
| 1451 | + | "once_cell", |
|
| 1452 | + | "rustls-pki-types", |
|
| 1453 | + | "rustls-webpki", |
|
| 1454 | + | "subtle", |
|
| 1455 | + | "zeroize", |
|
| 1456 | + | ] |
|
| 1457 | + | ||
| 1458 | + | [[package]] |
|
| 1459 | + | name = "rustls-native-certs" |
|
| 1460 | + | version = "0.8.4" |
|
| 1461 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1462 | + | checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" |
|
| 1463 | + | dependencies = [ |
|
| 1464 | + | "openssl-probe", |
|
| 1465 | + | "rustls-pki-types", |
|
| 1466 | + | "schannel", |
|
| 1467 | + | "security-framework", |
|
| 1468 | + | ] |
|
| 1469 | + | ||
| 1470 | + | [[package]] |
|
| 1471 | + | name = "rustls-pki-types" |
|
| 1472 | + | version = "1.15.1" |
|
| 1473 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1474 | + | checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" |
|
| 1475 | + | dependencies = [ |
|
| 1476 | + | "web-time", |
|
| 1477 | + | "zeroize", |
|
| 1478 | + | ] |
|
| 1479 | + | ||
| 1480 | + | [[package]] |
|
| 1481 | + | name = "rustls-platform-verifier" |
|
| 1482 | + | version = "0.7.0" |
|
| 1483 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1484 | + | checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" |
|
| 1485 | + | dependencies = [ |
|
| 1486 | + | "core-foundation", |
|
| 1487 | + | "core-foundation-sys", |
|
| 1488 | + | "jni", |
|
| 1489 | + | "log", |
|
| 1490 | + | "once_cell", |
|
| 1491 | + | "rustls", |
|
| 1492 | + | "rustls-native-certs", |
|
| 1493 | + | "rustls-platform-verifier-android", |
|
| 1494 | + | "rustls-webpki", |
|
| 1495 | + | "security-framework", |
|
| 1496 | + | "security-framework-sys", |
|
| 1497 | + | "webpki-root-certs", |
|
| 1498 | + | "windows-sys 0.61.2", |
|
| 1499 | + | ] |
|
| 1500 | + | ||
| 1501 | + | [[package]] |
|
| 1502 | + | name = "rustls-platform-verifier-android" |
|
| 1503 | + | version = "0.1.1" |
|
| 1504 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1505 | + | checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" |
|
| 1506 | + | ||
| 1507 | + | [[package]] |
|
| 1508 | + | name = "rustls-webpki" |
|
| 1509 | + | version = "0.103.13" |
|
| 1510 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1511 | + | checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" |
|
| 1512 | + | dependencies = [ |
|
| 1513 | + | "aws-lc-rs", |
|
| 1514 | + | "ring", |
|
| 1515 | + | "rustls-pki-types", |
|
| 1516 | + | "untrusted", |
|
| 1517 | + | ] |
|
| 1518 | + | ||
| 1519 | + | [[package]] |
|
| 1520 | + | name = "rustversion" |
|
| 1521 | + | version = "1.0.23" |
|
| 1522 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1523 | + | checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" |
|
| 1524 | + | ||
| 1525 | + | [[package]] |
|
| 1526 | + | name = "ryu" |
|
| 1527 | + | version = "1.0.23" |
|
| 1528 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1529 | + | checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" |
|
| 1530 | + | ||
| 1531 | + | [[package]] |
|
| 1532 | + | name = "same-file" |
|
| 1533 | + | version = "1.0.6" |
|
| 1534 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1535 | + | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" |
|
| 1536 | + | dependencies = [ |
|
| 1537 | + | "winapi-util", |
|
| 1538 | + | ] |
|
| 1539 | + | ||
| 1540 | + | [[package]] |
|
| 1541 | + | name = "schannel" |
|
| 1542 | + | version = "0.1.29" |
|
| 1543 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1544 | + | checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" |
|
| 1545 | + | dependencies = [ |
|
| 1546 | + | "windows-sys 0.61.2", |
|
| 1547 | + | ] |
|
| 1548 | + | ||
| 1549 | + | [[package]] |
|
| 1550 | + | name = "scopeguard" |
|
| 1551 | + | version = "1.2.0" |
|
| 1552 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1553 | + | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" |
|
| 1554 | + | ||
| 1555 | + | [[package]] |
|
| 1556 | + | name = "security-framework" |
|
| 1557 | + | version = "3.7.0" |
|
| 1558 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1559 | + | checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" |
|
| 1560 | + | dependencies = [ |
|
| 1561 | + | "bitflags", |
|
| 1562 | + | "core-foundation", |
|
| 1563 | + | "core-foundation-sys", |
|
| 1564 | + | "libc", |
|
| 1565 | + | "security-framework-sys", |
|
| 1566 | + | ] |
|
| 1567 | + | ||
| 1568 | + | [[package]] |
|
| 1569 | + | name = "security-framework-sys" |
|
| 1570 | + | version = "2.17.0" |
|
| 1571 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1572 | + | checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" |
|
| 1573 | + | dependencies = [ |
|
| 1574 | + | "core-foundation-sys", |
|
| 1575 | + | "libc", |
|
| 1576 | + | ] |
|
| 1577 | + | ||
| 1578 | + | [[package]] |
|
| 1579 | + | name = "semver" |
|
| 1580 | + | version = "1.0.28" |
|
| 1581 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1582 | + | checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" |
|
| 1583 | + | ||
| 1584 | + | [[package]] |
|
| 1585 | + | name = "serde" |
|
| 1586 | + | version = "1.0.229" |
|
| 1587 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1588 | + | checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" |
|
| 1589 | + | dependencies = [ |
|
| 1590 | + | "serde_core", |
|
| 1591 | + | "serde_derive", |
|
| 1592 | + | ] |
|
| 1593 | + | ||
| 1594 | + | [[package]] |
|
| 1595 | + | name = "serde_core" |
|
| 1596 | + | version = "1.0.229" |
|
| 1597 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1598 | + | checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" |
|
| 1599 | + | dependencies = [ |
|
| 1600 | + | "serde_derive", |
|
| 1601 | + | ] |
|
| 1602 | + | ||
| 1603 | + | [[package]] |
|
| 1604 | + | name = "serde_derive" |
|
| 1605 | + | version = "1.0.229" |
|
| 1606 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1607 | + | checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" |
|
| 1608 | + | dependencies = [ |
|
| 1609 | + | "proc-macro2", |
|
| 1610 | + | "quote", |
|
| 1611 | + | "syn 3.0.3", |
|
| 1612 | + | ] |
|
| 1613 | + | ||
| 1614 | + | [[package]] |
|
| 1615 | + | name = "serde_json" |
|
| 1616 | + | version = "1.0.151" |
|
| 1617 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1618 | + | checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" |
|
| 1619 | + | dependencies = [ |
|
| 1620 | + | "indexmap", |
|
| 1621 | + | "itoa", |
|
| 1622 | + | "memchr", |
|
| 1623 | + | "serde", |
|
| 1624 | + | "serde_core", |
|
| 1625 | + | "zmij", |
|
| 1626 | + | ] |
|
| 1627 | + | ||
| 1628 | + | [[package]] |
|
| 1629 | + | name = "serde_urlencoded" |
|
| 1630 | + | version = "0.7.1" |
|
| 1631 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1632 | + | checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" |
|
| 1633 | + | dependencies = [ |
|
| 1634 | + | "form_urlencoded", |
|
| 1635 | + | "itoa", |
|
| 1636 | + | "ryu", |
|
| 1637 | + | "serde", |
|
| 1638 | + | ] |
|
| 1639 | + | ||
| 1640 | + | [[package]] |
|
| 1641 | + | name = "serde_yaml" |
|
| 1642 | + | version = "0.9.34+deprecated" |
|
| 1643 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1644 | + | checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" |
|
| 1645 | + | dependencies = [ |
|
| 1646 | + | "indexmap", |
|
| 1647 | + | "itoa", |
|
| 1648 | + | "ryu", |
|
| 1649 | + | "serde", |
|
| 1650 | + | "unsafe-libyaml", |
|
| 1651 | + | ] |
|
| 1652 | + | ||
| 1653 | + | [[package]] |
|
| 1654 | + | name = "shlex" |
|
| 1655 | + | version = "2.0.1" |
|
| 1656 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1657 | + | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" |
|
| 1658 | + | ||
| 1659 | + | [[package]] |
|
| 1660 | + | name = "signal-hook" |
|
| 1661 | + | version = "0.3.18" |
|
| 1662 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1663 | + | checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" |
|
| 1664 | + | dependencies = [ |
|
| 1665 | + | "libc", |
|
| 1666 | + | "signal-hook-registry", |
|
| 1667 | + | ] |
|
| 1668 | + | ||
| 1669 | + | [[package]] |
|
| 1670 | + | name = "signal-hook-mio" |
|
| 1671 | + | version = "0.2.5" |
|
| 1672 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1673 | + | checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" |
|
| 1674 | + | dependencies = [ |
|
| 1675 | + | "libc", |
|
| 1676 | + | "mio", |
|
| 1677 | + | "signal-hook", |
|
| 1678 | + | ] |
|
| 1679 | + | ||
| 1680 | + | [[package]] |
|
| 1681 | + | name = "signal-hook-registry" |
|
| 1682 | + | version = "1.4.8" |
|
| 1683 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1684 | + | checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" |
|
| 1685 | + | dependencies = [ |
|
| 1686 | + | "errno", |
|
| 1687 | + | "libc", |
|
| 1688 | + | ] |
|
| 1689 | + | ||
| 1690 | + | [[package]] |
|
| 1691 | + | name = "simd_cesu8" |
|
| 1692 | + | version = "1.2.0" |
|
| 1693 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1694 | + | checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" |
|
| 1695 | + | dependencies = [ |
|
| 1696 | + | "rustc_version", |
|
| 1697 | + | "simdutf8", |
|
| 1698 | + | ] |
|
| 1699 | + | ||
| 1700 | + | [[package]] |
|
| 1701 | + | name = "simdutf8" |
|
| 1702 | + | version = "0.1.5" |
|
| 1703 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1704 | + | checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" |
|
| 1705 | + | ||
| 1706 | + | [[package]] |
|
| 1707 | + | name = "slab" |
|
| 1708 | + | version = "0.4.12" |
|
| 1709 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1710 | + | checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" |
|
| 1711 | + | ||
| 1712 | + | [[package]] |
|
| 1713 | + | name = "smallvec" |
|
| 1714 | + | version = "1.15.2" |
|
| 1715 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1716 | + | checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" |
|
| 1717 | + | ||
| 1718 | + | [[package]] |
|
| 1719 | + | name = "socket2" |
|
| 1720 | + | version = "0.6.5" |
|
| 1721 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1722 | + | checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" |
|
| 1723 | + | dependencies = [ |
|
| 1724 | + | "libc", |
|
| 1725 | + | "windows-sys 0.61.2", |
|
| 1726 | + | ] |
|
| 1727 | + | ||
| 1728 | + | [[package]] |
|
| 1729 | + | name = "stable_deref_trait" |
|
| 1730 | + | version = "1.2.1" |
|
| 1731 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1732 | + | checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" |
|
| 1733 | + | ||
| 1734 | + | [[package]] |
|
| 1735 | + | name = "static_assertions" |
|
| 1736 | + | version = "1.1.0" |
|
| 1737 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1738 | + | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" |
|
| 1739 | + | ||
| 1740 | + | [[package]] |
|
| 1741 | + | name = "strsim" |
|
| 1742 | + | version = "0.11.1" |
|
| 1743 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1744 | + | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" |
|
| 1745 | + | ||
| 1746 | + | [[package]] |
|
| 1747 | + | name = "strum" |
|
| 1748 | + | version = "0.26.3" |
|
| 1749 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1750 | + | checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" |
|
| 1751 | + | dependencies = [ |
|
| 1752 | + | "strum_macros", |
|
| 1753 | + | ] |
|
| 1754 | + | ||
| 1755 | + | [[package]] |
|
| 1756 | + | name = "strum_macros" |
|
| 1757 | + | version = "0.26.4" |
|
| 1758 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1759 | + | checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" |
|
| 1760 | + | dependencies = [ |
|
| 1761 | + | "heck", |
|
| 1762 | + | "proc-macro2", |
|
| 1763 | + | "quote", |
|
| 1764 | + | "rustversion", |
|
| 1765 | + | "syn 2.0.119", |
|
| 1766 | + | ] |
|
| 1767 | + | ||
| 1768 | + | [[package]] |
|
| 1769 | + | name = "subtle" |
|
| 1770 | + | version = "2.6.1" |
|
| 1771 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1772 | + | checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" |
|
| 1773 | + | ||
| 1774 | + | [[package]] |
|
| 1775 | + | name = "syn" |
|
| 1776 | + | version = "2.0.119" |
|
| 1777 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1778 | + | checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" |
|
| 1779 | + | dependencies = [ |
|
| 1780 | + | "proc-macro2", |
|
| 1781 | + | "quote", |
|
| 1782 | + | "unicode-ident", |
|
| 1783 | + | ] |
|
| 1784 | + | ||
| 1785 | + | [[package]] |
|
| 1786 | + | name = "syn" |
|
| 1787 | + | version = "3.0.3" |
|
| 1788 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1789 | + | checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" |
|
| 1790 | + | dependencies = [ |
|
| 1791 | + | "proc-macro2", |
|
| 1792 | + | "quote", |
|
| 1793 | + | "unicode-ident", |
|
| 1794 | + | ] |
|
| 1795 | + | ||
| 1796 | + | [[package]] |
|
| 1797 | + | name = "sync_wrapper" |
|
| 1798 | + | version = "1.0.2" |
|
| 1799 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1800 | + | checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" |
|
| 1801 | + | dependencies = [ |
|
| 1802 | + | "futures-core", |
|
| 1803 | + | ] |
|
| 1804 | + | ||
| 1805 | + | [[package]] |
|
| 1806 | + | name = "synstructure" |
|
| 1807 | + | version = "0.13.2" |
|
| 1808 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1809 | + | checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" |
|
| 1810 | + | dependencies = [ |
|
| 1811 | + | "proc-macro2", |
|
| 1812 | + | "quote", |
|
| 1813 | + | "syn 2.0.119", |
|
| 1814 | + | ] |
|
| 1815 | + | ||
| 1816 | + | [[package]] |
|
| 1817 | + | name = "tempfile" |
|
| 1818 | + | version = "3.27.0" |
|
| 1819 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1820 | + | checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" |
|
| 1821 | + | dependencies = [ |
|
| 1822 | + | "fastrand", |
|
| 1823 | + | "getrandom 0.4.3", |
|
| 1824 | + | "once_cell", |
|
| 1825 | + | "rustix 1.1.4", |
|
| 1826 | + | "windows-sys 0.61.2", |
|
| 1827 | + | ] |
|
| 1828 | + | ||
| 1829 | + | [[package]] |
|
| 1830 | + | name = "thiserror" |
|
| 1831 | + | version = "2.0.19" |
|
| 1832 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1833 | + | checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" |
|
| 1834 | + | dependencies = [ |
|
| 1835 | + | "thiserror-impl", |
|
| 1836 | + | ] |
|
| 1837 | + | ||
| 1838 | + | [[package]] |
|
| 1839 | + | name = "thiserror-impl" |
|
| 1840 | + | version = "2.0.19" |
|
| 1841 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1842 | + | checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" |
|
| 1843 | + | dependencies = [ |
|
| 1844 | + | "proc-macro2", |
|
| 1845 | + | "quote", |
|
| 1846 | + | "syn 3.0.3", |
|
| 1847 | + | ] |
|
| 1848 | + | ||
| 1849 | + | [[package]] |
|
| 1850 | + | name = "tinystr" |
|
| 1851 | + | version = "0.8.3" |
|
| 1852 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1853 | + | checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" |
|
| 1854 | + | dependencies = [ |
|
| 1855 | + | "displaydoc", |
|
| 1856 | + | "zerovec", |
|
| 1857 | + | ] |
|
| 1858 | + | ||
| 1859 | + | [[package]] |
|
| 1860 | + | name = "tinyvec" |
|
| 1861 | + | version = "1.12.0" |
|
| 1862 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1863 | + | checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" |
|
| 1864 | + | dependencies = [ |
|
| 1865 | + | "tinyvec_macros", |
|
| 1866 | + | ] |
|
| 1867 | + | ||
| 1868 | + | [[package]] |
|
| 1869 | + | name = "tinyvec_macros" |
|
| 1870 | + | version = "0.1.1" |
|
| 1871 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1872 | + | checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" |
|
| 1873 | + | ||
| 1874 | + | [[package]] |
|
| 1875 | + | name = "tokio" |
|
| 1876 | + | version = "1.53.1" |
|
| 1877 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1878 | + | checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" |
|
| 1879 | + | dependencies = [ |
|
| 1880 | + | "bytes", |
|
| 1881 | + | "libc", |
|
| 1882 | + | "mio", |
|
| 1883 | + | "pin-project-lite", |
|
| 1884 | + | "socket2", |
|
| 1885 | + | "tokio-macros", |
|
| 1886 | + | "windows-sys 0.61.2", |
|
| 1887 | + | ] |
|
| 1888 | + | ||
| 1889 | + | [[package]] |
|
| 1890 | + | name = "tokio-macros" |
|
| 1891 | + | version = "2.7.2" |
|
| 1892 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1893 | + | checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" |
|
| 1894 | + | dependencies = [ |
|
| 1895 | + | "proc-macro2", |
|
| 1896 | + | "quote", |
|
| 1897 | + | "syn 3.0.3", |
|
| 1898 | + | ] |
|
| 1899 | + | ||
| 1900 | + | [[package]] |
|
| 1901 | + | name = "tokio-rustls" |
|
| 1902 | + | version = "0.26.4" |
|
| 1903 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1904 | + | checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" |
|
| 1905 | + | dependencies = [ |
|
| 1906 | + | "rustls", |
|
| 1907 | + | "tokio", |
|
| 1908 | + | ] |
|
| 1909 | + | ||
| 1910 | + | [[package]] |
|
| 1911 | + | name = "tokio-util" |
|
| 1912 | + | version = "0.7.19" |
|
| 1913 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1914 | + | checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" |
|
| 1915 | + | dependencies = [ |
|
| 1916 | + | "bytes", |
|
| 1917 | + | "futures-core", |
|
| 1918 | + | "futures-sink", |
|
| 1919 | + | "libc", |
|
| 1920 | + | "pin-project-lite", |
|
| 1921 | + | "tokio", |
|
| 1922 | + | ] |
|
| 1923 | + | ||
| 1924 | + | [[package]] |
|
| 1925 | + | name = "tower" |
|
| 1926 | + | version = "0.5.3" |
|
| 1927 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1928 | + | checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" |
|
| 1929 | + | dependencies = [ |
|
| 1930 | + | "futures-core", |
|
| 1931 | + | "futures-util", |
|
| 1932 | + | "pin-project-lite", |
|
| 1933 | + | "sync_wrapper", |
|
| 1934 | + | "tokio", |
|
| 1935 | + | "tower-layer", |
|
| 1936 | + | "tower-service", |
|
| 1937 | + | ] |
|
| 1938 | + | ||
| 1939 | + | [[package]] |
|
| 1940 | + | name = "tower-http" |
|
| 1941 | + | version = "0.6.11" |
|
| 1942 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1943 | + | checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" |
|
| 1944 | + | dependencies = [ |
|
| 1945 | + | "bitflags", |
|
| 1946 | + | "bytes", |
|
| 1947 | + | "futures-util", |
|
| 1948 | + | "http", |
|
| 1949 | + | "http-body", |
|
| 1950 | + | "pin-project-lite", |
|
| 1951 | + | "tower", |
|
| 1952 | + | "tower-layer", |
|
| 1953 | + | "tower-service", |
|
| 1954 | + | "url", |
|
| 1955 | + | ] |
|
| 1956 | + | ||
| 1957 | + | [[package]] |
|
| 1958 | + | name = "tower-layer" |
|
| 1959 | + | version = "0.3.3" |
|
| 1960 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1961 | + | checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" |
|
| 1962 | + | ||
| 1963 | + | [[package]] |
|
| 1964 | + | name = "tower-service" |
|
| 1965 | + | version = "0.3.3" |
|
| 1966 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1967 | + | checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" |
|
| 1968 | + | ||
| 1969 | + | [[package]] |
|
| 1970 | + | name = "tracing" |
|
| 1971 | + | version = "0.1.44" |
|
| 1972 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1973 | + | checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" |
|
| 1974 | + | dependencies = [ |
|
| 1975 | + | "pin-project-lite", |
|
| 1976 | + | "tracing-core", |
|
| 1977 | + | ] |
|
| 1978 | + | ||
| 1979 | + | [[package]] |
|
| 1980 | + | name = "tracing-core" |
|
| 1981 | + | version = "0.1.36" |
|
| 1982 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1983 | + | checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" |
|
| 1984 | + | dependencies = [ |
|
| 1985 | + | "once_cell", |
|
| 1986 | + | ] |
|
| 1987 | + | ||
| 1988 | + | [[package]] |
|
| 1989 | + | name = "try-lock" |
|
| 1990 | + | version = "0.2.5" |
|
| 1991 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1992 | + | checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" |
|
| 1993 | + | ||
| 1994 | + | [[package]] |
|
| 1995 | + | name = "tui-textarea" |
|
| 1996 | + | version = "0.7.0" |
|
| 1997 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1998 | + | checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" |
|
| 1999 | + | dependencies = [ |
|
| 2000 | + | "crossterm", |
|
| 2001 | + | "ratatui", |
|
| 2002 | + | "unicode-width 0.2.0", |
|
| 2003 | + | ] |
|
| 2004 | + | ||
| 2005 | + | [[package]] |
|
| 2006 | + | name = "unicode-ident" |
|
| 2007 | + | version = "1.0.24" |
|
| 2008 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2009 | + | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" |
|
| 2010 | + | ||
| 2011 | + | [[package]] |
|
| 2012 | + | name = "unicode-segmentation" |
|
| 2013 | + | version = "1.13.3" |
|
| 2014 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2015 | + | checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" |
|
| 2016 | + | ||
| 2017 | + | [[package]] |
|
| 2018 | + | name = "unicode-truncate" |
|
| 2019 | + | version = "1.1.0" |
|
| 2020 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2021 | + | checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" |
|
| 2022 | + | dependencies = [ |
|
| 2023 | + | "itertools", |
|
| 2024 | + | "unicode-segmentation", |
|
| 2025 | + | "unicode-width 0.1.14", |
|
| 2026 | + | ] |
|
| 2027 | + | ||
| 2028 | + | [[package]] |
|
| 2029 | + | name = "unicode-width" |
|
| 2030 | + | version = "0.1.14" |
|
| 2031 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2032 | + | checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" |
|
| 2033 | + | ||
| 2034 | + | [[package]] |
|
| 2035 | + | name = "unicode-width" |
|
| 2036 | + | version = "0.2.0" |
|
| 2037 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2038 | + | checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" |
|
| 2039 | + | ||
| 2040 | + | [[package]] |
|
| 2041 | + | name = "unsafe-libyaml" |
|
| 2042 | + | version = "0.2.11" |
|
| 2043 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2044 | + | checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" |
|
| 2045 | + | ||
| 2046 | + | [[package]] |
|
| 2047 | + | name = "untrusted" |
|
| 2048 | + | version = "0.9.0" |
|
| 2049 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2050 | + | checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" |
|
| 2051 | + | ||
| 2052 | + | [[package]] |
|
| 2053 | + | name = "url" |
|
| 2054 | + | version = "2.5.8" |
|
| 2055 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2056 | + | checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" |
|
| 2057 | + | dependencies = [ |
|
| 2058 | + | "form_urlencoded", |
|
| 2059 | + | "idna", |
|
| 2060 | + | "percent-encoding", |
|
| 2061 | + | "serde", |
|
| 2062 | + | ] |
|
| 2063 | + | ||
| 2064 | + | [[package]] |
|
| 2065 | + | name = "utf8_iter" |
|
| 2066 | + | version = "1.0.4" |
|
| 2067 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2068 | + | checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" |
|
| 2069 | + | ||
| 2070 | + | [[package]] |
|
| 2071 | + | name = "utf8parse" |
|
| 2072 | + | version = "0.2.2" |
|
| 2073 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2074 | + | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" |
|
| 2075 | + | ||
| 2076 | + | [[package]] |
|
| 2077 | + | name = "uuid" |
|
| 2078 | + | version = "1.24.0" |
|
| 2079 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2080 | + | checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" |
|
| 2081 | + | dependencies = [ |
|
| 2082 | + | "getrandom 0.4.3", |
|
| 2083 | + | "js-sys", |
|
| 2084 | + | "serde_core", |
|
| 2085 | + | "wasm-bindgen", |
|
| 2086 | + | ] |
|
| 2087 | + | ||
| 2088 | + | [[package]] |
|
| 2089 | + | name = "walkdir" |
|
| 2090 | + | version = "2.5.0" |
|
| 2091 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2092 | + | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" |
|
| 2093 | + | dependencies = [ |
|
| 2094 | + | "same-file", |
|
| 2095 | + | "winapi-util", |
|
| 2096 | + | ] |
|
| 2097 | + | ||
| 2098 | + | [[package]] |
|
| 2099 | + | name = "want" |
|
| 2100 | + | version = "0.3.1" |
|
| 2101 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2102 | + | checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" |
|
| 2103 | + | dependencies = [ |
|
| 2104 | + | "try-lock", |
|
| 2105 | + | ] |
|
| 2106 | + | ||
| 2107 | + | [[package]] |
|
| 2108 | + | name = "wasi" |
|
| 2109 | + | version = "0.11.1+wasi-snapshot-preview1" |
|
| 2110 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2111 | + | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" |
|
| 2112 | + | ||
| 2113 | + | [[package]] |
|
| 2114 | + | name = "wasm-bindgen" |
|
| 2115 | + | version = "0.2.126" |
|
| 2116 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2117 | + | checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" |
|
| 2118 | + | dependencies = [ |
|
| 2119 | + | "cfg-if", |
|
| 2120 | + | "once_cell", |
|
| 2121 | + | "rustversion", |
|
| 2122 | + | "wasm-bindgen-macro", |
|
| 2123 | + | "wasm-bindgen-shared", |
|
| 2124 | + | ] |
|
| 2125 | + | ||
| 2126 | + | [[package]] |
|
| 2127 | + | name = "wasm-bindgen-futures" |
|
| 2128 | + | version = "0.4.76" |
|
| 2129 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2130 | + | checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" |
|
| 2131 | + | dependencies = [ |
|
| 2132 | + | "js-sys", |
|
| 2133 | + | "wasm-bindgen", |
|
| 2134 | + | ] |
|
| 2135 | + | ||
| 2136 | + | [[package]] |
|
| 2137 | + | name = "wasm-bindgen-macro" |
|
| 2138 | + | version = "0.2.126" |
|
| 2139 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2140 | + | checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" |
|
| 2141 | + | dependencies = [ |
|
| 2142 | + | "quote", |
|
| 2143 | + | "wasm-bindgen-macro-support", |
|
| 2144 | + | ] |
|
| 2145 | + | ||
| 2146 | + | [[package]] |
|
| 2147 | + | name = "wasm-bindgen-macro-support" |
|
| 2148 | + | version = "0.2.126" |
|
| 2149 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2150 | + | checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" |
|
| 2151 | + | dependencies = [ |
|
| 2152 | + | "bumpalo", |
|
| 2153 | + | "proc-macro2", |
|
| 2154 | + | "quote", |
|
| 2155 | + | "syn 2.0.119", |
|
| 2156 | + | "wasm-bindgen-shared", |
|
| 2157 | + | ] |
|
| 2158 | + | ||
| 2159 | + | [[package]] |
|
| 2160 | + | name = "wasm-bindgen-shared" |
|
| 2161 | + | version = "0.2.126" |
|
| 2162 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2163 | + | checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" |
|
| 2164 | + | dependencies = [ |
|
| 2165 | + | "unicode-ident", |
|
| 2166 | + | ] |
|
| 2167 | + | ||
| 2168 | + | [[package]] |
|
| 2169 | + | name = "web-sys" |
|
| 2170 | + | version = "0.3.103" |
|
| 2171 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2172 | + | checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" |
|
| 2173 | + | dependencies = [ |
|
| 2174 | + | "js-sys", |
|
| 2175 | + | "wasm-bindgen", |
|
| 2176 | + | ] |
|
| 2177 | + | ||
| 2178 | + | [[package]] |
|
| 2179 | + | name = "web-time" |
|
| 2180 | + | version = "1.1.0" |
|
| 2181 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2182 | + | checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" |
|
| 2183 | + | dependencies = [ |
|
| 2184 | + | "js-sys", |
|
| 2185 | + | "wasm-bindgen", |
|
| 2186 | + | ] |
|
| 2187 | + | ||
| 2188 | + | [[package]] |
|
| 2189 | + | name = "webpki-root-certs" |
|
| 2190 | + | version = "1.0.9" |
|
| 2191 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2192 | + | checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" |
|
| 2193 | + | dependencies = [ |
|
| 2194 | + | "rustls-pki-types", |
|
| 2195 | + | ] |
|
| 2196 | + | ||
| 2197 | + | [[package]] |
|
| 2198 | + | name = "winapi" |
|
| 2199 | + | version = "0.3.9" |
|
| 2200 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2201 | + | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" |
|
| 2202 | + | dependencies = [ |
|
| 2203 | + | "winapi-i686-pc-windows-gnu", |
|
| 2204 | + | "winapi-x86_64-pc-windows-gnu", |
|
| 2205 | + | ] |
|
| 2206 | + | ||
| 2207 | + | [[package]] |
|
| 2208 | + | name = "winapi-i686-pc-windows-gnu" |
|
| 2209 | + | version = "0.4.0" |
|
| 2210 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2211 | + | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" |
|
| 2212 | + | ||
| 2213 | + | [[package]] |
|
| 2214 | + | name = "winapi-util" |
|
| 2215 | + | version = "0.1.11" |
|
| 2216 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2217 | + | checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" |
|
| 2218 | + | dependencies = [ |
|
| 2219 | + | "windows-sys 0.61.2", |
|
| 2220 | + | ] |
|
| 2221 | + | ||
| 2222 | + | [[package]] |
|
| 2223 | + | name = "winapi-x86_64-pc-windows-gnu" |
|
| 2224 | + | version = "0.4.0" |
|
| 2225 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2226 | + | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" |
|
| 2227 | + | ||
| 2228 | + | [[package]] |
|
| 2229 | + | name = "windows-link" |
|
| 2230 | + | version = "0.2.1" |
|
| 2231 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2232 | + | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" |
|
| 2233 | + | ||
| 2234 | + | [[package]] |
|
| 2235 | + | name = "windows-sys" |
|
| 2236 | + | version = "0.52.0" |
|
| 2237 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2238 | + | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" |
|
| 2239 | + | dependencies = [ |
|
| 2240 | + | "windows-targets", |
|
| 2241 | + | ] |
|
| 2242 | + | ||
| 2243 | + | [[package]] |
|
| 2244 | + | name = "windows-sys" |
|
| 2245 | + | version = "0.59.0" |
|
| 2246 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2247 | + | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" |
|
| 2248 | + | dependencies = [ |
|
| 2249 | + | "windows-targets", |
|
| 2250 | + | ] |
|
| 2251 | + | ||
| 2252 | + | [[package]] |
|
| 2253 | + | name = "windows-sys" |
|
| 2254 | + | version = "0.61.2" |
|
| 2255 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2256 | + | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" |
|
| 2257 | + | dependencies = [ |
|
| 2258 | + | "windows-link", |
|
| 2259 | + | ] |
|
| 2260 | + | ||
| 2261 | + | [[package]] |
|
| 2262 | + | name = "windows-targets" |
|
| 2263 | + | version = "0.52.6" |
|
| 2264 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2265 | + | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" |
|
| 2266 | + | dependencies = [ |
|
| 2267 | + | "windows_aarch64_gnullvm", |
|
| 2268 | + | "windows_aarch64_msvc", |
|
| 2269 | + | "windows_i686_gnu", |
|
| 2270 | + | "windows_i686_gnullvm", |
|
| 2271 | + | "windows_i686_msvc", |
|
| 2272 | + | "windows_x86_64_gnu", |
|
| 2273 | + | "windows_x86_64_gnullvm", |
|
| 2274 | + | "windows_x86_64_msvc", |
|
| 2275 | + | ] |
|
| 2276 | + | ||
| 2277 | + | [[package]] |
|
| 2278 | + | name = "windows_aarch64_gnullvm" |
|
| 2279 | + | version = "0.52.6" |
|
| 2280 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2281 | + | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" |
|
| 2282 | + | ||
| 2283 | + | [[package]] |
|
| 2284 | + | name = "windows_aarch64_msvc" |
|
| 2285 | + | version = "0.52.6" |
|
| 2286 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2287 | + | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" |
|
| 2288 | + | ||
| 2289 | + | [[package]] |
|
| 2290 | + | name = "windows_i686_gnu" |
|
| 2291 | + | version = "0.52.6" |
|
| 2292 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2293 | + | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" |
|
| 2294 | + | ||
| 2295 | + | [[package]] |
|
| 2296 | + | name = "windows_i686_gnullvm" |
|
| 2297 | + | version = "0.52.6" |
|
| 2298 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2299 | + | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" |
|
| 2300 | + | ||
| 2301 | + | [[package]] |
|
| 2302 | + | name = "windows_i686_msvc" |
|
| 2303 | + | version = "0.52.6" |
|
| 2304 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2305 | + | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" |
|
| 2306 | + | ||
| 2307 | + | [[package]] |
|
| 2308 | + | name = "windows_x86_64_gnu" |
|
| 2309 | + | version = "0.52.6" |
|
| 2310 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2311 | + | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" |
|
| 2312 | + | ||
| 2313 | + | [[package]] |
|
| 2314 | + | name = "windows_x86_64_gnullvm" |
|
| 2315 | + | version = "0.52.6" |
|
| 2316 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2317 | + | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" |
|
| 2318 | + | ||
| 2319 | + | [[package]] |
|
| 2320 | + | name = "windows_x86_64_msvc" |
|
| 2321 | + | version = "0.52.6" |
|
| 2322 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2323 | + | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" |
|
| 2324 | + | ||
| 2325 | + | [[package]] |
|
| 2326 | + | name = "wiremock" |
|
| 2327 | + | version = "0.6.5" |
|
| 2328 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2329 | + | checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" |
|
| 2330 | + | dependencies = [ |
|
| 2331 | + | "assert-json-diff", |
|
| 2332 | + | "base64", |
|
| 2333 | + | "deadpool", |
|
| 2334 | + | "futures", |
|
| 2335 | + | "http", |
|
| 2336 | + | "http-body-util", |
|
| 2337 | + | "hyper", |
|
| 2338 | + | "hyper-util", |
|
| 2339 | + | "log", |
|
| 2340 | + | "once_cell", |
|
| 2341 | + | "regex", |
|
| 2342 | + | "serde", |
|
| 2343 | + | "serde_json", |
|
| 2344 | + | "tokio", |
|
| 2345 | + | "url", |
|
| 2346 | + | ] |
|
| 2347 | + | ||
| 2348 | + | [[package]] |
|
| 2349 | + | name = "writeable" |
|
| 2350 | + | version = "0.6.3" |
|
| 2351 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2352 | + | checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" |
|
| 2353 | + | ||
| 2354 | + | [[package]] |
|
| 2355 | + | name = "yoke" |
|
| 2356 | + | version = "0.8.3" |
|
| 2357 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2358 | + | checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" |
|
| 2359 | + | dependencies = [ |
|
| 2360 | + | "stable_deref_trait", |
|
| 2361 | + | "yoke-derive", |
|
| 2362 | + | "zerofrom", |
|
| 2363 | + | ] |
|
| 2364 | + | ||
| 2365 | + | [[package]] |
|
| 2366 | + | name = "yoke-derive" |
|
| 2367 | + | version = "0.8.2" |
|
| 2368 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2369 | + | checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" |
|
| 2370 | + | dependencies = [ |
|
| 2371 | + | "proc-macro2", |
|
| 2372 | + | "quote", |
|
| 2373 | + | "syn 2.0.119", |
|
| 2374 | + | "synstructure", |
|
| 2375 | + | ] |
|
| 2376 | + | ||
| 2377 | + | [[package]] |
|
| 2378 | + | name = "zerofrom" |
|
| 2379 | + | version = "0.1.8" |
|
| 2380 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2381 | + | checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" |
|
| 2382 | + | dependencies = [ |
|
| 2383 | + | "zerofrom-derive", |
|
| 2384 | + | ] |
|
| 2385 | + | ||
| 2386 | + | [[package]] |
|
| 2387 | + | name = "zerofrom-derive" |
|
| 2388 | + | version = "0.1.7" |
|
| 2389 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2390 | + | checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" |
|
| 2391 | + | dependencies = [ |
|
| 2392 | + | "proc-macro2", |
|
| 2393 | + | "quote", |
|
| 2394 | + | "syn 2.0.119", |
|
| 2395 | + | "synstructure", |
|
| 2396 | + | ] |
|
| 2397 | + | ||
| 2398 | + | [[package]] |
|
| 2399 | + | name = "zeroize" |
|
| 2400 | + | version = "1.9.0" |
|
| 2401 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2402 | + | checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" |
|
| 2403 | + | ||
| 2404 | + | [[package]] |
|
| 2405 | + | name = "zerotrie" |
|
| 2406 | + | version = "0.2.4" |
|
| 2407 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2408 | + | checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" |
|
| 2409 | + | dependencies = [ |
|
| 2410 | + | "displaydoc", |
|
| 2411 | + | "yoke", |
|
| 2412 | + | "zerofrom", |
|
| 2413 | + | ] |
|
| 2414 | + | ||
| 2415 | + | [[package]] |
|
| 2416 | + | name = "zerovec" |
|
| 2417 | + | version = "0.11.6" |
|
| 2418 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2419 | + | checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" |
|
| 2420 | + | dependencies = [ |
|
| 2421 | + | "yoke", |
|
| 2422 | + | "zerofrom", |
|
| 2423 | + | "zerovec-derive", |
|
| 2424 | + | ] |
|
| 2425 | + | ||
| 2426 | + | [[package]] |
|
| 2427 | + | name = "zerovec-derive" |
|
| 2428 | + | version = "0.11.3" |
|
| 2429 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2430 | + | checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" |
|
| 2431 | + | dependencies = [ |
|
| 2432 | + | "proc-macro2", |
|
| 2433 | + | "quote", |
|
| 2434 | + | "syn 2.0.119", |
|
| 2435 | + | ] |
|
| 2436 | + | ||
| 2437 | + | [[package]] |
|
| 2438 | + | name = "zmij" |
|
| 2439 | + | version = "1.0.23" |
|
| 2440 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2441 | + | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" |
| 1 | + | [package] |
|
| 2 | + | name = "cielago" |
|
| 3 | + | version = "0.1.0" |
|
| 4 | + | edition = "2024" |
|
| 5 | + | repository = "https://github.com/stevedylandev/cielago" |
|
| 6 | + | description = "API Client TUI in Rust" |
|
| 7 | + | homepage = "https://github.com/stevedylandev/cielago" |
|
| 8 | + | ||
| 9 | + | [dependencies] |
|
| 10 | + | anyhow = "1.0.104" |
|
| 11 | + | clap = { version = "4.6.6", features = ["derive"] } |
|
| 12 | + | crossterm = "0.28.1" |
|
| 13 | + | dirs = "6.0.0" |
|
| 14 | + | ratatui = "0.29.0" |
|
| 15 | + | reqwest = { version = "0.13.4", default-features = false, features = [ |
|
| 16 | + | "rustls", |
|
| 17 | + | "json", |
|
| 18 | + | "query", |
|
| 19 | + | "form", |
|
| 20 | + | ] } |
|
| 21 | + | serde = { version = "1.0.229", features = ["derive"] } |
|
| 22 | + | serde_json = { version = "1.0.151", features = ["preserve_order"] } |
|
| 23 | + | serde_yaml = "0.9.34" |
|
| 24 | + | thiserror = "2.0.19" |
|
| 25 | + | tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros"] } |
|
| 26 | + | tui-textarea = "0.7.0" |
|
| 27 | + | url = "2.5.8" |
|
| 28 | + | uuid = { version = "1.24.0", features = ["v4", "serde"] } |
|
| 29 | + | ||
| 30 | + | [dev-dependencies] |
|
| 31 | + | wiremock = "0.6" |
|
| 32 | + | tempfile = "3" |
|
| 33 | + | tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros"] } |
|
| 34 | + | ||
| 35 | + | # The profile that 'dist' will build with |
|
| 36 | + | [profile.dist] |
|
| 37 | + | inherits = "release" |
|
| 38 | + | lto = "thin" |
| 1 | + | MIT License |
|
| 2 | + | ||
| 3 | + | Copyright (c) 2026 Steve Simkins |
|
| 4 | + | ||
| 5 | + | Permission is hereby granted, free of charge, to any person obtaining a copy |
|
| 6 | + | of this software and associated documentation files (the "Software"), to deal |
|
| 7 | + | in the Software without restriction, including without limitation the rights |
|
| 8 | + | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
|
| 9 | + | copies of the Software, and to permit persons to whom the Software is |
|
| 10 | + | furnished to do so, subject to the following conditions: |
|
| 11 | + | ||
| 12 | + | The above copyright notice and this permission notice shall be included in all |
|
| 13 | + | copies or substantial portions of the Software. |
|
| 14 | + | ||
| 15 | + | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|
| 16 | + | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
|
| 17 | + | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
|
| 18 | + | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
|
| 19 | + | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
|
| 20 | + | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|
| 21 | + | SOFTWARE. |
|
| 22 | + |
| 1 | + | # cielago |
|
| 2 | + | ||
| 3 | + | A vim-style TUI for building and sending HTTP requests, organized into |
|
| 4 | + | collections you can import straight from an OpenAPI spec. |
|
| 5 | + | ||
| 6 | + | ## Features |
|
| 7 | + | ||
| 8 | + | - **OpenAPI 3.x import** — turn a spec (file or URL) into a collection with |
|
| 9 | + | requests, params, example bodies, servers, and docs prefilled. |
|
| 10 | + | - **Ad-hoc collections** — no spec needed; paste a full URL and it's split into |
|
| 11 | + | server, path, and query params for you. |
|
| 12 | + | - **Vim-style TUI** — three panes (requests / editor / response), `j`/`k` |
|
| 13 | + | navigation, `:` command line, `/` incremental search. |
|
| 14 | + | - **Variables** — `{{name}}` from the collection, plus dynamic ones like |
|
| 15 | + | `{{uuid}}`, `{{timestamp}}`, `{{randomInt(1,100)}}`. |
|
| 16 | + | - **OAuth2 client credentials** — per collection, tokens cached in memory only. |
|
| 17 | + | - **Server switcher** — swap base URLs so requests stay portable across envs. |
|
| 18 | + | - **Syntax highlighting** — JSON and XML in both request bodies and responses. |
|
| 19 | + | - **Plain JSON storage** — collections live in `~/.config/cielago/collections/`. |
|
| 20 | + | ||
| 21 | + | ## Installation |
|
| 22 | + | ||
| 23 | + | ```sh |
|
| 24 | + | cargo install --path . |
|
| 25 | + | ``` |
|
| 26 | + | ||
| 27 | + | Or build without installing: |
|
| 28 | + | ||
| 29 | + | ```sh |
|
| 30 | + | cargo build --release |
|
| 31 | + | ./target/release/cielago --help |
|
| 32 | + | ``` |
|
| 33 | + | ||
| 34 | + | ## Usage |
|
| 35 | + | ||
| 36 | + | ```sh |
|
| 37 | + | cielago # open the last-used collection in the TUI |
|
| 38 | + | cielago open [name] # open a specific collection |
|
| 39 | + | cielago import <spec|url> # import an OpenAPI 3.x spec |
|
| 40 | + | cielago new <name> [--server u] # create an empty collection and open it |
|
| 41 | + | cielago list [-l] # list collections (-l adds counts + paths) |
|
| 42 | + | cielago info <name> # servers, counts, auth, groups |
|
| 43 | + | cielago edit <name> # edit the collection JSON in $EDITOR |
|
| 44 | + | cielago rename <name> <new> # rename a collection and its file |
|
| 45 | + | cielago delete <name> [-f] # delete a collection |
|
| 46 | + | cielago path <name> # print the collection's JSON path |
|
| 47 | + | ``` |
|
| 48 | + | ||
| 49 | + | `<name>` matches loosely — `Some API`, `some api`, and `some-api` all resolve to |
|
| 50 | + | the same collection. |
|
| 51 | + | ||
| 52 | + | ### Keys |
|
| 53 | + | ||
| 54 | + | | Key | Action | |
|
| 55 | + | |---|---| |
|
| 56 | + | | `1`/`2`/`3`, `Tab` | Focus sidebar / editor / response | |
|
| 57 | + | | `z` | Maximize focused pane | |
|
| 58 | + | | `[` / `]` | Previous / next editor tab | |
|
| 59 | + | | `Enter` | Send request | |
|
| 60 | + | | `/` | Search requests | |
|
| 61 | + | | `E` / `A` | Servers / OAuth config | |
|
| 62 | + | | `:` | Command line (`:w`, `:q`, `:new`, `:open`, …) | |
|
| 63 | + | | `?` | Help | |
|
| 64 | + | ||
| 65 | + | Sidebar: `n`/`r`/`d`/`y` new/rename/delete/duplicate, `t` cycle label source. |
|
| 66 | + | Tables: `space` toggle row, `i` edit, `a` add, `d` delete, `m` cycle method, |
|
| 67 | + | `p` edit URL. Body/response: `i` edit inline, `e` open in `$EDITOR`, |
|
| 68 | + | `j`/`k`/`d`/`u`/`g`/`G` scroll. |
|
| 69 | + | ||
| 70 | + | Press `?` in the TUI for the full list. |
|
| 71 | + | ||
| 72 | + | ## Development |
|
| 73 | + | ||
| 74 | + | ```sh |
|
| 75 | + | cargo build |
|
| 76 | + | cargo test |
|
| 77 | + | cargo clippy --all-targets |
|
| 78 | + | cargo fmt |
|
| 79 | + | ``` |
|
| 80 | + | ||
| 81 | + | ## License |
|
| 82 | + | ||
| 83 | + | [MIT](LICENSE) |
| 1 | + | [workspace] |
|
| 2 | + | members = ["cargo:."] |
|
| 3 | + | ||
| 4 | + | # Config for 'dist' |
|
| 5 | + | [dist] |
|
| 6 | + | # The preferred dist version to use in CI (Cargo.toml SemVer syntax) |
|
| 7 | + | cargo-dist-version = "0.32.0" |
|
| 8 | + | # CI backends to support |
|
| 9 | + | ci = "github" |
|
| 10 | + | # The installers to generate for each app |
|
| 11 | + | installers = ["shell", "homebrew"] |
|
| 12 | + | # A GitHub repo to push Homebrew formulas to |
|
| 13 | + | tap = "stevedylandev/cielago" |
|
| 14 | + | # Target platforms to build apps for (Rust target-triple syntax) |
|
| 15 | + | targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] |
|
| 16 | + | # Path that installers should place binaries in |
|
| 17 | + | install-path = "CARGO_HOME" |
|
| 18 | + | # Publish jobs to run in CI |
|
| 19 | + | publish-jobs = ["homebrew"] |
|
| 20 | + | # Whether to install an updater program |
|
| 21 | + | install-updater = false |
| 1 | + | //! Application state and the TUI run loop. |
|
| 2 | + | ||
| 3 | + | use std::collections::HashSet; |
|
| 4 | + | use std::io; |
|
| 5 | + | use std::path::PathBuf; |
|
| 6 | + | use std::process::Command; |
|
| 7 | + | use std::time::Duration; |
|
| 8 | + | ||
| 9 | + | use anyhow::Result; |
|
| 10 | + | use crossterm::event::{self, Event, KeyEventKind}; |
|
| 11 | + | use crossterm::execute; |
|
| 12 | + | use crossterm::terminal::{ |
|
| 13 | + | EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, |
|
| 14 | + | }; |
|
| 15 | + | use ratatui::Terminal; |
|
| 16 | + | use ratatui::backend::CrosstermBackend; |
|
| 17 | + | use serde::{Deserialize, Serialize}; |
|
| 18 | + | use tokio::sync::mpsc; |
|
| 19 | + | use tui_textarea::TextArea; |
|
| 20 | + | use uuid::Uuid; |
|
| 21 | + | ||
| 22 | + | use crate::http::{HttpResponse, OAuthToken, SendOutcome, send_with_auth, split_url_input}; |
|
| 23 | + | use crate::model::{Collection, KeyValueRow, LabelMode, OAuthConfig, SavedRequest, variables_map}; |
|
| 24 | + | use crate::store::{self, AppConfig}; |
|
| 25 | + | use crate::{input, ui}; |
|
| 26 | + | ||
| 27 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
|
| 28 | + | pub enum Mode { |
|
| 29 | + | Normal, |
|
| 30 | + | Insert, |
|
| 31 | + | Command, |
|
| 32 | + | /// Incremental sidebar filter, opened with `/`. |
|
| 33 | + | Search, |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | /// Which pane has the keyboard: the `1` / `2` / `3` panes. Persisted as part |
|
| 37 | + | /// of a collection's saved view, hence the serde derives. |
|
| 38 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
|
| 39 | + | #[serde(rename_all = "lowercase")] |
|
| 40 | + | pub enum Focus { |
|
| 41 | + | Sidebar, |
|
| 42 | + | Editor, |
|
| 43 | + | Response, |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | /// Persisted with the saved view alongside [`Focus`]. |
|
| 47 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
|
| 48 | + | #[serde(rename_all = "lowercase")] |
|
| 49 | + | pub enum EditorTab { |
|
| 50 | + | Params, |
|
| 51 | + | Headers, |
|
| 52 | + | Body, |
|
| 53 | + | /// Read-only view of the spec's types, enums and descriptions. |
|
| 54 | + | Docs, |
|
| 55 | + | Variables, |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | impl EditorTab { |
|
| 59 | + | pub const ALL: [EditorTab; 5] = [ |
|
| 60 | + | EditorTab::Params, |
|
| 61 | + | EditorTab::Headers, |
|
| 62 | + | EditorTab::Body, |
|
| 63 | + | EditorTab::Docs, |
|
| 64 | + | EditorTab::Variables, |
|
| 65 | + | ]; |
|
| 66 | + | ||
| 67 | + | pub fn title(self) -> &'static str { |
|
| 68 | + | match self { |
|
| 69 | + | EditorTab::Params => "Params", |
|
| 70 | + | EditorTab::Headers => "Headers", |
|
| 71 | + | EditorTab::Body => "Body", |
|
| 72 | + | EditorTab::Docs => "Docs", |
|
| 73 | + | EditorTab::Variables => "Variables", |
|
| 74 | + | } |
|
| 75 | + | } |
|
| 76 | + | ||
| 77 | + | pub fn index(self) -> usize { |
|
| 78 | + | EditorTab::ALL.iter().position(|t| *t == self).unwrap_or(0) |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | pub fn next(self) -> Self { |
|
| 82 | + | EditorTab::ALL[(self.index() + 1) % EditorTab::ALL.len()] |
|
| 83 | + | } |
|
| 84 | + | ||
| 85 | + | pub fn prev(self) -> Self { |
|
| 86 | + | EditorTab::ALL[(self.index() + EditorTab::ALL.len() - 1) % EditorTab::ALL.len()] |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | /// Tables map onto editable key/value rows; Body uses the textarea and |
|
| 90 | + | /// Docs is rendered text. |
|
| 91 | + | pub fn table(self) -> Option<TableId> { |
|
| 92 | + | match self { |
|
| 93 | + | EditorTab::Params => Some(TableId::Params), |
|
| 94 | + | EditorTab::Headers => Some(TableId::Headers), |
|
| 95 | + | EditorTab::Variables => Some(TableId::Vars), |
|
| 96 | + | EditorTab::Body | EditorTab::Docs => None, |
|
| 97 | + | } |
|
| 98 | + | } |
|
| 99 | + | } |
|
| 100 | + | ||
| 101 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
|
| 102 | + | pub enum TableId { |
|
| 103 | + | /// Path params first, then query params (Postman-style Params tab). |
|
| 104 | + | Params, |
|
| 105 | + | Headers, |
|
| 106 | + | Vars, |
|
| 107 | + | } |
|
| 108 | + | ||
| 109 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
|
| 110 | + | pub enum Popup { |
|
| 111 | + | None, |
|
| 112 | + | Help, |
|
| 113 | + | Env, |
|
| 114 | + | Auth, |
|
| 115 | + | } |
|
| 116 | + | ||
| 117 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
|
| 118 | + | pub enum CellCol { |
|
| 119 | + | Key, |
|
| 120 | + | Value, |
|
| 121 | + | } |
|
| 122 | + | ||
| 123 | + | /// What the single-line input currently edits (Insert mode). |
|
| 124 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
|
| 125 | + | pub enum EditTarget { |
|
| 126 | + | Cell { |
|
| 127 | + | table: TableId, |
|
| 128 | + | row: usize, |
|
| 129 | + | col: CellCol, |
|
| 130 | + | }, |
|
| 131 | + | Rename, |
|
| 132 | + | NewRequest, |
|
| 133 | + | /// The selected request's URL / path. Pasting an absolute URL here also |
|
| 134 | + | /// sets the collection's server — see [`App::apply_url_input`]. |
|
| 135 | + | Url, |
|
| 136 | + | EnvNew, |
|
| 137 | + | AuthField(usize), |
|
| 138 | + | } |
|
| 139 | + | ||
| 140 | + | #[derive(Debug, Clone, PartialEq, Eq)] |
|
| 141 | + | pub enum SidebarRow { |
|
| 142 | + | Group(String), |
|
| 143 | + | Request(usize), |
|
| 144 | + | } |
|
| 145 | + | ||
| 146 | + | /// What a queued `$EDITOR` session opens. |
|
| 147 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
|
| 148 | + | pub enum ExternalEdit { |
|
| 149 | + | /// The request body; the file is read back on exit. |
|
| 150 | + | Body, |
|
| 151 | + | /// The response body, for paging/searching in a real editor. Saves are |
|
| 152 | + | /// discarded — a response is a record of what the server returned. |
|
| 153 | + | Response, |
|
| 154 | + | } |
|
| 155 | + | ||
| 156 | + | /// Sidebar group a request belongs to: its first spec tag, or `default`. |
|
| 157 | + | fn group_tag(req: &SavedRequest) -> String { |
|
| 158 | + | req.tags |
|
| 159 | + | .first() |
|
| 160 | + | .cloned() |
|
| 161 | + | .unwrap_or_else(|| "default".into()) |
|
| 162 | + | } |
|
| 163 | + | ||
| 164 | + | /// `"list pets"` → `"list pets copy"`, then `"list pets copy 2"`, … Nothing in |
|
| 165 | + | /// the app keys on `name`, but three identically-labelled sidebar rows are |
|
| 166 | + | /// unusable. A ` copy`/` copy N` suffix on the source is stripped first, so |
|
| 167 | + | /// duplicating a duplicate gives `x copy 2` rather than `x copy copy`. |
|
| 168 | + | fn unique_request_name(requests: &[SavedRequest], base: &str) -> String { |
|
| 169 | + | let stem = copy_stem(base); |
|
| 170 | + | let taken = |name: &str| requests.iter().any(|r| r.name == name); |
|
| 171 | + | let first = format!("{stem} copy"); |
|
| 172 | + | if !taken(&first) { |
|
| 173 | + | return first; |
|
| 174 | + | } |
|
| 175 | + | (2..) |
|
| 176 | + | .map(|n| format!("{stem} copy {n}")) |
|
| 177 | + | .find(|candidate| !taken(candidate)) |
|
| 178 | + | .unwrap_or(first) |
|
| 179 | + | } |
|
| 180 | + | ||
| 181 | + | /// Strip a trailing ` copy` or ` copy <n>` from a request name. |
|
| 182 | + | fn copy_stem(name: &str) -> &str { |
|
| 183 | + | if let Some(head) = name.strip_suffix(" copy") { |
|
| 184 | + | return head; |
|
| 185 | + | } |
|
| 186 | + | let without_digits = name.trim_end_matches(|c: char| c.is_ascii_digit()); |
|
| 187 | + | if without_digits.len() < name.len() |
|
| 188 | + | && let Some(head) = without_digits.strip_suffix(" copy ") |
|
| 189 | + | { |
|
| 190 | + | return head; |
|
| 191 | + | } |
|
| 192 | + | name |
|
| 193 | + | } |
|
| 194 | + | ||
| 195 | + | /// Minimal single-line editor with a cursor. |
|
| 196 | + | #[derive(Debug, Default, Clone)] |
|
| 197 | + | pub struct LineEdit { |
|
| 198 | + | pub buf: String, |
|
| 199 | + | pub cursor: usize, |
|
| 200 | + | } |
|
| 201 | + | ||
| 202 | + | impl LineEdit { |
|
| 203 | + | pub fn set(&mut self, s: &str) { |
|
| 204 | + | self.buf = s.to_string(); |
|
| 205 | + | self.cursor = self.buf.len(); |
|
| 206 | + | } |
|
| 207 | + | ||
| 208 | + | pub fn insert(&mut self, c: char) { |
|
| 209 | + | self.buf.insert(self.cursor, c); |
|
| 210 | + | self.cursor += c.len_utf8(); |
|
| 211 | + | } |
|
| 212 | + | ||
| 213 | + | pub fn backspace(&mut self) { |
|
| 214 | + | if self.cursor > 0 { |
|
| 215 | + | let prev = self.cursor - self.buf[..self.cursor].chars().last().unwrap().len_utf8(); |
|
| 216 | + | self.buf.replace_range(prev..self.cursor, ""); |
|
| 217 | + | self.cursor = prev; |
|
| 218 | + | } |
|
| 219 | + | } |
|
| 220 | + | ||
| 221 | + | pub fn delete(&mut self) { |
|
| 222 | + | if self.cursor < self.buf.len() { |
|
| 223 | + | let next = self.cursor + self.buf[self.cursor..].chars().next().unwrap().len_utf8(); |
|
| 224 | + | self.buf.replace_range(self.cursor..next, ""); |
|
| 225 | + | } |
|
| 226 | + | } |
|
| 227 | + | ||
| 228 | + | pub fn left(&mut self) { |
|
| 229 | + | if self.cursor > 0 { |
|
| 230 | + | self.cursor -= self.buf[..self.cursor].chars().last().unwrap().len_utf8(); |
|
| 231 | + | } |
|
| 232 | + | } |
|
| 233 | + | ||
| 234 | + | pub fn right(&mut self) { |
|
| 235 | + | if self.cursor < self.buf.len() { |
|
| 236 | + | self.cursor += self.buf[self.cursor..].chars().next().unwrap().len_utf8(); |
|
| 237 | + | } |
|
| 238 | + | } |
|
| 239 | + | ||
| 240 | + | pub fn home(&mut self) { |
|
| 241 | + | self.cursor = 0; |
|
| 242 | + | } |
|
| 243 | + | ||
| 244 | + | pub fn end(&mut self) { |
|
| 245 | + | self.cursor = self.buf.len(); |
|
| 246 | + | } |
|
| 247 | + | } |
|
| 248 | + | ||
| 249 | + | pub struct App { |
|
| 250 | + | pub collection: Collection, |
|
| 251 | + | pub path: PathBuf, |
|
| 252 | + | pub config: AppConfig, |
|
| 253 | + | pub client: reqwest::Client, |
|
| 254 | + | pub dirty: bool, |
|
| 255 | + | pub should_quit: bool, |
|
| 256 | + | ||
| 257 | + | pub mode: Mode, |
|
| 258 | + | pub focus: Focus, |
|
| 259 | + | pub tab: EditorTab, |
|
| 260 | + | pub popup: Popup, |
|
| 261 | + | /// `z`: the focused pane fills the frame and the others are not drawn. |
|
| 262 | + | /// Purely a view flag — focus still moves normally underneath, so |
|
| 263 | + | /// Tab/1/2/3 swap which pane is the zoomed one. |
|
| 264 | + | pub zoom: bool, |
|
| 265 | + | ||
| 266 | + | pub collapsed: HashSet<String>, |
|
| 267 | + | pub sidebar_rows: Vec<SidebarRow>, |
|
| 268 | + | pub sidebar_sel: usize, |
|
| 269 | + | /// Active sidebar filter; empty means "show everything". |
|
| 270 | + | pub filter: String, |
|
| 271 | + | /// The `/` prompt buffer while [`Mode::Search`] is active. |
|
| 272 | + | pub search: LineEdit, |
|
| 273 | + | ||
| 274 | + | /// Index into `collection.requests` currently loaded in the editor. |
|
| 275 | + | pub selected: Option<usize>, |
|
| 276 | + | pub table_row: usize, |
|
| 277 | + | ||
| 278 | + | pub editing: Option<EditTarget>, |
|
| 279 | + | pub input: LineEdit, |
|
| 280 | + | /// After committing a new row's key, continue to its value cell. |
|
| 281 | + | pub chain_to_value: bool, |
|
| 282 | + | pub textarea: TextArea<'static>, |
|
| 283 | + | ||
| 284 | + | /// Scroll offset of the Docs tab, reset when another request is opened. |
|
| 285 | + | pub docs_scroll: usize, |
|
| 286 | + | ||
| 287 | + | pub response: Option<HttpResponse>, |
|
| 288 | + | pub response_scroll: usize, |
|
| 289 | + | ||
| 290 | + | pub sending: bool, |
|
| 291 | + | pub tx: mpsc::UnboundedSender<SendOutcome>, |
|
| 292 | + | pub rx: mpsc::UnboundedReceiver<SendOutcome>, |
|
| 293 | + | pub token: Option<OAuthToken>, |
|
| 294 | + | ||
| 295 | + | pub status: String, |
|
| 296 | + | pub command: String, |
|
| 297 | + | pub pending_external: Option<ExternalEdit>, |
|
| 298 | + | /// Scroll offset of the help popup, which is taller than short terminals. |
|
| 299 | + | pub help_scroll: usize, |
|
| 300 | + | ||
| 301 | + | pub env_sel: usize, |
|
| 302 | + | pub auth_form: OAuthConfig, |
|
| 303 | + | pub auth_field: usize, |
|
| 304 | + | } |
|
| 305 | + | ||
| 306 | + | impl App { |
|
| 307 | + | pub fn new(collection: Collection, path: PathBuf, config: AppConfig) -> Self { |
|
| 308 | + | let (tx, rx) = mpsc::unbounded_channel(); |
|
| 309 | + | let client = reqwest::Client::builder() |
|
| 310 | + | .timeout(Duration::from_secs(30)) |
|
| 311 | + | .build() |
|
| 312 | + | .unwrap_or_default(); |
|
| 313 | + | let mut app = Self { |
|
| 314 | + | collection, |
|
| 315 | + | path, |
|
| 316 | + | config, |
|
| 317 | + | client, |
|
| 318 | + | dirty: false, |
|
| 319 | + | should_quit: false, |
|
| 320 | + | mode: Mode::Normal, |
|
| 321 | + | focus: Focus::Sidebar, |
|
| 322 | + | tab: EditorTab::Params, |
|
| 323 | + | popup: Popup::None, |
|
| 324 | + | zoom: false, |
|
| 325 | + | collapsed: HashSet::new(), |
|
| 326 | + | sidebar_rows: Vec::new(), |
|
| 327 | + | sidebar_sel: 0, |
|
| 328 | + | filter: String::new(), |
|
| 329 | + | search: LineEdit::default(), |
|
| 330 | + | selected: None, |
|
| 331 | + | table_row: 0, |
|
| 332 | + | editing: None, |
|
| 333 | + | input: LineEdit::default(), |
|
| 334 | + | chain_to_value: false, |
|
| 335 | + | textarea: TextArea::default(), |
|
| 336 | + | docs_scroll: 0, |
|
| 337 | + | response: None, |
|
| 338 | + | response_scroll: 0, |
|
| 339 | + | sending: false, |
|
| 340 | + | tx, |
|
| 341 | + | rx, |
|
| 342 | + | token: None, |
|
| 343 | + | status: "Press ? for help".to_string(), |
|
| 344 | + | command: String::new(), |
|
| 345 | + | pending_external: None, |
|
| 346 | + | help_scroll: 0, |
|
| 347 | + | env_sel: 0, |
|
| 348 | + | auth_form: OAuthConfig::default(), |
|
| 349 | + | auth_field: 0, |
|
| 350 | + | }; |
|
| 351 | + | if app.collection.groups_collapsed { |
|
| 352 | + | app.collapsed = app.group_tags(); |
|
| 353 | + | } |
|
| 354 | + | app.rebuild_sidebar(); |
|
| 355 | + | if !app.collection.requests.is_empty() { |
|
| 356 | + | // Restore the request that was open when the collection was last |
|
| 357 | + | // saved; failing that, load the first one so the editor isn't |
|
| 358 | + | // blank. |
|
| 359 | + | match app.saved_view_index() { |
|
| 360 | + | Some(idx) => app.restore_saved_view(idx), |
|
| 361 | + | None => app.select_request(0), |
|
| 362 | + | } |
|
| 363 | + | } |
|
| 364 | + | app.restore_saved_panes(); |
|
| 365 | + | if app.collection.requests.is_empty() { |
|
| 366 | + | app.status = "Empty collection — press n to add a request".into(); |
|
| 367 | + | } |
|
| 368 | + | app |
|
| 369 | + | } |
|
| 370 | + | ||
| 371 | + | // ----- saved view ----- |
|
| 372 | + | ||
| 373 | + | /// Index of `collection.last_request`, if that request still exists. Ids |
|
| 374 | + | /// are matched rather than positions so a re-import that reorders or drops |
|
| 375 | + | /// operations can't restore the wrong request. |
|
| 376 | + | fn saved_view_index(&self) -> Option<usize> { |
|
| 377 | + | let id = self.collection.last_request?; |
|
| 378 | + | self.collection.requests.iter().position(|r| r.id == id) |
|
| 379 | + | } |
|
| 380 | + | ||
| 381 | + | /// Restore the focused pane and editor tab from the saved view. Without |
|
| 382 | + | /// one, focus starts on the sidebar (`select_request` leaves it on the |
|
| 383 | + | /// editor): opening a collection, the first move is picking which request |
|
| 384 | + | /// to work on. |
|
| 385 | + | fn restore_saved_panes(&mut self) { |
|
| 386 | + | if let Some(tab) = self.collection.last_tab { |
|
| 387 | + | self.tab = tab; |
|
| 388 | + | } |
|
| 389 | + | self.focus = match self.collection.last_focus { |
|
| 390 | + | // Responses aren't persisted, so a saved Response pane is empty on |
|
| 391 | + | // open — land on the editor instead of a pane with nothing in it. |
|
| 392 | + | Some(Focus::Response) if self.response.is_none() => Focus::Editor, |
|
| 393 | + | Some(focus) => focus, |
|
| 394 | + | None => Focus::Sidebar, |
|
| 395 | + | }; |
|
| 396 | + | } |
|
| 397 | + | ||
| 398 | + | /// Open `idx` and park the sidebar cursor on it. Expands the containing |
|
| 399 | + | /// group if needed: with `groups_collapsed` set, the restored request would |
|
| 400 | + | /// otherwise be scrolled to but invisible. |
|
| 401 | + | fn restore_saved_view(&mut self, idx: usize) { |
|
| 402 | + | let tag = group_tag(&self.collection.requests[idx]); |
|
| 403 | + | if self.collapsed.remove(&tag) { |
|
| 404 | + | self.rebuild_sidebar(); |
|
| 405 | + | } |
|
| 406 | + | if let Some(pos) = self |
|
| 407 | + | .sidebar_rows |
|
| 408 | + | .iter() |
|
| 409 | + | .position(|r| *r == SidebarRow::Request(idx)) |
|
| 410 | + | { |
|
| 411 | + | self.sidebar_sel = pos; |
|
| 412 | + | } |
|
| 413 | + | self.select_request(idx); |
|
| 414 | + | } |
|
| 415 | + | ||
| 416 | + | // ----- sidebar ----- |
|
| 417 | + | ||
| 418 | + | /// Every group tag in the collection, filter ignored. |
|
| 419 | + | fn group_tags(&self) -> HashSet<String> { |
|
| 420 | + | self.collection.requests.iter().map(group_tag).collect() |
|
| 421 | + | } |
|
| 422 | + | ||
| 423 | + | pub fn rebuild_sidebar(&mut self) { |
|
| 424 | + | let filtering = !self.filter.is_empty(); |
|
| 425 | + | let mut rows = Vec::new(); |
|
| 426 | + | let mut groups: Vec<String> = Vec::new(); |
|
| 427 | + | for (i, req) in self.collection.requests.iter().enumerate() { |
|
| 428 | + | if filtering && !req.matches(&self.filter) { |
|
| 429 | + | continue; |
|
| 430 | + | } |
|
| 431 | + | let tag = group_tag(req); |
|
| 432 | + | if !groups.contains(&tag) { |
|
| 433 | + | groups.push(tag.clone()); |
|
| 434 | + | rows.push(SidebarRow::Group(tag.clone())); |
|
| 435 | + | } |
|
| 436 | + | // While filtering, matches are always shown — a collapsed group |
|
| 437 | + | // would otherwise hide the thing being searched for. |
|
| 438 | + | if filtering || !self.collapsed.contains(&tag) { |
|
| 439 | + | rows.push(SidebarRow::Request(i)); |
|
| 440 | + | } |
|
| 441 | + | } |
|
| 442 | + | self.sidebar_rows = rows; |
|
| 443 | + | if self.sidebar_sel >= self.sidebar_rows.len() { |
|
| 444 | + | self.sidebar_sel = self.sidebar_rows.len().saturating_sub(1); |
|
| 445 | + | } |
|
| 446 | + | } |
|
| 447 | + | ||
| 448 | + | // ----- sidebar search ----- |
|
| 449 | + | ||
| 450 | + | pub fn start_search(&mut self) { |
|
| 451 | + | self.search.set(&self.filter); |
|
| 452 | + | self.focus = Focus::Sidebar; |
|
| 453 | + | self.mode = Mode::Search; |
|
| 454 | + | } |
|
| 455 | + | ||
| 456 | + | /// Re-apply the live `/` buffer as the filter and land the cursor on the |
|
| 457 | + | /// first matching request. |
|
| 458 | + | pub fn apply_search(&mut self) { |
|
| 459 | + | self.filter = self.search.buf.clone(); |
|
| 460 | + | self.rebuild_sidebar(); |
|
| 461 | + | if let Some(pos) = self |
|
| 462 | + | .sidebar_rows |
|
| 463 | + | .iter() |
|
| 464 | + | .position(|r| matches!(r, SidebarRow::Request(_))) |
|
| 465 | + | { |
|
| 466 | + | self.sidebar_sel = pos; |
|
| 467 | + | } |
|
| 468 | + | } |
|
| 469 | + | ||
| 470 | + | pub fn finish_search(&mut self) { |
|
| 471 | + | self.mode = Mode::Normal; |
|
| 472 | + | self.status = if self.filter.is_empty() { |
|
| 473 | + | "Filter cleared".into() |
|
| 474 | + | } else { |
|
| 475 | + | let n = self |
|
| 476 | + | .sidebar_rows |
|
| 477 | + | .iter() |
|
| 478 | + | .filter(|r| matches!(r, SidebarRow::Request(_))) |
|
| 479 | + | .count(); |
|
| 480 | + | format!("Filter \"{}\" — {n} request(s) · Esc clears", self.filter) |
|
| 481 | + | }; |
|
| 482 | + | } |
|
| 483 | + | ||
| 484 | + | pub fn clear_filter(&mut self) { |
|
| 485 | + | if self.filter.is_empty() { |
|
| 486 | + | return; |
|
| 487 | + | } |
|
| 488 | + | self.filter.clear(); |
|
| 489 | + | self.search.set(""); |
|
| 490 | + | self.rebuild_sidebar(); |
|
| 491 | + | self.status = "Filter cleared".into(); |
|
| 492 | + | } |
|
| 493 | + | ||
| 494 | + | // ----- request labels ----- |
|
| 495 | + | ||
| 496 | + | pub fn cycle_label_mode(&mut self) { |
|
| 497 | + | self.collection.label_mode = self.collection.label_mode.next(); |
|
| 498 | + | self.dirty = true; |
|
| 499 | + | self.status = format!("Sidebar labels: {}", self.collection.label_mode.title()); |
|
| 500 | + | } |
|
| 501 | + | ||
| 502 | + | pub fn activate_sidebar(&mut self) { |
|
| 503 | + | match self.sidebar_rows.get(self.sidebar_sel).cloned() { |
|
| 504 | + | Some(SidebarRow::Group(tag)) => { |
|
| 505 | + | if !self.collapsed.remove(&tag) { |
|
| 506 | + | self.collapsed.insert(tag); |
|
| 507 | + | } |
|
| 508 | + | self.rebuild_sidebar(); |
|
| 509 | + | } |
|
| 510 | + | Some(SidebarRow::Request(idx)) => self.select_request(idx), |
|
| 511 | + | None => {} |
|
| 512 | + | } |
|
| 513 | + | } |
|
| 514 | + | ||
| 515 | + | // ----- request selection ----- |
|
| 516 | + | ||
| 517 | + | pub fn selected_request(&self) -> Option<&SavedRequest> { |
|
| 518 | + | self.selected.map(|i| &self.collection.requests[i]) |
|
| 519 | + | } |
|
| 520 | + | ||
| 521 | + | pub fn select_request(&mut self, idx: usize) { |
|
| 522 | + | self.commit_body(); |
|
| 523 | + | self.selected = Some(idx); |
|
| 524 | + | self.table_row = 0; |
|
| 525 | + | self.docs_scroll = 0; |
|
| 526 | + | let body = self.collection.requests[idx] |
|
| 527 | + | .body |
|
| 528 | + | .clone() |
|
| 529 | + | .unwrap_or_default(); |
|
| 530 | + | self.set_textarea_text(&body); |
|
| 531 | + | self.focus = Focus::Editor; |
|
| 532 | + | } |
|
| 533 | + | ||
| 534 | + | pub fn set_textarea_text(&mut self, text: &str) { |
|
| 535 | + | let lines: Vec<String> = if text.is_empty() { |
|
| 536 | + | vec![String::new()] |
|
| 537 | + | } else { |
|
| 538 | + | text.lines().map(String::from).collect() |
|
| 539 | + | }; |
|
| 540 | + | self.textarea = TextArea::from(lines); |
|
| 541 | + | self.textarea |
|
| 542 | + | .set_cursor_line_style(ratatui::style::Style::default()); |
|
| 543 | + | } |
|
| 544 | + | ||
| 545 | + | /// Write the textarea contents back into the selected request body. |
|
| 546 | + | pub fn commit_body(&mut self) { |
|
| 547 | + | let Some(idx) = self.selected else { return }; |
|
| 548 | + | let text = self.textarea.lines().join("\n"); |
|
| 549 | + | let text = text.trim_end_matches('\n').to_string(); |
|
| 550 | + | let req = &mut self.collection.requests[idx]; |
|
| 551 | + | let new = if text.trim().is_empty() { |
|
| 552 | + | None |
|
| 553 | + | } else { |
|
| 554 | + | Some(text) |
|
| 555 | + | }; |
|
| 556 | + | if req.body != new { |
|
| 557 | + | req.body = new; |
|
| 558 | + | self.dirty = true; |
|
| 559 | + | } |
|
| 560 | + | } |
|
| 561 | + | ||
| 562 | + | // ----- tables (params / headers / variables) ----- |
|
| 563 | + | ||
| 564 | + | pub fn table_len(&self, table: TableId) -> usize { |
|
| 565 | + | let Some(req) = self.selected_request() else { |
|
| 566 | + | return if table == TableId::Vars { |
|
| 567 | + | self.collection.variables.len() |
|
| 568 | + | } else { |
|
| 569 | + | 0 |
|
| 570 | + | }; |
|
| 571 | + | }; |
|
| 572 | + | match table { |
|
| 573 | + | TableId::Params => req.path_params.len() + req.query.len(), |
|
| 574 | + | TableId::Headers => req.headers.len(), |
|
| 575 | + | TableId::Vars => self.collection.variables.len(), |
|
| 576 | + | } |
|
| 577 | + | } |
|
| 578 | + | ||
| 579 | + | fn row_ref(&self, table: TableId, row: usize) -> Option<&KeyValueRow> { |
|
| 580 | + | match table { |
|
| 581 | + | TableId::Vars => self.collection.variables.get(row), |
|
| 582 | + | TableId::Headers => self.selected_request()?.headers.get(row), |
|
| 583 | + | TableId::Params => { |
|
| 584 | + | let req = self.selected_request()?; |
|
| 585 | + | let np = req.path_params.len(); |
|
| 586 | + | if row < np { |
|
| 587 | + | req.path_params.get(row) |
|
| 588 | + | } else { |
|
| 589 | + | req.query.get(row - np) |
|
| 590 | + | } |
|
| 591 | + | } |
|
| 592 | + | } |
|
| 593 | + | } |
|
| 594 | + | ||
| 595 | + | fn row_mut(&mut self, table: TableId, row: usize) -> Option<&mut KeyValueRow> { |
|
| 596 | + | match table { |
|
| 597 | + | TableId::Vars => self.collection.variables.get_mut(row), |
|
| 598 | + | TableId::Headers => { |
|
| 599 | + | let i = self.selected?; |
|
| 600 | + | self.collection.requests.get_mut(i)?.headers.get_mut(row) |
|
| 601 | + | } |
|
| 602 | + | TableId::Params => { |
|
| 603 | + | let i = self.selected?; |
|
| 604 | + | let req = self.collection.requests.get_mut(i)?; |
|
| 605 | + | let np = req.path_params.len(); |
|
| 606 | + | if row < np { |
|
| 607 | + | req.path_params.get_mut(row) |
|
| 608 | + | } else { |
|
| 609 | + | req.query.get_mut(row - np) |
|
| 610 | + | } |
|
| 611 | + | } |
|
| 612 | + | } |
|
| 613 | + | } |
|
| 614 | + | ||
| 615 | + | pub fn row_value(&self, table: TableId, row: usize, col: CellCol) -> Option<String> { |
|
| 616 | + | let r = self.row_ref(table, row)?; |
|
| 617 | + | Some(match col { |
|
| 618 | + | CellCol::Key => r.key.clone(), |
|
| 619 | + | CellCol::Value => r.value.clone(), |
|
| 620 | + | }) |
|
| 621 | + | } |
|
| 622 | + | ||
| 623 | + | pub fn toggle_row(&mut self, table: TableId, row: usize) { |
|
| 624 | + | if let Some(r) = self.row_mut(table, row) { |
|
| 625 | + | r.enabled = !r.enabled; |
|
| 626 | + | self.dirty = true; |
|
| 627 | + | } |
|
| 628 | + | } |
|
| 629 | + | ||
| 630 | + | pub fn delete_row(&mut self, table: TableId, row: usize) { |
|
| 631 | + | let removed = match table { |
|
| 632 | + | TableId::Vars => { |
|
| 633 | + | if row < self.collection.variables.len() { |
|
| 634 | + | self.collection.variables.remove(row); |
|
| 635 | + | true |
|
| 636 | + | } else { |
|
| 637 | + | false |
|
| 638 | + | } |
|
| 639 | + | } |
|
| 640 | + | TableId::Headers => match self.selected { |
|
| 641 | + | Some(i) if row < self.collection.requests[i].headers.len() => { |
|
| 642 | + | self.collection.requests[i].headers.remove(row); |
|
| 643 | + | true |
|
| 644 | + | } |
|
| 645 | + | _ => false, |
|
| 646 | + | }, |
|
| 647 | + | TableId::Params => match self.selected { |
|
| 648 | + | Some(i) => { |
|
| 649 | + | let req = &mut self.collection.requests[i]; |
|
| 650 | + | let np = req.path_params.len(); |
|
| 651 | + | if row < np { |
|
| 652 | + | req.path_params.remove(row); |
|
| 653 | + | true |
|
| 654 | + | } else if row - np < req.query.len() { |
|
| 655 | + | req.query.remove(row - np); |
|
| 656 | + | true |
|
| 657 | + | } else { |
|
| 658 | + | false |
|
| 659 | + | } |
|
| 660 | + | } |
|
| 661 | + | None => false, |
|
| 662 | + | }, |
|
| 663 | + | }; |
|
| 664 | + | if removed { |
|
| 665 | + | self.dirty = true; |
|
| 666 | + | let len = self.table_len(table); |
|
| 667 | + | if self.table_row >= len { |
|
| 668 | + | self.table_row = len.saturating_sub(1); |
|
| 669 | + | } |
|
| 670 | + | } |
|
| 671 | + | } |
|
| 672 | + | ||
| 673 | + | /// Append an empty row and start editing its key (value edit chains after). |
|
| 674 | + | pub fn add_row(&mut self, table: TableId) { |
|
| 675 | + | let row = match table { |
|
| 676 | + | TableId::Vars => { |
|
| 677 | + | self.collection |
|
| 678 | + | .variables |
|
| 679 | + | .push(KeyValueRow::new("", "", true)); |
|
| 680 | + | self.collection.variables.len() - 1 |
|
| 681 | + | } |
|
| 682 | + | TableId::Headers => { |
|
| 683 | + | let Some(i) = self.selected else { return }; |
|
| 684 | + | self.collection.requests[i] |
|
| 685 | + | .headers |
|
| 686 | + | .push(KeyValueRow::new("", "", true)); |
|
| 687 | + | self.collection.requests[i].headers.len() - 1 |
|
| 688 | + | } |
|
| 689 | + | TableId::Params => { |
|
| 690 | + | let Some(i) = self.selected else { return }; |
|
| 691 | + | // New params are query params; path params come from the path. |
|
| 692 | + | self.collection.requests[i] |
|
| 693 | + | .query |
|
| 694 | + | .push(KeyValueRow::new("", "", true)); |
|
| 695 | + | self.collection.requests[i].path_params.len() |
|
| 696 | + | + self.collection.requests[i].query.len() |
|
| 697 | + | - 1 |
|
| 698 | + | } |
|
| 699 | + | }; |
|
| 700 | + | self.dirty = true; |
|
| 701 | + | self.table_row = row; |
|
| 702 | + | self.start_edit(EditTarget::Cell { |
|
| 703 | + | table, |
|
| 704 | + | row, |
|
| 705 | + | col: CellCol::Key, |
|
| 706 | + | }); |
|
| 707 | + | self.chain_to_value = true; |
|
| 708 | + | } |
|
| 709 | + | ||
| 710 | + | // ----- editing ----- |
|
| 711 | + | ||
| 712 | + | pub fn start_edit(&mut self, target: EditTarget) { |
|
| 713 | + | let initial = match target { |
|
| 714 | + | EditTarget::Cell { table, row, col } => { |
|
| 715 | + | self.row_value(table, row, col).unwrap_or_default() |
|
| 716 | + | } |
|
| 717 | + | EditTarget::Rename => self |
|
| 718 | + | .selected_request() |
|
| 719 | + | .map(|r| r.name.clone()) |
|
| 720 | + | .unwrap_or_default(), |
|
| 721 | + | // Prefill the path only, not `base_url() + path`: re-serializing |
|
| 722 | + | // the full URL would rebuild the query from the table and lose each |
|
| 723 | + | // row's `enabled` flag. The origin is visible in the URL bar anyway. |
|
| 724 | + | // A bare `/` (what `SavedRequest::blank` gives a new request) is |
|
| 725 | + | // dropped, so pasting a URL into a fresh request isn't prefixed by it. |
|
| 726 | + | EditTarget::Url => self |
|
| 727 | + | .selected_request() |
|
| 728 | + | .map(|r| r.path.clone()) |
|
| 729 | + | .filter(|p| p != "/") |
|
| 730 | + | .unwrap_or_default(), |
|
| 731 | + | EditTarget::NewRequest | EditTarget::EnvNew => String::new(), |
|
| 732 | + | EditTarget::AuthField(i) => self.auth_field_value(i), |
|
| 733 | + | }; |
|
| 734 | + | self.input.set(&initial); |
|
| 735 | + | self.editing = Some(target); |
|
| 736 | + | self.mode = Mode::Insert; |
|
| 737 | + | } |
|
| 738 | + | ||
| 739 | + | pub fn cancel_edit(&mut self) { |
|
| 740 | + | self.editing = None; |
|
| 741 | + | self.chain_to_value = false; |
|
| 742 | + | self.mode = Mode::Normal; |
|
| 743 | + | } |
|
| 744 | + | ||
| 745 | + | pub fn commit_edit(&mut self) { |
|
| 746 | + | let Some(target) = self.editing.take() else { |
|
| 747 | + | return; |
|
| 748 | + | }; |
|
| 749 | + | let value = self.input.buf.trim().to_string(); |
|
| 750 | + | self.mode = Mode::Normal; |
|
| 751 | + | ||
| 752 | + | match target { |
|
| 753 | + | EditTarget::Cell { table, row, col } => { |
|
| 754 | + | if let Some(r) = self.row_mut(table, row) { |
|
| 755 | + | match col { |
|
| 756 | + | CellCol::Key => r.key = value, |
|
| 757 | + | CellCol::Value => r.value = value, |
|
| 758 | + | } |
|
| 759 | + | self.dirty = true; |
|
| 760 | + | } |
|
| 761 | + | if col == CellCol::Key && self.chain_to_value { |
|
| 762 | + | self.chain_to_value = false; |
|
| 763 | + | self.start_edit(EditTarget::Cell { |
|
| 764 | + | table, |
|
| 765 | + | row, |
|
| 766 | + | col: CellCol::Value, |
|
| 767 | + | }); |
|
| 768 | + | } |
|
| 769 | + | } |
|
| 770 | + | EditTarget::Rename => { |
|
| 771 | + | if !value.is_empty() |
|
| 772 | + | && let Some(i) = self.selected |
|
| 773 | + | { |
|
| 774 | + | self.collection.requests[i].name = value; |
|
| 775 | + | self.dirty = true; |
|
| 776 | + | } |
|
| 777 | + | } |
|
| 778 | + | EditTarget::NewRequest => { |
|
| 779 | + | if !value.is_empty() { |
|
| 780 | + | let req = SavedRequest::blank(value); |
|
| 781 | + | self.collection.requests.push(req); |
|
| 782 | + | self.dirty = true; |
|
| 783 | + | self.rebuild_sidebar(); |
|
| 784 | + | let idx = self.collection.requests.len() - 1; |
|
| 785 | + | // Move sidebar selection to the new request. |
|
| 786 | + | if let Some(pos) = self |
|
| 787 | + | .sidebar_rows |
|
| 788 | + | .iter() |
|
| 789 | + | .position(|r| *r == SidebarRow::Request(idx)) |
|
| 790 | + | { |
|
| 791 | + | self.sidebar_sel = pos; |
|
| 792 | + | } |
|
| 793 | + | self.select_request(idx); |
|
| 794 | + | // `blank` gives you `GET /`, which is sendable but useless; |
|
| 795 | + | // chain straight into the URL so a new request is usable in |
|
| 796 | + | // one flow. |
|
| 797 | + | self.start_edit(EditTarget::Url); |
|
| 798 | + | } |
|
| 799 | + | } |
|
| 800 | + | EditTarget::Url => self.apply_url_input(&value), |
|
| 801 | + | EditTarget::EnvNew => { |
|
| 802 | + | if !value.is_empty() { |
|
| 803 | + | self.collection.servers.push(value); |
|
| 804 | + | self.collection.active_server = self.collection.servers.len() - 1; |
|
| 805 | + | self.env_sel = self.collection.active_server; |
|
| 806 | + | self.dirty = true; |
|
| 807 | + | } |
|
| 808 | + | } |
|
| 809 | + | EditTarget::AuthField(i) => { |
|
| 810 | + | self.set_auth_field(i, &value); |
|
| 811 | + | } |
|
| 812 | + | } |
|
| 813 | + | } |
|
| 814 | + | ||
| 815 | + | // ----- url bar ----- |
|
| 816 | + | ||
| 817 | + | /// Apply a URL-bar entry to the selected request. An absolute URL |
|
| 818 | + | /// contributes its origin to `collection.servers` — added if new, made |
|
| 819 | + | /// active either way — the rest becomes `req.path`, and a query string (only |
|
| 820 | + | /// if the input actually had one) replaces the query rows. |
|
| 821 | + | pub fn apply_url_input(&mut self, input: &str) { |
|
| 822 | + | let Some(i) = self.selected else { |
|
| 823 | + | self.status = "No request selected".into(); |
|
| 824 | + | return; |
|
| 825 | + | }; |
|
| 826 | + | // Something that names a scheme but isn't http(s) is a typo, not a |
|
| 827 | + | // relative path — say so rather than filing it under `path`. The |
|
| 828 | + | // scheme-shape check matters: it keeps a stray `/api/https://…` out of |
|
| 829 | + | // this branch, where the error would be more confusing than the path. |
|
| 830 | + | if let Some((scheme, _)) = input.trim().split_once("://") |
|
| 831 | + | && !scheme.is_empty() |
|
| 832 | + | && scheme |
|
| 833 | + | .chars() |
|
| 834 | + | .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) |
|
| 835 | + | && !matches!(scheme, "http" | "https") |
|
| 836 | + | { |
|
| 837 | + | self.status = format!("Only http(s) URLs are supported (got {scheme:?}://)"); |
|
| 838 | + | return; |
|
| 839 | + | } |
|
| 840 | + | ||
| 841 | + | let parts = split_url_input(input); |
|
| 842 | + | let mut notes: Vec<String> = Vec::new(); |
|
| 843 | + | if let Some(origin) = parts.origin { |
|
| 844 | + | // Compare trimmed: servers added with `E` may carry a trailing |
|
| 845 | + | // slash, imported ones never do. |
|
| 846 | + | match self |
|
| 847 | + | .collection |
|
| 848 | + | .servers |
|
| 849 | + | .iter() |
|
| 850 | + | .position(|s| s.trim_end_matches('/') == origin) |
|
| 851 | + | { |
|
| 852 | + | Some(idx) => { |
|
| 853 | + | if self.collection.active_server != idx { |
|
| 854 | + | self.collection.active_server = idx; |
|
| 855 | + | notes.push(format!("server → {origin}")); |
|
| 856 | + | } |
|
| 857 | + | } |
|
| 858 | + | None => { |
|
| 859 | + | self.collection.servers.push(origin.clone()); |
|
| 860 | + | self.collection.active_server = self.collection.servers.len() - 1; |
|
| 861 | + | notes.push(format!("server + {origin}")); |
|
| 862 | + | } |
|
| 863 | + | } |
|
| 864 | + | } |
|
| 865 | + | ||
| 866 | + | let req = &mut self.collection.requests[i]; |
|
| 867 | + | req.path = parts.path; |
|
| 868 | + | if let Some(query) = parts.query { |
|
| 869 | + | notes.push(format!("{} query param(s)", query.len())); |
|
| 870 | + | req.query = query; |
|
| 871 | + | } |
|
| 872 | + | req.sync_path_params(); |
|
| 873 | + | self.dirty = true; |
|
| 874 | + | self.table_row = 0; |
|
| 875 | + | let path = self.collection.requests[i].path.clone(); |
|
| 876 | + | self.status = if notes.is_empty() { |
|
| 877 | + | format!("Path: {path}") |
|
| 878 | + | } else { |
|
| 879 | + | format!("Path: {path} · {}", notes.join(" · ")) |
|
| 880 | + | }; |
|
| 881 | + | } |
|
| 882 | + | ||
| 883 | + | // ----- duplicating ----- |
|
| 884 | + | ||
| 885 | + | /// Clone the request at `idx` as a starting point: fresh id, name suffixed |
|
| 886 | + | /// `copy` / `copy 2` / …, inserted directly after the original so it lands |
|
| 887 | + | /// beside it in the same sidebar group. Selects the clone. |
|
| 888 | + | pub fn duplicate_request(&mut self, idx: usize) { |
|
| 889 | + | // Must precede the insert: `select_request` commits the textarea into |
|
| 890 | + | // `requests[selected]`, and the indices shift underneath it. |
|
| 891 | + | self.commit_body(); |
|
| 892 | + | let Some(src) = self.collection.requests.get(idx) else { |
|
| 893 | + | return; |
|
| 894 | + | }; |
|
| 895 | + | let mut clone = src.clone(); |
|
| 896 | + | // A fresh id is required, not cosmetic: `saved_view_index` resolves |
|
| 897 | + | // `last_request` with `position(|r| r.id == id)`, so a duplicate id |
|
| 898 | + | // would make reopening the collection ambiguous. |
|
| 899 | + | clone.id = Uuid::new_v4(); |
|
| 900 | + | clone.name = unique_request_name(&self.collection.requests, &src.name); |
|
| 901 | + | let at = idx + 1; |
|
| 902 | + | self.collection.requests.insert(at, clone); |
|
| 903 | + | if let Some(s) = self.selected.filter(|s| *s >= at) { |
|
| 904 | + | self.selected = Some(s + 1); |
|
| 905 | + | } |
|
| 906 | + | self.dirty = true; |
|
| 907 | + | self.rebuild_sidebar(); |
|
| 908 | + | // An active filter may hide the clone, in which case the cursor stays put. |
|
| 909 | + | if let Some(pos) = self |
|
| 910 | + | .sidebar_rows |
|
| 911 | + | .iter() |
|
| 912 | + | .position(|r| *r == SidebarRow::Request(at)) |
|
| 913 | + | { |
|
| 914 | + | self.sidebar_sel = pos; |
|
| 915 | + | } |
|
| 916 | + | self.select_request(at); |
|
| 917 | + | self.status = format!("Duplicated as \"{}\"", self.collection.requests[at].name); |
|
| 918 | + | } |
|
| 919 | + | ||
| 920 | + | // ----- auth popup ----- |
|
| 921 | + | ||
| 922 | + | pub const AUTH_FIELDS: [&'static str; 5] = [ |
|
| 923 | + | "Token URL", |
|
| 924 | + | "Client ID", |
|
| 925 | + | "Client Secret", |
|
| 926 | + | "Scopes (space separated)", |
|
| 927 | + | "Auth style", |
|
| 928 | + | ]; |
|
| 929 | + | ||
| 930 | + | pub fn open_auth_popup(&mut self) { |
|
| 931 | + | self.auth_form = self.collection.auth.clone().unwrap_or_default(); |
|
| 932 | + | self.auth_field = 0; |
|
| 933 | + | self.popup = Popup::Auth; |
|
| 934 | + | } |
|
| 935 | + | ||
| 936 | + | pub fn auth_field_value(&self, i: usize) -> String { |
|
| 937 | + | match i { |
|
| 938 | + | 0 => self.auth_form.token_url.clone(), |
|
| 939 | + | 1 => self.auth_form.client_id.clone(), |
|
| 940 | + | 2 => self.auth_form.client_secret.clone(), |
|
| 941 | + | 3 => self.auth_form.scopes.join(" "), |
|
| 942 | + | 4 => match self.auth_form.auth_style { |
|
| 943 | + | crate::model::AuthStyle::Basic => "basic".into(), |
|
| 944 | + | crate::model::AuthStyle::Post => "post".into(), |
|
| 945 | + | }, |
|
| 946 | + | _ => String::new(), |
|
| 947 | + | } |
|
| 948 | + | } |
|
| 949 | + | ||
| 950 | + | pub fn set_auth_field(&mut self, i: usize, value: &str) { |
|
| 951 | + | match i { |
|
| 952 | + | 0 => self.auth_form.token_url = value.to_string(), |
|
| 953 | + | 1 => self.auth_form.client_id = value.to_string(), |
|
| 954 | + | 2 => self.auth_form.client_secret = value.to_string(), |
|
| 955 | + | 3 => self.auth_form.scopes = value.split_whitespace().map(String::from).collect(), |
|
| 956 | + | _ => {} |
|
| 957 | + | } |
|
| 958 | + | } |
|
| 959 | + | ||
| 960 | + | pub fn toggle_auth_style(&mut self) { |
|
| 961 | + | self.auth_form.auth_style = match self.auth_form.auth_style { |
|
| 962 | + | crate::model::AuthStyle::Basic => crate::model::AuthStyle::Post, |
|
| 963 | + | crate::model::AuthStyle::Post => crate::model::AuthStyle::Basic, |
|
| 964 | + | }; |
|
| 965 | + | } |
|
| 966 | + | ||
| 967 | + | /// Apply the auth form to the collection (called when the popup closes). |
|
| 968 | + | pub fn apply_auth_form(&mut self) { |
|
| 969 | + | let empty = self.auth_form.token_url.is_empty() |
|
| 970 | + | && self.auth_form.client_id.is_empty() |
|
| 971 | + | && self.auth_form.client_secret.is_empty(); |
|
| 972 | + | let new = if empty { |
|
| 973 | + | None |
|
| 974 | + | } else { |
|
| 975 | + | Some(self.auth_form.clone()) |
|
| 976 | + | }; |
|
| 977 | + | if self.collection.auth != new { |
|
| 978 | + | self.collection.auth = new; |
|
| 979 | + | self.dirty = true; |
|
| 980 | + | } |
|
| 981 | + | } |
|
| 982 | + | ||
| 983 | + | // ----- sending ----- |
|
| 984 | + | ||
| 985 | + | pub fn send_selected(&mut self) { |
|
| 986 | + | self.commit_body(); |
|
| 987 | + | let Some(idx) = self.selected else { |
|
| 988 | + | self.status = "No request selected".into(); |
|
| 989 | + | return; |
|
| 990 | + | }; |
|
| 991 | + | let Some(base) = self.collection.base_url().map(String::from) else { |
|
| 992 | + | self.status = "No server configured — press E to add a base URL".into(); |
|
| 993 | + | return; |
|
| 994 | + | }; |
|
| 995 | + | let req = self.collection.requests[idx].clone(); |
|
| 996 | + | let vars = variables_map(&self.collection.variables); |
|
| 997 | + | let auth = self.collection.auth.clone(); |
|
| 998 | + | let token = self.token.take(); |
|
| 999 | + | let client = self.client.clone(); |
|
| 1000 | + | let tx = self.tx.clone(); |
|
| 1001 | + | ||
| 1002 | + | self.sending = true; |
|
| 1003 | + | self.status = format!("Sending {} {} …", req.method, req.path); |
|
| 1004 | + | tokio::spawn(async move { |
|
| 1005 | + | let outcome = send_with_auth(&client, &base, &req, &vars, auth.as_ref(), token).await; |
|
| 1006 | + | let _ = tx.send(outcome); |
|
| 1007 | + | }); |
|
| 1008 | + | } |
|
| 1009 | + | ||
| 1010 | + | pub fn handle_outcome(&mut self, outcome: SendOutcome) { |
|
| 1011 | + | self.sending = false; |
|
| 1012 | + | self.token = outcome.token; |
|
| 1013 | + | match outcome.result { |
|
| 1014 | + | Ok(resp) => { |
|
| 1015 | + | self.status = resp.status_line(); |
|
| 1016 | + | self.response = Some(resp); |
|
| 1017 | + | self.response_scroll = 0; |
|
| 1018 | + | } |
|
| 1019 | + | Err(e) => { |
|
| 1020 | + | self.status = e; |
|
| 1021 | + | } |
|
| 1022 | + | } |
|
| 1023 | + | } |
|
| 1024 | + | ||
| 1025 | + | // ----- persistence / quit ----- |
|
| 1026 | + | ||
| 1027 | + | /// Copy the current view (open request, pane, editor tab) onto the |
|
| 1028 | + | /// collection. Called from [`App::save`] rather than from the navigation |
|
| 1029 | + | /// handlers: marking the collection dirty every time the cursor moves |
|
| 1030 | + | /// would make `:q` complain about unsaved changes after a read-only browse. |
|
| 1031 | + | pub fn record_view(&mut self) { |
|
| 1032 | + | self.collection.last_request = self.selected_request().map(|r| r.id); |
|
| 1033 | + | self.collection.last_focus = Some(self.focus); |
|
| 1034 | + | self.collection.last_tab = Some(self.tab); |
|
| 1035 | + | } |
|
| 1036 | + | ||
| 1037 | + | pub fn save(&mut self) { |
|
| 1038 | + | self.commit_body(); |
|
| 1039 | + | self.record_view(); |
|
| 1040 | + | match store::save_collection(&self.collection) { |
|
| 1041 | + | Ok(path) => { |
|
| 1042 | + | self.dirty = false; |
|
| 1043 | + | self.status = format!("Saved to {}", path.display()); |
|
| 1044 | + | } |
|
| 1045 | + | Err(e) => self.status = format!("Save failed: {e:#}"), |
|
| 1046 | + | } |
|
| 1047 | + | } |
|
| 1048 | + | ||
| 1049 | + | // ----- switching collections ----- |
|
| 1050 | + | ||
| 1051 | + | /// Replace the whole app state with a different collection, keeping the |
|
| 1052 | + | /// process and terminal alive. Everything view-related is derived from the |
|
| 1053 | + | /// collection by [`App::new`], so a wholesale reassign is both the smallest |
|
| 1054 | + | /// and the safest option: it also drops the send channel (so a response |
|
| 1055 | + | /// still in flight for the old collection can't land in the new one) and the |
|
| 1056 | + | /// cached OAuth token, which belonged to the old collection's auth config. |
|
| 1057 | + | /// |
|
| 1058 | + | /// Deliberately does not persist `AppConfig`: staying filesystem-free keeps |
|
| 1059 | + | /// this callable from tests (`store::config_dir` is hard-wired to the real |
|
| 1060 | + | /// home directory). The two callers below write it once they've committed. |
|
| 1061 | + | pub fn switch_collection(&mut self, collection: Collection, path: PathBuf) { |
|
| 1062 | + | let name = collection.name.clone(); |
|
| 1063 | + | let mut config = std::mem::take(&mut self.config); |
|
| 1064 | + | config.last_collection = Some(name.clone()); |
|
| 1065 | + | *self = App::new(collection, path, config); |
|
| 1066 | + | // `App::new` sets its own status; ours is the more useful one here. |
|
| 1067 | + | self.status = format!("Switched to \"{name}\""); |
|
| 1068 | + | } |
|
| 1069 | + | ||
| 1070 | + | /// `:new <name>` — create an empty collection on disk and switch to it. |
|
| 1071 | + | fn new_collection(&mut self, name: &str, force: bool) { |
|
| 1072 | + | if name.is_empty() { |
|
| 1073 | + | self.status = "Usage: :new <collection name>".into(); |
|
| 1074 | + | return; |
|
| 1075 | + | } |
|
| 1076 | + | if self.dirty && !force { |
|
| 1077 | + | self.status = "Unsaved changes — :w first, or :new! to discard".into(); |
|
| 1078 | + | return; |
|
| 1079 | + | } |
|
| 1080 | + | let path = match store::collection_path(name) { |
|
| 1081 | + | Ok(p) => p, |
|
| 1082 | + | Err(e) => { |
|
| 1083 | + | self.status = format!("{e:#}"); |
|
| 1084 | + | return; |
|
| 1085 | + | } |
|
| 1086 | + | }; |
|
| 1087 | + | // Checks the slug path, so a name that collides after slugify is caught. |
|
| 1088 | + | if path.exists() { |
|
| 1089 | + | self.status = format!("A collection already exists at {}", path.display()); |
|
| 1090 | + | return; |
|
| 1091 | + | } |
|
| 1092 | + | let collection = Collection::new(name); |
|
| 1093 | + | match store::save_collection(&collection) { |
|
| 1094 | + | Ok(path) => { |
|
| 1095 | + | self.switch_collection(collection, path); |
|
| 1096 | + | let _ = self.config.save(); |
|
| 1097 | + | } |
|
| 1098 | + | Err(e) => self.status = format!("Could not create {name:?}: {e:#}"), |
|
| 1099 | + | } |
|
| 1100 | + | } |
|
| 1101 | + | ||
| 1102 | + | /// `:open <name>` — switch to another saved collection. |
|
| 1103 | + | fn open_collection(&mut self, name: &str, force: bool) { |
|
| 1104 | + | if name.is_empty() { |
|
| 1105 | + | self.status = "Usage: :open <collection name>".into(); |
|
| 1106 | + | return; |
|
| 1107 | + | } |
|
| 1108 | + | if self.dirty && !force { |
|
| 1109 | + | self.status = "Unsaved changes — :w first, or :open! to discard".into(); |
|
| 1110 | + | return; |
|
| 1111 | + | } |
|
| 1112 | + | let loaded = store::resolve_collection(name) |
|
| 1113 | + | .and_then(|n| Ok((store::load_collection(&n)?, store::collection_path(&n)?))); |
|
| 1114 | + | match loaded { |
|
| 1115 | + | Ok((collection, path)) => { |
|
| 1116 | + | self.switch_collection(collection, path); |
|
| 1117 | + | let _ = self.config.save(); |
|
| 1118 | + | } |
|
| 1119 | + | // `resolve_collection`'s error already lists what is available. |
|
| 1120 | + | Err(e) => self.status = format!("{e:#}").replace('\n', " "), |
|
| 1121 | + | } |
|
| 1122 | + | } |
|
| 1123 | + | ||
| 1124 | + | pub fn try_quit(&mut self) { |
|
| 1125 | + | if self.dirty { |
|
| 1126 | + | self.status = "Unsaved changes — use :q! to discard, :w to save".into(); |
|
| 1127 | + | } else { |
|
| 1128 | + | self.should_quit = true; |
|
| 1129 | + | } |
|
| 1130 | + | } |
|
| 1131 | + | ||
| 1132 | + | pub fn exec_command(&mut self) { |
|
| 1133 | + | let cmd = self.command.trim().to_string(); |
|
| 1134 | + | self.command.clear(); |
|
| 1135 | + | self.mode = Mode::Normal; |
|
| 1136 | + | match cmd.as_str() { |
|
| 1137 | + | "w" => self.save(), |
|
| 1138 | + | "q" => self.try_quit(), |
|
| 1139 | + | "q!" => self.should_quit = true, |
|
| 1140 | + | "wq" => { |
|
| 1141 | + | self.save(); |
|
| 1142 | + | if !self.dirty { |
|
| 1143 | + | self.should_quit = true; |
|
| 1144 | + | } |
|
| 1145 | + | } |
|
| 1146 | + | // Argument-less forms; the command is already trimmed, so these |
|
| 1147 | + | // never reach the `split_once` arms below. |
|
| 1148 | + | "new" | "new!" => self.new_collection("", false), |
|
| 1149 | + | "open" | "open!" => self.open_collection("", false), |
|
| 1150 | + | "" => {} |
|
| 1151 | + | other => match other.split_once(char::is_whitespace) { |
|
| 1152 | + | Some(("new", arg)) => self.new_collection(arg.trim(), false), |
|
| 1153 | + | Some(("new!", arg)) => self.new_collection(arg.trim(), true), |
|
| 1154 | + | Some(("open", arg)) => self.open_collection(arg.trim(), false), |
|
| 1155 | + | Some(("open!", arg)) => self.open_collection(arg.trim(), true), |
|
| 1156 | + | Some(("label", arg)) => self.set_label_mode(arg.trim()), |
|
| 1157 | + | Some(("groups", arg)) => self.set_group_default(arg.trim()), |
|
| 1158 | + | Some(("rename-all", arg)) => self.rename_all(arg.trim()), |
|
| 1159 | + | _ => self.status = format!("Unknown command: {other}"), |
|
| 1160 | + | }, |
|
| 1161 | + | } |
|
| 1162 | + | } |
|
| 1163 | + | ||
| 1164 | + | fn set_label_mode(&mut self, arg: &str) { |
|
| 1165 | + | let mode = match arg { |
|
| 1166 | + | "name" => LabelMode::Name, |
|
| 1167 | + | "summary" => LabelMode::Summary, |
|
| 1168 | + | "path" => LabelMode::Path, |
|
| 1169 | + | other => { |
|
| 1170 | + | self.status = format!("Usage: :label name|summary|path (got {other:?})"); |
|
| 1171 | + | return; |
|
| 1172 | + | } |
|
| 1173 | + | }; |
|
| 1174 | + | self.collection.label_mode = mode; |
|
| 1175 | + | self.dirty = true; |
|
| 1176 | + | self.status = format!("Sidebar labels: {}", mode.title()); |
|
| 1177 | + | } |
|
| 1178 | + | ||
| 1179 | + | /// Set whether groups start collapsed, and apply it to the current view so |
|
| 1180 | + | /// the effect is visible without reopening the collection. |
|
| 1181 | + | fn set_group_default(&mut self, arg: &str) { |
|
| 1182 | + | let collapsed = match arg { |
|
| 1183 | + | "collapsed" => true, |
|
| 1184 | + | "expanded" => false, |
|
| 1185 | + | other => { |
|
| 1186 | + | self.status = format!("Usage: :groups collapsed|expanded (got {other:?})"); |
|
| 1187 | + | return; |
|
| 1188 | + | } |
|
| 1189 | + | }; |
|
| 1190 | + | self.collection.groups_collapsed = collapsed; |
|
| 1191 | + | self.collapsed = if collapsed { |
|
| 1192 | + | self.group_tags() |
|
| 1193 | + | } else { |
|
| 1194 | + | HashSet::new() |
|
| 1195 | + | }; |
|
| 1196 | + | self.rebuild_sidebar(); |
|
| 1197 | + | self.dirty = true; |
|
| 1198 | + | self.status = format!("Groups default: {arg}"); |
|
| 1199 | + | } |
|
| 1200 | + | ||
| 1201 | + | /// Rewrite every request's `name` from a spec-derived field. Unlike |
|
| 1202 | + | /// `:label`, this is destructive — it replaces the stored names. |
|
| 1203 | + | fn rename_all(&mut self, arg: &str) { |
|
| 1204 | + | let mut renamed = 0usize; |
|
| 1205 | + | for req in &mut self.collection.requests { |
|
| 1206 | + | let new = match arg { |
|
| 1207 | + | "summary" => req.summary.clone(), |
|
| 1208 | + | "operation" => req.operation_id.clone(), |
|
| 1209 | + | "path" => Some(req.path.clone()), |
|
| 1210 | + | "method-path" => Some(format!("{} {}", req.method, req.path)), |
|
| 1211 | + | other => { |
|
| 1212 | + | self.status = format!( |
|
| 1213 | + | "Usage: :rename-all summary|operation|path|method-path (got {other:?})" |
|
| 1214 | + | ); |
|
| 1215 | + | return; |
|
| 1216 | + | } |
|
| 1217 | + | }; |
|
| 1218 | + | if let Some(new) = new.filter(|s| !s.is_empty()) |
|
| 1219 | + | && req.name != new |
|
| 1220 | + | { |
|
| 1221 | + | req.name = new; |
|
| 1222 | + | renamed += 1; |
|
| 1223 | + | } |
|
| 1224 | + | } |
|
| 1225 | + | if renamed > 0 { |
|
| 1226 | + | self.dirty = true; |
|
| 1227 | + | } |
|
| 1228 | + | self.status = format!("Renamed {renamed} request(s) from {arg}"); |
|
| 1229 | + | } |
|
| 1230 | + | } |
|
| 1231 | + | ||
| 1232 | + | // ----- run loop ----- |
|
| 1233 | + | ||
| 1234 | + | pub async fn run(collection: Collection, path: PathBuf, config: AppConfig) -> Result<()> { |
|
| 1235 | + | let mut app = App::new(collection, path, config); |
|
| 1236 | + | ||
| 1237 | + | // Restore the terminal even if the TUI panics. |
|
| 1238 | + | let original_hook = std::panic::take_hook(); |
|
| 1239 | + | std::panic::set_hook(Box::new(move |info| { |
|
| 1240 | + | let _ = disable_raw_mode(); |
|
| 1241 | + | let _ = execute!(io::stdout(), LeaveAlternateScreen); |
|
| 1242 | + | original_hook(info); |
|
| 1243 | + | })); |
|
| 1244 | + | ||
| 1245 | + | enable_raw_mode()?; |
|
| 1246 | + | let mut stdout = io::stdout(); |
|
| 1247 | + | execute!(stdout, EnterAlternateScreen)?; |
|
| 1248 | + | let backend = CrosstermBackend::new(stdout); |
|
| 1249 | + | let mut terminal = Terminal::new(backend)?; |
|
| 1250 | + | ||
| 1251 | + | let result = run_loop(&mut app, &mut terminal).await; |
|
| 1252 | + | ||
| 1253 | + | disable_raw_mode()?; |
|
| 1254 | + | execute!(terminal.backend_mut(), LeaveAlternateScreen)?; |
|
| 1255 | + | terminal.show_cursor()?; |
|
| 1256 | + | result |
|
| 1257 | + | } |
|
| 1258 | + | ||
| 1259 | + | async fn run_loop( |
|
| 1260 | + | app: &mut App, |
|
| 1261 | + | terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, |
|
| 1262 | + | ) -> Result<()> { |
|
| 1263 | + | loop { |
|
| 1264 | + | terminal.draw(|f| ui::draw(f, app))?; |
|
| 1265 | + | ||
| 1266 | + | while let Ok(outcome) = app.rx.try_recv() { |
|
| 1267 | + | app.handle_outcome(outcome); |
|
| 1268 | + | } |
|
| 1269 | + | ||
| 1270 | + | if let Some(target) = app.pending_external { |
|
| 1271 | + | run_external_edit(app, terminal, target)?; |
|
| 1272 | + | } |
|
| 1273 | + | ||
| 1274 | + | if event::poll(Duration::from_millis(60))? |
|
| 1275 | + | && let Event::Key(key) = event::read()? |
|
| 1276 | + | && key.kind == KeyEventKind::Press |
|
| 1277 | + | { |
|
| 1278 | + | input::handle_key(app, key); |
|
| 1279 | + | } |
|
| 1280 | + | ||
| 1281 | + | if app.should_quit { |
|
| 1282 | + | return Ok(()); |
|
| 1283 | + | } |
|
| 1284 | + | } |
|
| 1285 | + | } |
|
| 1286 | + | ||
| 1287 | + | /// Open a request or response body in `$EDITOR`: suspend the TUI, edit a temp |
|
| 1288 | + | /// file, resume. Request bodies are read back; responses are view-only. |
|
| 1289 | + | fn run_external_edit( |
|
| 1290 | + | app: &mut App, |
|
| 1291 | + | terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, |
|
| 1292 | + | target: ExternalEdit, |
|
| 1293 | + | ) -> Result<()> { |
|
| 1294 | + | app.pending_external = None; |
|
| 1295 | + | ||
| 1296 | + | let content = match target { |
|
| 1297 | + | ExternalEdit::Body => { |
|
| 1298 | + | app.commit_body(); |
|
| 1299 | + | app.selected_request() |
|
| 1300 | + | .and_then(|r| r.body.clone()) |
|
| 1301 | + | .unwrap_or_default() |
|
| 1302 | + | } |
|
| 1303 | + | ExternalEdit::Response => match app.response.as_ref() { |
|
| 1304 | + | Some(resp) => resp.body.clone(), |
|
| 1305 | + | None => { |
|
| 1306 | + | app.status = "No response to open".into(); |
|
| 1307 | + | return Ok(()); |
|
| 1308 | + | } |
|
| 1309 | + | }, |
|
| 1310 | + | }; |
|
| 1311 | + | ||
| 1312 | + | let stem = match target { |
|
| 1313 | + | ExternalEdit::Body => "body", |
|
| 1314 | + | ExternalEdit::Response => "response", |
|
| 1315 | + | }; |
|
| 1316 | + | let mut tmp = std::env::temp_dir(); |
|
| 1317 | + | tmp.push(format!( |
|
| 1318 | + | "cielago-{stem}-{}.{}", |
|
| 1319 | + | std::process::id(), |
|
| 1320 | + | guess_extension(&content) |
|
| 1321 | + | )); |
|
| 1322 | + | std::fs::write(&tmp, &content)?; |
|
| 1323 | + | ||
| 1324 | + | disable_raw_mode()?; |
|
| 1325 | + | execute!(terminal.backend_mut(), LeaveAlternateScreen)?; |
|
| 1326 | + | ||
| 1327 | + | let editor = app.config.editor_cmd(); |
|
| 1328 | + | let mut parts = editor.split_whitespace(); |
|
| 1329 | + | let program = parts.next().unwrap_or("vi"); |
|
| 1330 | + | let status = Command::new(program).args(parts).arg(&tmp).status(); |
|
| 1331 | + | ||
| 1332 | + | enable_raw_mode()?; |
|
| 1333 | + | execute!(terminal.backend_mut(), EnterAlternateScreen)?; |
|
| 1334 | + | terminal.clear()?; |
|
| 1335 | + | ||
| 1336 | + | match (target, status) { |
|
| 1337 | + | (ExternalEdit::Body, Ok(s)) if s.success() => { |
|
| 1338 | + | let content = std::fs::read_to_string(&tmp)?; |
|
| 1339 | + | if let Some(i) = app.selected { |
|
| 1340 | + | app.collection.requests[i].body = Some(content.clone()); |
|
| 1341 | + | app.dirty = true; |
|
| 1342 | + | } |
|
| 1343 | + | app.set_textarea_text(&content); |
|
| 1344 | + | app.status = "Body updated from editor".into(); |
|
| 1345 | + | } |
|
| 1346 | + | (ExternalEdit::Body, Ok(s)) => { |
|
| 1347 | + | app.status = format!("Editor exited with {s}; body unchanged") |
|
| 1348 | + | } |
|
| 1349 | + | // Nothing is read back: the response stays exactly as received. |
|
| 1350 | + | (ExternalEdit::Response, Ok(_)) => app.status = "Response closed — unchanged".into(), |
|
| 1351 | + | (_, Err(e)) => app.status = format!("Could not launch editor: {e}"), |
|
| 1352 | + | } |
|
| 1353 | + | let _ = std::fs::remove_file(&tmp); |
|
| 1354 | + | Ok(()) |
|
| 1355 | + | } |
|
| 1356 | + | ||
| 1357 | + | /// Extension for the temp file, so the editor picks sane syntax highlighting. |
|
| 1358 | + | fn guess_extension(content: &str) -> &'static str { |
|
| 1359 | + | match content.trim_start().chars().next() { |
|
| 1360 | + | Some('{') | Some('[') => "json", |
|
| 1361 | + | Some('<') => "xml", |
|
| 1362 | + | _ => "txt", |
|
| 1363 | + | } |
|
| 1364 | + | } |
| 1 | + | //! Syntax highlighting for the response and body views. |
|
| 2 | + | //! |
|
| 3 | + | //! A hand-rolled tokenizer rather than a syntax-definition crate: cielago only |
|
| 4 | + | //! ever shows JSON, the occasional XML/HTML error page, and plain text, and |
|
| 5 | + | //! `syntect`-class dependencies dwarf the rest of the binary. |
|
| 6 | + | //! |
|
| 7 | + | //! Highlighting is line-oriented — every token type here (JSON strings |
|
| 8 | + | //! included, since they cannot contain a raw newline) starts and ends on one |
|
| 9 | + | //! line — so a line can be rendered without scanning the ones before it. |
|
| 10 | + | ||
| 11 | + | use ratatui::style::{Color, Modifier, Style}; |
|
| 12 | + | use ratatui::text::{Line, Span}; |
|
| 13 | + | ||
| 14 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
|
| 15 | + | pub enum Syntax { |
|
| 16 | + | Json, |
|
| 17 | + | Xml, |
|
| 18 | + | Plain, |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | /// Guess the syntax from the first non-blank character, the same way |
|
| 22 | + | /// [`crate::app`] picks a temp-file extension for `$EDITOR`. |
|
| 23 | + | pub fn detect(text: &str) -> Syntax { |
|
| 24 | + | match text.trim_start().chars().next() { |
|
| 25 | + | Some('{') | Some('[') => Syntax::Json, |
|
| 26 | + | Some('<') => Syntax::Xml, |
|
| 27 | + | _ => Syntax::Plain, |
|
| 28 | + | } |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | const KEY: Color = Color::Cyan; |
|
| 32 | + | const STRING: Color = Color::Green; |
|
| 33 | + | const NUMBER: Color = Color::Yellow; |
|
| 34 | + | const LITERAL: Color = Color::Magenta; |
|
| 35 | + | const PUNCT: Color = Color::DarkGray; |
|
| 36 | + | const TAG: Color = Color::Blue; |
|
| 37 | + | ||
| 38 | + | /// Highlight `text`, one [`Line`] per input line. |
|
| 39 | + | /// |
|
| 40 | + | /// `marks_vars` additionally paints `{{variable}}` placeholders — wanted in the |
|
| 41 | + | /// request body, where they are live template syntax, but not in a response, |
|
| 42 | + | /// where the same braces are just bytes the server sent. |
|
| 43 | + | pub fn highlight(text: &str, marks_vars: bool) -> Vec<Line<'static>> { |
|
| 44 | + | let syntax = detect(text); |
|
| 45 | + | text.split('\n') |
|
| 46 | + | .map(|line| match syntax { |
|
| 47 | + | Syntax::Json => json_line(line, marks_vars), |
|
| 48 | + | Syntax::Xml => xml_line(line, marks_vars), |
|
| 49 | + | Syntax::Plain => { |
|
| 50 | + | let mut spans = Vec::new(); |
|
| 51 | + | push_text(&mut spans, line, Style::default(), marks_vars); |
|
| 52 | + | Line::from(spans) |
|
| 53 | + | } |
|
| 54 | + | }) |
|
| 55 | + | .collect() |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | // ----- JSON ----- |
|
| 59 | + | ||
| 60 | + | fn json_line(line: &str, marks_vars: bool) -> Line<'static> { |
|
| 61 | + | let chars: Vec<char> = line.chars().collect(); |
|
| 62 | + | let mut spans: Vec<Span<'static>> = Vec::new(); |
|
| 63 | + | let mut i = 0; |
|
| 64 | + | ||
| 65 | + | while i < chars.len() { |
|
| 66 | + | let c = chars[i]; |
|
| 67 | + | match c { |
|
| 68 | + | '"' => { |
|
| 69 | + | let start = i; |
|
| 70 | + | i = end_of_string(&chars, i); |
|
| 71 | + | // A string followed by `:` is an object key. |
|
| 72 | + | let is_key = chars[i..] |
|
| 73 | + | .iter() |
|
| 74 | + | .find(|c| !c.is_whitespace()) |
|
| 75 | + | .is_some_and(|c| *c == ':'); |
|
| 76 | + | let color = if is_key { KEY } else { STRING }; |
|
| 77 | + | let text: String = chars[start..i].iter().collect(); |
|
| 78 | + | push_text(&mut spans, &text, Style::default().fg(color), marks_vars); |
|
| 79 | + | } |
|
| 80 | + | '-' | '0'..='9' => { |
|
| 81 | + | let start = i; |
|
| 82 | + | while i < chars.len() && is_number_char(chars[i]) { |
|
| 83 | + | i += 1; |
|
| 84 | + | } |
|
| 85 | + | spans.push(span(&chars[start..i], NUMBER)); |
|
| 86 | + | } |
|
| 87 | + | c if c.is_ascii_alphabetic() => { |
|
| 88 | + | let start = i; |
|
| 89 | + | while i < chars.len() && chars[i].is_ascii_alphanumeric() { |
|
| 90 | + | i += 1; |
|
| 91 | + | } |
|
| 92 | + | let word: String = chars[start..i].iter().collect(); |
|
| 93 | + | let color = match word.as_str() { |
|
| 94 | + | "true" | "false" | "null" => LITERAL, |
|
| 95 | + | _ => Color::Reset, |
|
| 96 | + | }; |
|
| 97 | + | spans.push(Span::styled(word, Style::default().fg(color))); |
|
| 98 | + | } |
|
| 99 | + | '{' | '}' | '[' | ']' | ',' | ':' => { |
|
| 100 | + | let start = i; |
|
| 101 | + | i += 1; |
|
| 102 | + | spans.push(span(&chars[start..i], PUNCT)); |
|
| 103 | + | } |
|
| 104 | + | _ => { |
|
| 105 | + | let start = i; |
|
| 106 | + | i += 1; |
|
| 107 | + | spans.push(Span::raw(chars[start..i].iter().collect::<String>())); |
|
| 108 | + | } |
|
| 109 | + | } |
|
| 110 | + | } |
|
| 111 | + | Line::from(spans) |
|
| 112 | + | } |
|
| 113 | + | ||
| 114 | + | /// Index just past the closing quote of the string starting at `i`, or the end |
|
| 115 | + | /// of the line for an unterminated one. |
|
| 116 | + | fn end_of_string(chars: &[char], i: usize) -> usize { |
|
| 117 | + | let mut i = i + 1; |
|
| 118 | + | while i < chars.len() { |
|
| 119 | + | match chars[i] { |
|
| 120 | + | '\\' => i += 2, |
|
| 121 | + | '"' => return (i + 1).min(chars.len()), |
|
| 122 | + | _ => i += 1, |
|
| 123 | + | } |
|
| 124 | + | } |
|
| 125 | + | chars.len() |
|
| 126 | + | } |
|
| 127 | + | ||
| 128 | + | fn is_number_char(c: char) -> bool { |
|
| 129 | + | c.is_ascii_digit() || matches!(c, '-' | '+' | '.' | 'e' | 'E') |
|
| 130 | + | } |
|
| 131 | + | ||
| 132 | + | // ----- XML / HTML ----- |
|
| 133 | + | ||
| 134 | + | /// Markup is coloured structurally: everything between `<` and `>` is a tag, |
|
| 135 | + | /// with its name, attribute names and quoted values distinguished; anything |
|
| 136 | + | /// else is text. |
|
| 137 | + | fn xml_line(line: &str, marks_vars: bool) -> Line<'static> { |
|
| 138 | + | let chars: Vec<char> = line.chars().collect(); |
|
| 139 | + | let mut spans: Vec<Span<'static>> = Vec::new(); |
|
| 140 | + | let mut i = 0; |
|
| 141 | + | ||
| 142 | + | while i < chars.len() { |
|
| 143 | + | if chars[i] != '<' { |
|
| 144 | + | let start = i; |
|
| 145 | + | while i < chars.len() && chars[i] != '<' { |
|
| 146 | + | i += 1; |
|
| 147 | + | } |
|
| 148 | + | let text: String = chars[start..i].iter().collect(); |
|
| 149 | + | push_text(&mut spans, &text, Style::default(), marks_vars); |
|
| 150 | + | continue; |
|
| 151 | + | } |
|
| 152 | + | ||
| 153 | + | // `<` … `>`: opening punctuation plus the tag name, then attributes. |
|
| 154 | + | let start = i; |
|
| 155 | + | i += 1; |
|
| 156 | + | while i < chars.len() && matches!(chars[i], '/' | '!' | '?') { |
|
| 157 | + | i += 1; |
|
| 158 | + | } |
|
| 159 | + | while i < chars.len() && !chars[i].is_whitespace() && !matches!(chars[i], '>' | '/') { |
|
| 160 | + | i += 1; |
|
| 161 | + | } |
|
| 162 | + | spans.push(span(&chars[start..i], TAG)); |
|
| 163 | + | ||
| 164 | + | while i < chars.len() && chars[i] != '>' { |
|
| 165 | + | match chars[i] { |
|
| 166 | + | '"' | '\'' => { |
|
| 167 | + | let quote = chars[i]; |
|
| 168 | + | let start = i; |
|
| 169 | + | i += 1; |
|
| 170 | + | while i < chars.len() && chars[i] != quote { |
|
| 171 | + | i += 1; |
|
| 172 | + | } |
|
| 173 | + | i = (i + 1).min(chars.len()); |
|
| 174 | + | let text: String = chars[start..i].iter().collect(); |
|
| 175 | + | push_text(&mut spans, &text, Style::default().fg(STRING), marks_vars); |
|
| 176 | + | } |
|
| 177 | + | c if c.is_whitespace() || c == '=' || c == '/' => { |
|
| 178 | + | let start = i; |
|
| 179 | + | i += 1; |
|
| 180 | + | spans.push(span(&chars[start..i], PUNCT)); |
|
| 181 | + | } |
|
| 182 | + | _ => { |
|
| 183 | + | let start = i; |
|
| 184 | + | while i < chars.len() |
|
| 185 | + | && !chars[i].is_whitespace() |
|
| 186 | + | && !"=>/\"'".contains(chars[i]) |
|
| 187 | + | { |
|
| 188 | + | i += 1; |
|
| 189 | + | } |
|
| 190 | + | spans.push(span(&chars[start..i], KEY)); |
|
| 191 | + | } |
|
| 192 | + | } |
|
| 193 | + | } |
|
| 194 | + | if i < chars.len() { |
|
| 195 | + | spans.push(span(&chars[i..i + 1], TAG)); |
|
| 196 | + | i += 1; |
|
| 197 | + | } |
|
| 198 | + | } |
|
| 199 | + | Line::from(spans) |
|
| 200 | + | } |
|
| 201 | + | ||
| 202 | + | // ----- shared ----- |
|
| 203 | + | ||
| 204 | + | fn span(chars: &[char], color: Color) -> Span<'static> { |
|
| 205 | + | Span::styled(chars.iter().collect::<String>(), Style::default().fg(color)) |
|
| 206 | + | } |
|
| 207 | + | ||
| 208 | + | /// Push `text` in `style`, breaking out `{{variable}}` placeholders when |
|
| 209 | + | /// `marks_vars` is set so template syntax stands out from literal content. |
|
| 210 | + | fn push_text(spans: &mut Vec<Span<'static>>, text: &str, style: Style, marks_vars: bool) { |
|
| 211 | + | if text.is_empty() { |
|
| 212 | + | return; |
|
| 213 | + | } |
|
| 214 | + | if !marks_vars { |
|
| 215 | + | spans.push(Span::styled(text.to_string(), style)); |
|
| 216 | + | return; |
|
| 217 | + | } |
|
| 218 | + | let var_style = Style::default() |
|
| 219 | + | .fg(LITERAL) |
|
| 220 | + | .add_modifier(Modifier::BOLD | Modifier::ITALIC); |
|
| 221 | + | let mut rest = text; |
|
| 222 | + | while let Some(start) = rest.find("{{") { |
|
| 223 | + | let Some(end) = rest[start + 2..].find("}}") else { |
|
| 224 | + | break; |
|
| 225 | + | }; |
|
| 226 | + | if start > 0 { |
|
| 227 | + | spans.push(Span::styled(rest[..start].to_string(), style)); |
|
| 228 | + | } |
|
| 229 | + | let stop = start + 2 + end + 2; |
|
| 230 | + | spans.push(Span::styled(rest[start..stop].to_string(), var_style)); |
|
| 231 | + | rest = &rest[stop..]; |
|
| 232 | + | } |
|
| 233 | + | if !rest.is_empty() { |
|
| 234 | + | spans.push(Span::styled(rest.to_string(), style)); |
|
| 235 | + | } |
|
| 236 | + | } |
|
| 237 | + | ||
| 238 | + | #[cfg(test)] |
|
| 239 | + | mod tests { |
|
| 240 | + | use super::*; |
|
| 241 | + | ||
| 242 | + | /// (text, fg colour) pairs, for asserting on what a line renders as. |
|
| 243 | + | fn tokens(line: &Line<'static>) -> Vec<(String, Option<Color>)> { |
|
| 244 | + | line.spans |
|
| 245 | + | .iter() |
|
| 246 | + | .map(|s| (s.content.to_string(), s.style.fg)) |
|
| 247 | + | .collect() |
|
| 248 | + | } |
|
| 249 | + | ||
| 250 | + | fn colored(line: &Line<'static>, text: &str) -> Option<Color> { |
|
| 251 | + | tokens(line) |
|
| 252 | + | .into_iter() |
|
| 253 | + | .find(|(t, _)| t == text) |
|
| 254 | + | .and_then(|(_, c)| c) |
|
| 255 | + | } |
|
| 256 | + | ||
| 257 | + | #[test] |
|
| 258 | + | fn detects_syntax_from_first_char() { |
|
| 259 | + | assert_eq!(detect(" {\"a\": 1}"), Syntax::Json); |
|
| 260 | + | assert_eq!(detect("[1]"), Syntax::Json); |
|
| 261 | + | assert_eq!(detect("<html>"), Syntax::Xml); |
|
| 262 | + | assert_eq!(detect("plain words"), Syntax::Plain); |
|
| 263 | + | assert_eq!(detect(""), Syntax::Plain); |
|
| 264 | + | } |
|
| 265 | + | ||
| 266 | + | #[test] |
|
| 267 | + | fn json_keys_and_values_differ() { |
|
| 268 | + | let lines = highlight( |
|
| 269 | + | "{\n \"name\": \"ada\",\n \"n\": -1.5e3,\n \"ok\": true\n}", |
|
| 270 | + | false, |
|
| 271 | + | ); |
|
| 272 | + | assert_eq!(lines.len(), 5); |
|
| 273 | + | assert_eq!(colored(&lines[1], "\"name\""), Some(KEY)); |
|
| 274 | + | assert_eq!(colored(&lines[1], "\"ada\""), Some(STRING)); |
|
| 275 | + | assert_eq!(colored(&lines[2], "-1.5e3"), Some(NUMBER)); |
|
| 276 | + | assert_eq!(colored(&lines[3], "true"), Some(LITERAL)); |
|
| 277 | + | assert_eq!(colored(&lines[0], "{"), Some(PUNCT)); |
|
| 278 | + | } |
|
| 279 | + | ||
| 280 | + | #[test] |
|
| 281 | + | fn json_strings_keep_escapes_and_colons_inside() { |
|
| 282 | + | let lines = highlight("{\"a\": \"x\\\": y\"}", false); |
|
| 283 | + | assert_eq!(colored(&lines[0], "\"a\""), Some(KEY)); |
|
| 284 | + | // The escaped quote must not end the string early, and the `:` inside |
|
| 285 | + | // it must not promote the value to a key. |
|
| 286 | + | assert_eq!(colored(&lines[0], "\"x\\\": y\""), Some(STRING)); |
|
| 287 | + | } |
|
| 288 | + | ||
| 289 | + | #[test] |
|
| 290 | + | fn unterminated_json_string_does_not_panic() { |
|
| 291 | + | let lines = highlight("{\"a\": \"oops", false); |
|
| 292 | + | assert_eq!(colored(&lines[0], "\"oops"), Some(STRING)); |
|
| 293 | + | } |
|
| 294 | + | ||
| 295 | + | #[test] |
|
| 296 | + | fn every_character_survives_highlighting() { |
|
| 297 | + | for text in [ |
|
| 298 | + | "{\"a\": [1, 2, {\"b\": null}], \"c\": \"é☃\"}", |
|
| 299 | + | "<a href=\"/x\">hi & bye</a>", |
|
| 300 | + | "not markup at all", |
|
| 301 | + | ] { |
|
| 302 | + | let rendered: String = highlight(text, true) |
|
| 303 | + | .iter() |
|
| 304 | + | .map(|l| { |
|
| 305 | + | l.spans |
|
| 306 | + | .iter() |
|
| 307 | + | .map(|s| s.content.as_ref()) |
|
| 308 | + | .collect::<String>() |
|
| 309 | + | }) |
|
| 310 | + | .collect::<Vec<_>>() |
|
| 311 | + | .join("\n"); |
|
| 312 | + | assert_eq!(rendered, text); |
|
| 313 | + | } |
|
| 314 | + | } |
|
| 315 | + | ||
| 316 | + | #[test] |
|
| 317 | + | fn xml_tags_attributes_and_text() { |
|
| 318 | + | let lines = highlight("<a href=\"/x\">hi</a>", false); |
|
| 319 | + | assert_eq!(colored(&lines[0], "<a"), Some(TAG)); |
|
| 320 | + | assert_eq!(colored(&lines[0], "href"), Some(KEY)); |
|
| 321 | + | assert_eq!(colored(&lines[0], "\"/x\""), Some(STRING)); |
|
| 322 | + | assert_eq!(colored(&lines[0], "hi"), None); |
|
| 323 | + | assert_eq!(colored(&lines[0], "</a"), Some(TAG)); |
|
| 324 | + | } |
|
| 325 | + | ||
| 326 | + | #[test] |
|
| 327 | + | fn variables_are_marked_only_when_asked() { |
|
| 328 | + | let body = "{\"id\": \"{{uuid}}\"}"; |
|
| 329 | + | let marked = highlight(body, true); |
|
| 330 | + | assert_eq!(colored(&marked[0], "{{uuid}}"), Some(LITERAL)); |
|
| 331 | + | assert_eq!(colored(&marked[0], "\""), Some(STRING)); |
|
| 332 | + | ||
| 333 | + | let plain = highlight(body, false); |
|
| 334 | + | assert_eq!(colored(&plain[0], "\"{{uuid}}\""), Some(STRING)); |
|
| 335 | + | } |
|
| 336 | + | ||
| 337 | + | #[test] |
|
| 338 | + | fn unclosed_variable_is_left_alone() { |
|
| 339 | + | let lines = highlight("value {{oops", true); |
|
| 340 | + | assert_eq!(tokens(&lines[0]).len(), 1); |
|
| 341 | + | } |
|
| 342 | + | } |
| 1 | + | //! Building and sending a [`SavedRequest`], and capturing the response. |
|
| 2 | + | ||
| 3 | + | use std::collections::HashMap; |
|
| 4 | + | use std::time::{Duration, Instant}; |
|
| 5 | + | ||
| 6 | + | use anyhow::{Context, Result, anyhow}; |
|
| 7 | + | use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}; |
|
| 8 | + | ||
| 9 | + | use super::vars::substitute; |
|
| 10 | + | use crate::model::{Method, SavedRequest}; |
|
| 11 | + | ||
| 12 | + | #[derive(Debug, Clone)] |
|
| 13 | + | pub struct HttpResponse { |
|
| 14 | + | pub status: u16, |
|
| 15 | + | pub reason: String, |
|
| 16 | + | pub elapsed: Duration, |
|
| 17 | + | pub headers: Vec<(String, String)>, |
|
| 18 | + | pub body: String, |
|
| 19 | + | pub size: usize, |
|
| 20 | + | } |
|
| 21 | + | ||
| 22 | + | impl HttpResponse { |
|
| 23 | + | pub fn status_line(&self) -> String { |
|
| 24 | + | let ms = self.elapsed.as_millis(); |
|
| 25 | + | format!( |
|
| 26 | + | "{} {} · {}ms · {}", |
|
| 27 | + | self.status, |
|
| 28 | + | self.reason, |
|
| 29 | + | ms, |
|
| 30 | + | human_size(self.size) |
|
| 31 | + | ) |
|
| 32 | + | } |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | fn human_size(n: usize) -> String { |
|
| 36 | + | if n < 1024 { |
|
| 37 | + | format!("{n}B") |
|
| 38 | + | } else if n < 1024 * 1024 { |
|
| 39 | + | format!("{:.1}kB", n as f64 / 1024.0) |
|
| 40 | + | } else { |
|
| 41 | + | format!("{:.1}MB", n as f64 / 1024.0 / 1024.0) |
|
| 42 | + | } |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | /// Send a request against `base_url`, applying `{{variable}}` substitution and |
|
| 46 | + | /// `{pathParam}` replacement. `bearer`, when present, sets the Authorization |
|
| 47 | + | /// header unless the request already defines one. |
|
| 48 | + | pub async fn send_request( |
|
| 49 | + | client: &reqwest::Client, |
|
| 50 | + | base_url: &str, |
|
| 51 | + | req: &SavedRequest, |
|
| 52 | + | vars: &HashMap<String, String>, |
|
| 53 | + | bearer: Option<&str>, |
|
| 54 | + | ) -> Result<HttpResponse> { |
|
| 55 | + | let url = build_url(base_url, req, vars); |
|
| 56 | + | ||
| 57 | + | let mut headers = HeaderMap::new(); |
|
| 58 | + | let mut has_auth = false; |
|
| 59 | + | let mut has_content_type = false; |
|
| 60 | + | for row in req |
|
| 61 | + | .headers |
|
| 62 | + | .iter() |
|
| 63 | + | .filter(|r| r.enabled && !r.key.is_empty()) |
|
| 64 | + | { |
|
| 65 | + | let name = HeaderName::from_bytes(substitute(&row.key, vars).as_bytes()) |
|
| 66 | + | .map_err(|e| anyhow!("invalid header name {:?}: {e}", row.key))?; |
|
| 67 | + | let value = HeaderValue::from_str(&substitute(&row.value, vars)) |
|
| 68 | + | .map_err(|e| anyhow!("invalid value for header {:?}: {e}", row.key))?; |
|
| 69 | + | if name == AUTHORIZATION { |
|
| 70 | + | has_auth = true; |
|
| 71 | + | } |
|
| 72 | + | if name == CONTENT_TYPE { |
|
| 73 | + | has_content_type = true; |
|
| 74 | + | } |
|
| 75 | + | headers.insert(name, value); |
|
| 76 | + | } |
|
| 77 | + | ||
| 78 | + | let method = match req.method { |
|
| 79 | + | Method::Get => reqwest::Method::GET, |
|
| 80 | + | Method::Post => reqwest::Method::POST, |
|
| 81 | + | Method::Put => reqwest::Method::PUT, |
|
| 82 | + | Method::Patch => reqwest::Method::PATCH, |
|
| 83 | + | Method::Delete => reqwest::Method::DELETE, |
|
| 84 | + | Method::Head => reqwest::Method::HEAD, |
|
| 85 | + | Method::Options => reqwest::Method::OPTIONS, |
|
| 86 | + | }; |
|
| 87 | + | ||
| 88 | + | let query: Vec<(String, String)> = req |
|
| 89 | + | .query |
|
| 90 | + | .iter() |
|
| 91 | + | .filter(|r| r.enabled && !r.key.is_empty()) |
|
| 92 | + | .map(|r| (substitute(&r.key, vars), substitute(&r.value, vars))) |
|
| 93 | + | .collect(); |
|
| 94 | + | ||
| 95 | + | let mut rb = client.request(method, &url).headers(headers).query(&query); |
|
| 96 | + | ||
| 97 | + | if let Some(token) = bearer.filter(|_| !has_auth) { |
|
| 98 | + | rb = rb.bearer_auth(token); |
|
| 99 | + | } |
|
| 100 | + | ||
| 101 | + | if let Some(body) = req.body.as_ref().filter(|b| !b.trim().is_empty()) { |
|
| 102 | + | rb = rb.body(substitute(body, vars)); |
|
| 103 | + | if !has_content_type { |
|
| 104 | + | rb = rb.header(CONTENT_TYPE, "application/json"); |
|
| 105 | + | } |
|
| 106 | + | } |
|
| 107 | + | ||
| 108 | + | let start = Instant::now(); |
|
| 109 | + | let resp = rb.send().await.context("request failed")?; |
|
| 110 | + | let elapsed = start.elapsed(); |
|
| 111 | + | ||
| 112 | + | let status = resp.status(); |
|
| 113 | + | let reason = status.canonical_reason().unwrap_or("").to_string(); |
|
| 114 | + | let is_json = resp |
|
| 115 | + | .headers() |
|
| 116 | + | .get(CONTENT_TYPE) |
|
| 117 | + | .and_then(|v| v.to_str().ok()) |
|
| 118 | + | .map(|ct| ct.contains("json")) |
|
| 119 | + | .unwrap_or(false); |
|
| 120 | + | let resp_headers: Vec<(String, String)> = resp |
|
| 121 | + | .headers() |
|
| 122 | + | .iter() |
|
| 123 | + | .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) |
|
| 124 | + | .collect(); |
|
| 125 | + | let bytes = resp.bytes().await.context("reading response body")?; |
|
| 126 | + | let size = bytes.len(); |
|
| 127 | + | let raw = String::from_utf8_lossy(&bytes).into_owned(); |
|
| 128 | + | let body = if is_json { |
|
| 129 | + | serde_json::from_str::<serde_json::Value>(&raw) |
|
| 130 | + | .and_then(|v| serde_json::to_string_pretty(&v)) |
|
| 131 | + | .unwrap_or(raw) |
|
| 132 | + | } else { |
|
| 133 | + | raw |
|
| 134 | + | }; |
|
| 135 | + | ||
| 136 | + | Ok(HttpResponse { |
|
| 137 | + | status: status.as_u16(), |
|
| 138 | + | reason, |
|
| 139 | + | elapsed, |
|
| 140 | + | headers: resp_headers, |
|
| 141 | + | body, |
|
| 142 | + | size, |
|
| 143 | + | }) |
|
| 144 | + | } |
|
| 145 | + | ||
| 146 | + | /// Build the final URL: base + path with `{{vars}}` and `{pathParams}` applied. |
|
| 147 | + | pub fn build_url(base_url: &str, req: &SavedRequest, vars: &HashMap<String, String>) -> String { |
|
| 148 | + | let mut path = substitute(&req.path, vars); |
|
| 149 | + | for row in req.path_params.iter().filter(|r| r.enabled) { |
|
| 150 | + | let value = encode_path_segment(&substitute(&row.value, vars)); |
|
| 151 | + | path = path.replace(&format!("{{{}}}", row.key), &value); |
|
| 152 | + | } |
|
| 153 | + | format!( |
|
| 154 | + | "{}/{}", |
|
| 155 | + | base_url.trim_end_matches('/'), |
|
| 156 | + | path.trim_start_matches('/') |
|
| 157 | + | ) |
|
| 158 | + | } |
|
| 159 | + | ||
| 160 | + | /// Percent-encode a path parameter value (unreserved chars kept as-is). |
|
| 161 | + | fn encode_path_segment(s: &str) -> String { |
|
| 162 | + | let mut out = String::with_capacity(s.len()); |
|
| 163 | + | for b in s.bytes() { |
|
| 164 | + | match b { |
|
| 165 | + | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { |
|
| 166 | + | out.push(b as char) |
|
| 167 | + | } |
|
| 168 | + | _ => out.push_str(&format!("%{b:02X}")), |
|
| 169 | + | } |
|
| 170 | + | } |
|
| 171 | + | out |
|
| 172 | + | } |
| 1 | + | pub mod client; |
|
| 2 | + | pub mod oauth; |
|
| 3 | + | pub mod send; |
|
| 4 | + | pub mod url_input; |
|
| 5 | + | pub mod vars; |
|
| 6 | + | ||
| 7 | + | pub use client::{HttpResponse, send_request}; |
|
| 8 | + | pub use oauth::{OAuthToken, fetch_token, token_valid}; |
|
| 9 | + | pub use send::{SendOutcome, send_with_auth}; |
|
| 10 | + | pub use url_input::{UrlParts, split_url_input}; |
|
| 11 | + | pub use vars::{DYNAMIC_VARS, substitute}; |
| 1 | + | //! OAuth 2.0 client-credentials flow (RFC 6749 §4.4). |
|
| 2 | + | ||
| 3 | + | use std::time::{Duration, Instant}; |
|
| 4 | + | ||
| 5 | + | use anyhow::{Context, Result, anyhow, bail}; |
|
| 6 | + | use serde_json::Value; |
|
| 7 | + | ||
| 8 | + | use crate::model::{AuthStyle, OAuthConfig}; |
|
| 9 | + | ||
| 10 | + | /// Clock skew so tokens are refreshed slightly before their stated expiry. |
|
| 11 | + | const EXPIRY_SKEW_SECS: u64 = 30; |
|
| 12 | + | ||
| 13 | + | #[derive(Debug, Clone)] |
|
| 14 | + | pub struct OAuthToken { |
|
| 15 | + | pub access_token: String, |
|
| 16 | + | pub expires_at: Instant, |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | pub fn token_valid(token: &OAuthToken) -> bool { |
|
| 20 | + | Instant::now() < token.expires_at |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | /// Request a new access token using the client-credentials grant. |
|
| 24 | + | pub async fn fetch_token(client: &reqwest::Client, cfg: &OAuthConfig) -> Result<OAuthToken> { |
|
| 25 | + | if cfg.token_url.is_empty() { |
|
| 26 | + | bail!("OAuth token URL is not configured (press A to configure auth)"); |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | let mut form: Vec<(&str, String)> = vec![("grant_type", "client_credentials".into())]; |
|
| 30 | + | if !cfg.scopes.is_empty() { |
|
| 31 | + | form.push(("scope", cfg.scopes.join(" "))); |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | let mut rb = client.post(&cfg.token_url); |
|
| 35 | + | match cfg.auth_style { |
|
| 36 | + | AuthStyle::Basic => { |
|
| 37 | + | rb = rb.basic_auth(cfg.client_id.clone(), Some(cfg.client_secret.clone())); |
|
| 38 | + | } |
|
| 39 | + | AuthStyle::Post => { |
|
| 40 | + | form.push(("client_id", cfg.client_id.clone())); |
|
| 41 | + | form.push(("client_secret", cfg.client_secret.clone())); |
|
| 42 | + | } |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | let resp = rb |
|
| 46 | + | .form(&form) |
|
| 47 | + | .send() |
|
| 48 | + | .await |
|
| 49 | + | .context("token request failed")?; |
|
| 50 | + | let status = resp.status(); |
|
| 51 | + | let text = resp.text().await.context("reading token response")?; |
|
| 52 | + | if !status.is_success() { |
|
| 53 | + | bail!("token request returned {status}: {text}"); |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | let v: Value = serde_json::from_str(&text).context("token response is not JSON")?; |
|
| 57 | + | let access_token = v |
|
| 58 | + | .get("access_token") |
|
| 59 | + | .and_then(Value::as_str) |
|
| 60 | + | .ok_or_else(|| anyhow!("token response missing access_token"))? |
|
| 61 | + | .to_string(); |
|
| 62 | + | let expires_in = v.get("expires_in").and_then(Value::as_u64).unwrap_or(3600); |
|
| 63 | + | ||
| 64 | + | Ok(OAuthToken { |
|
| 65 | + | access_token, |
|
| 66 | + | expires_at: Instant::now() |
|
| 67 | + | + Duration::from_secs(expires_in.saturating_sub(EXPIRY_SKEW_SECS)), |
|
| 68 | + | }) |
|
| 69 | + | } |
| 1 | + | //! App-level send orchestration: OAuth token caching + one 401 retry. |
|
| 2 | + | ||
| 3 | + | use std::collections::HashMap; |
|
| 4 | + | ||
| 5 | + | use super::client::{HttpResponse, send_request}; |
|
| 6 | + | use super::oauth::{OAuthToken, fetch_token, token_valid}; |
|
| 7 | + | use crate::model::{OAuthConfig, SavedRequest}; |
|
| 8 | + | ||
| 9 | + | pub struct SendOutcome { |
|
| 10 | + | pub result: Result<HttpResponse, String>, |
|
| 11 | + | /// Latest token cache (unchanged on failure, refreshed on (re)fetch). |
|
| 12 | + | pub token: Option<OAuthToken>, |
|
| 13 | + | } |
|
| 14 | + | ||
| 15 | + | /// Send a request, transparently handling OAuth client-credentials auth: |
|
| 16 | + | /// reuse a cached token while valid, fetch one otherwise, and retry the |
|
| 17 | + | /// request once with a fresh token on a 401 response. |
|
| 18 | + | pub async fn send_with_auth( |
|
| 19 | + | client: &reqwest::Client, |
|
| 20 | + | base_url: &str, |
|
| 21 | + | req: &SavedRequest, |
|
| 22 | + | vars: &HashMap<String, String>, |
|
| 23 | + | auth: Option<&OAuthConfig>, |
|
| 24 | + | cached: Option<OAuthToken>, |
|
| 25 | + | ) -> SendOutcome { |
|
| 26 | + | let mut token = cached; |
|
| 27 | + | let auth = auth.filter(|c| c.is_configured()); |
|
| 28 | + | ||
| 29 | + | let mut bearer: Option<String> = None; |
|
| 30 | + | if let Some(cfg) = auth { |
|
| 31 | + | let stale = token.as_ref().map(|t| !token_valid(t)).unwrap_or(true); |
|
| 32 | + | if stale { |
|
| 33 | + | match fetch_token(client, cfg).await { |
|
| 34 | + | Ok(t) => token = Some(t), |
|
| 35 | + | Err(e) => { |
|
| 36 | + | return SendOutcome { |
|
| 37 | + | result: Err(format!("token fetch failed: {e:#}")), |
|
| 38 | + | token, |
|
| 39 | + | }; |
|
| 40 | + | } |
|
| 41 | + | } |
|
| 42 | + | } |
|
| 43 | + | bearer = token.as_ref().map(|t| t.access_token.clone()); |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | let mut resp = send_request(client, base_url, req, vars, bearer.as_deref()) |
|
| 47 | + | .await |
|
| 48 | + | .map_err(|e| format!("{e:#}")); |
|
| 49 | + | ||
| 50 | + | if let (Ok(r), Some(cfg)) = (&resp, auth) |
|
| 51 | + | && r.status == 401 |
|
| 52 | + | && let Ok(t) = fetch_token(client, cfg).await |
|
| 53 | + | { |
|
| 54 | + | bearer = Some(t.access_token.clone()); |
|
| 55 | + | token = Some(t); |
|
| 56 | + | resp = send_request(client, base_url, req, vars, bearer.as_deref()) |
|
| 57 | + | .await |
|
| 58 | + | .map_err(|e| format!("{e:#}")); |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | SendOutcome { |
|
| 62 | + | result: resp, |
|
| 63 | + | token, |
|
| 64 | + | } |
|
| 65 | + | } |
| 1 | + | //! Decompose a URL typed or pasted into the URL bar. This is the inverse of |
|
| 2 | + | //! [`super::client`]'s `build_url`: that composes `base + path + query` into a |
|
| 3 | + | //! request, this splits a pasted URL back into the pieces a |
|
| 4 | + | //! [`crate::model::SavedRequest`] stores. |
|
| 5 | + | ||
| 6 | + | use url::Url; |
|
| 7 | + | ||
| 8 | + | use crate::model::KeyValueRow; |
|
| 9 | + | ||
| 10 | + | /// The pieces a URL-bar entry decomposes into. |
|
| 11 | + | #[derive(Debug, Clone, Default, PartialEq, Eq)] |
|
| 12 | + | pub struct UrlParts { |
|
| 13 | + | /// Origin (`scheme://host[:port]`) when an absolute http(s) URL was pasted; |
|
| 14 | + | /// `None` for a bare path, which is what the field normally holds. |
|
| 15 | + | pub origin: Option<String>, |
|
| 16 | + | /// Always leading-slash (or a `{{var}}` template), relative to `origin`. |
|
| 17 | + | pub path: String, |
|
| 18 | + | /// `None` when the input had no `?` at all, meaning "leave the existing |
|
| 19 | + | /// query rows alone"; `Some(rows)` when it did, even if empty — a bare `?` |
|
| 20 | + | /// clears them. |
|
| 21 | + | pub query: Option<Vec<KeyValueRow>>, |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | pub fn split_url_input(input: &str) -> UrlParts { |
|
| 25 | + | let input = input.trim(); |
|
| 26 | + | if input.is_empty() { |
|
| 27 | + | return UrlParts { |
|
| 28 | + | origin: None, |
|
| 29 | + | path: "/".into(), |
|
| 30 | + | query: None, |
|
| 31 | + | }; |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | // `Url::parse` succeeds for anything with a scheme, including nonsense like |
|
| 35 | + | // `localhost:8080/pets` (scheme `localhost`, path `8080/pets`), so the |
|
| 36 | + | // scheme and host checks are load-bearing rather than cosmetic. |
|
| 37 | + | if let Ok(u) = Url::parse(input) |
|
| 38 | + | && matches!(u.scheme(), "http" | "https") |
|
| 39 | + | && u.host().is_some() |
|
| 40 | + | { |
|
| 41 | + | return UrlParts { |
|
| 42 | + | origin: Some(u.origin().ascii_serialization()), |
|
| 43 | + | path: restore_braces(u.path()), |
|
| 44 | + | query: u.query().map(parse_query), |
|
| 45 | + | // The fragment is deliberately dropped: it is never sent to a server. |
|
| 46 | + | }; |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | let no_fragment = input.split_once('#').map_or(input, |(head, _)| head); |
|
| 50 | + | let (path, query) = match no_fragment.split_once('?') { |
|
| 51 | + | Some((p, q)) => (p, Some(parse_query(q))), |
|
| 52 | + | None => (no_fragment, None), |
|
| 53 | + | }; |
|
| 54 | + | // `{{baseUrl}}/pets` is a template, not a relative path — leave it verbatim. |
|
| 55 | + | let path = if path.is_empty() { |
|
| 56 | + | "/".to_string() |
|
| 57 | + | } else if path.starts_with('/') || path.starts_with("{{") { |
|
| 58 | + | path.to_string() |
|
| 59 | + | } else { |
|
| 60 | + | format!("/{path}") |
|
| 61 | + | }; |
|
| 62 | + | UrlParts { |
|
| 63 | + | origin: None, |
|
| 64 | + | path, |
|
| 65 | + | query, |
|
| 66 | + | } |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | /// Undo the `url` crate's percent-encoding of `{` and `}`, which are in its |
|
| 70 | + | /// path encode set — so `/pets/{id}` comes back as `/pets/%7Bid%7D` and would |
|
| 71 | + | /// break both `{pathParam}` replacement and `{{variable}}` substitution. Only |
|
| 72 | + | /// the braces are restored; a blanket decode would corrupt segments the user |
|
| 73 | + | /// percent-encoded on purpose. |
|
| 74 | + | fn restore_braces(s: &str) -> String { |
|
| 75 | + | s.replace("%7B", "{") |
|
| 76 | + | .replace("%7b", "{") |
|
| 77 | + | .replace("%7D", "}") |
|
| 78 | + | .replace("%7d", "}") |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | /// Query string to enabled rows. Values are decoded here and re-encoded by |
|
| 82 | + | /// `build_url`'s `.query(&pairs)`, so they round-trip. |
|
| 83 | + | fn parse_query(raw: &str) -> Vec<KeyValueRow> { |
|
| 84 | + | url::form_urlencoded::parse(raw.as_bytes()) |
|
| 85 | + | .map(|(k, v)| KeyValueRow::new(k.as_ref(), v.as_ref(), true)) |
|
| 86 | + | .filter(|r| !r.key.is_empty()) |
|
| 87 | + | .collect() |
|
| 88 | + | } |
|
| 89 | + | ||
| 90 | + | #[cfg(test)] |
|
| 91 | + | mod tests { |
|
| 92 | + | use super::*; |
|
| 93 | + | ||
| 94 | + | fn rows(parts: &UrlParts) -> Vec<(String, String)> { |
|
| 95 | + | parts |
|
| 96 | + | .query |
|
| 97 | + | .as_ref() |
|
| 98 | + | .map(|q| q.iter().map(|r| (r.key.clone(), r.value.clone())).collect()) |
|
| 99 | + | .unwrap_or_default() |
|
| 100 | + | } |
|
| 101 | + | ||
| 102 | + | #[test] |
|
| 103 | + | fn splits_a_full_url() { |
|
| 104 | + | let p = split_url_input("https://api.example.com/v1/pets?limit=10"); |
|
| 105 | + | assert_eq!(p.origin.as_deref(), Some("https://api.example.com")); |
|
| 106 | + | assert_eq!(p.path, "/v1/pets"); |
|
| 107 | + | assert_eq!(rows(&p), vec![("limit".to_string(), "10".to_string())]); |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | #[test] |
|
| 111 | + | fn bare_path_has_no_origin() { |
|
| 112 | + | let p = split_url_input("/v1/pets"); |
|
| 113 | + | assert_eq!(p.origin, None); |
|
| 114 | + | assert_eq!(p.path, "/v1/pets"); |
|
| 115 | + | assert_eq!(p.query, None); |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | #[test] |
|
| 119 | + | fn relative_path_gains_a_leading_slash() { |
|
| 120 | + | assert_eq!(split_url_input("pets/42").path, "/pets/42"); |
|
| 121 | + | } |
|
| 122 | + | ||
| 123 | + | #[test] |
|
| 124 | + | fn host_with_port_and_no_scheme_is_relative() { |
|
| 125 | + | // `Url::parse` accepts this with scheme "localhost"; we must not. |
|
| 126 | + | let p = split_url_input("localhost:8080/pets"); |
|
| 127 | + | assert_eq!(p.origin, None); |
|
| 128 | + | assert_eq!(p.path, "/localhost:8080/pets"); |
|
| 129 | + | } |
|
| 130 | + | ||
| 131 | + | #[test] |
|
| 132 | + | fn keeps_brace_placeholders_unencoded() { |
|
| 133 | + | let p = split_url_input("https://api.example.com/pets/{petId}/photos"); |
|
| 134 | + | assert_eq!(p.path, "/pets/{petId}/photos"); |
|
| 135 | + | } |
|
| 136 | + | ||
| 137 | + | #[test] |
|
| 138 | + | fn keeps_double_brace_variables_in_a_relative_path() { |
|
| 139 | + | let p = split_url_input("{{prefix}}/pets"); |
|
| 140 | + | assert_eq!(p.origin, None); |
|
| 141 | + | assert_eq!(p.path, "{{prefix}}/pets"); |
|
| 142 | + | } |
|
| 143 | + | ||
| 144 | + | #[test] |
|
| 145 | + | fn drops_the_fragment() { |
|
| 146 | + | assert_eq!( |
|
| 147 | + | split_url_input("https://api.example.com/pets#section").path, |
|
| 148 | + | "/pets" |
|
| 149 | + | ); |
|
| 150 | + | assert_eq!(split_url_input("/pets#section").path, "/pets"); |
|
| 151 | + | assert_eq!(split_url_input("/pets?a=1#section").path, "/pets"); |
|
| 152 | + | assert_eq!( |
|
| 153 | + | rows(&split_url_input("/pets?a=1#section")), |
|
| 154 | + | vec![("a".to_string(), "1".to_string())] |
|
| 155 | + | ); |
|
| 156 | + | } |
|
| 157 | + | ||
| 158 | + | #[test] |
|
| 159 | + | fn empty_input_becomes_root_path() { |
|
| 160 | + | let p = split_url_input(" "); |
|
| 161 | + | assert_eq!(p.path, "/"); |
|
| 162 | + | assert_eq!(p.origin, None); |
|
| 163 | + | assert_eq!(p.query, None); |
|
| 164 | + | } |
|
| 165 | + | ||
| 166 | + | #[test] |
|
| 167 | + | fn absent_question_mark_leaves_query_none() { |
|
| 168 | + | assert_eq!(split_url_input("https://api.example.com/pets").query, None); |
|
| 169 | + | assert_eq!(split_url_input("/pets").query, None); |
|
| 170 | + | } |
|
| 171 | + | ||
| 172 | + | #[test] |
|
| 173 | + | fn bare_question_mark_clears_the_query() { |
|
| 174 | + | assert_eq!(split_url_input("/pets?").query, Some(Vec::new())); |
|
| 175 | + | assert_eq!( |
|
| 176 | + | split_url_input("https://api.example.com/pets?").query, |
|
| 177 | + | Some(Vec::new()) |
|
| 178 | + | ); |
|
| 179 | + | } |
|
| 180 | + | ||
| 181 | + | #[test] |
|
| 182 | + | fn decodes_query_values() { |
|
| 183 | + | let p = split_url_input("/search?q=hello%20world&tag=a%2Bb"); |
|
| 184 | + | assert_eq!( |
|
| 185 | + | rows(&p), |
|
| 186 | + | vec![ |
|
| 187 | + | ("q".to_string(), "hello world".to_string()), |
|
| 188 | + | ("tag".to_string(), "a+b".to_string()), |
|
| 189 | + | ] |
|
| 190 | + | ); |
|
| 191 | + | } |
|
| 192 | + | ||
| 193 | + | #[test] |
|
| 194 | + | fn default_ports_are_dropped_from_the_origin() { |
|
| 195 | + | assert_eq!( |
|
| 196 | + | split_url_input("https://api.example.com:443/pets") |
|
| 197 | + | .origin |
|
| 198 | + | .as_deref(), |
|
| 199 | + | Some("https://api.example.com") |
|
| 200 | + | ); |
|
| 201 | + | assert_eq!( |
|
| 202 | + | split_url_input("http://localhost:8080/pets") |
|
| 203 | + | .origin |
|
| 204 | + | .as_deref(), |
|
| 205 | + | Some("http://localhost:8080") |
|
| 206 | + | ); |
|
| 207 | + | } |
|
| 208 | + | ||
| 209 | + | #[test] |
|
| 210 | + | fn query_rows_are_enabled_and_skip_empty_keys() { |
|
| 211 | + | let p = split_url_input("/pets?a=1&=2&b"); |
|
| 212 | + | let q = p.query.unwrap(); |
|
| 213 | + | assert_eq!(q.len(), 2); |
|
| 214 | + | assert!(q.iter().all(|r| r.enabled)); |
|
| 215 | + | assert_eq!(q[1].key, "b"); |
|
| 216 | + | assert_eq!(q[1].value, ""); |
|
| 217 | + | } |
|
| 218 | + | } |
| 1 | + | //! `{{variable}}` substitution for paths, params, headers and bodies. |
|
| 2 | + | //! |
|
| 3 | + | //! Two kinds of variable resolve here: |
|
| 4 | + | //! |
|
| 5 | + | //! - **Collection variables** — looked up by (trimmed) name in the Variables tab. |
|
| 6 | + | //! - **Dynamic variables** — computed at send time: `{{uuid}}`, `{{timestamp}}`, |
|
| 7 | + | //! `{{randomInt(1,100)}}` … see [`DYNAMIC_VARS`]. |
|
| 8 | + | //! |
|
| 9 | + | //! A collection variable shadows a dynamic one of the same name, so `uuid` can |
|
| 10 | + | //! be pinned to a fixed value for a debugging session. Prefixing with `$` |
|
| 11 | + | //! (`{{$uuid}}`, Postman's spelling) always takes the dynamic one. |
|
| 12 | + | //! |
|
| 13 | + | //! Unknown variables are left untouched so the user can see what failed to |
|
| 14 | + | //! resolve. |
|
| 15 | + | ||
| 16 | + | use std::collections::HashMap; |
|
| 17 | + | use std::time::{SystemTime, UNIX_EPOCH}; |
|
| 18 | + | ||
| 19 | + | use uuid::Uuid; |
|
| 20 | + | ||
| 21 | + | /// Dynamic variable names and their help text, in the order the help popup |
|
| 22 | + | /// lists them. Names are matched case-insensitively, ignoring `_`, so |
|
| 23 | + | /// `isoTimestamp`, `iso_timestamp` and `ISOTIMESTAMP` are the same variable. |
|
| 24 | + | pub const DYNAMIC_VARS: [(&str, &str); 8] = [ |
|
| 25 | + | ("uuid", "UUID v4, fresh per occurrence"), |
|
| 26 | + | ("timestamp", "Unix time in seconds"), |
|
| 27 | + | ("timestampMs", "Unix time in milliseconds"), |
|
| 28 | + | ("isoTimestamp", "RFC 3339 UTC, e.g. 2026-08-06T12:34:56Z"), |
|
| 29 | + | ("randomInt", "0–1000, or randomInt(min,max) inclusive"), |
|
| 30 | + | ("randomHex", "16 hex chars, or randomHex(n)"), |
|
| 31 | + | ("randomString", "16 alphanumerics, or randomString(n)"), |
|
| 32 | + | ("randomBool", "true or false"), |
|
| 33 | + | ]; |
|
| 34 | + | ||
| 35 | + | pub fn substitute(input: &str, vars: &HashMap<String, String>) -> String { |
|
| 36 | + | let mut out = String::with_capacity(input.len()); |
|
| 37 | + | let mut rest = input; |
|
| 38 | + | while let Some(start) = rest.find("{{") { |
|
| 39 | + | out.push_str(&rest[..start]); |
|
| 40 | + | let after = &rest[start + 2..]; |
|
| 41 | + | match after.find("}}") { |
|
| 42 | + | Some(end) => { |
|
| 43 | + | let key = after[..end].trim(); |
|
| 44 | + | match resolve(key, vars) { |
|
| 45 | + | Some(v) => out.push_str(&v), |
|
| 46 | + | // Unknown variable: keep the placeholder as-is. |
|
| 47 | + | None => out.push_str(&rest[..start + 2 + end + 2]), |
|
| 48 | + | } |
|
| 49 | + | rest = &after[end + 2..]; |
|
| 50 | + | } |
|
| 51 | + | None => { |
|
| 52 | + | out.push_str(&rest[start..]); |
|
| 53 | + | rest = ""; |
|
| 54 | + | } |
|
| 55 | + | } |
|
| 56 | + | } |
|
| 57 | + | out.push_str(rest); |
|
| 58 | + | out |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | /// A `$` prefix forces the dynamic variable; otherwise the collection wins. |
|
| 62 | + | fn resolve(key: &str, vars: &HashMap<String, String>) -> Option<String> { |
|
| 63 | + | match key.strip_prefix('$') { |
|
| 64 | + | Some(name) => dynamic(name.trim()), |
|
| 65 | + | None => vars.get(key).cloned().or_else(|| dynamic(key)), |
|
| 66 | + | } |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | /// Evaluate a dynamic variable, with optional `name(arg,arg)` arguments. |
|
| 70 | + | /// Returns `None` for an unknown name or unusable arguments — the caller then |
|
| 71 | + | /// leaves the placeholder visible rather than silently emitting junk. |
|
| 72 | + | fn dynamic(spec: &str) -> Option<String> { |
|
| 73 | + | let (name, args) = split_call(spec)?; |
|
| 74 | + | let name = normalize(&name); |
|
| 75 | + | Some(match (name.as_str(), args.as_slice()) { |
|
| 76 | + | ("uuid", []) => Uuid::new_v4().to_string(), |
|
| 77 | + | ("timestamp", []) => unix_secs().to_string(), |
|
| 78 | + | ("timestampms", []) => unix_millis().to_string(), |
|
| 79 | + | ("isotimestamp", []) => iso_timestamp(unix_secs()), |
|
| 80 | + | ("randomint", []) => random_int(0, 1000).to_string(), |
|
| 81 | + | ("randomint", [min, max]) => { |
|
| 82 | + | let (min, max) = (min.parse::<i64>().ok()?, max.parse::<i64>().ok()?); |
|
| 83 | + | if min > max { |
|
| 84 | + | return None; |
|
| 85 | + | } |
|
| 86 | + | random_int(min, max).to_string() |
|
| 87 | + | } |
|
| 88 | + | ("randomhex", []) => random_hex(16), |
|
| 89 | + | ("randomhex", [n]) => random_hex(parse_len(n)?), |
|
| 90 | + | ("randomstring", []) => random_string(16), |
|
| 91 | + | ("randomstring", [n]) => random_string(parse_len(n)?), |
|
| 92 | + | ("randombool", []) => (random_int(0, 1) == 1).to_string(), |
|
| 93 | + | _ => return None, |
|
| 94 | + | }) |
|
| 95 | + | } |
|
| 96 | + | ||
| 97 | + | /// `name` or `name(a, b)` → `("name", ["a", "b"])`. Empty args are rejected so |
|
| 98 | + | /// `randomHex()` doesn't quietly mean `randomHex`. |
|
| 99 | + | fn split_call(spec: &str) -> Option<(String, Vec<String>)> { |
|
| 100 | + | let Some(open) = spec.find('(') else { |
|
| 101 | + | return Some((spec.to_string(), Vec::new())); |
|
| 102 | + | }; |
|
| 103 | + | let inner = spec.strip_suffix(')')?.get(open + 1..)?; |
|
| 104 | + | let args: Vec<String> = inner.split(',').map(|a| a.trim().to_string()).collect(); |
|
| 105 | + | if args.iter().any(|a| a.is_empty()) { |
|
| 106 | + | return None; |
|
| 107 | + | } |
|
| 108 | + | Some((spec[..open].trim().to_string(), args)) |
|
| 109 | + | } |
|
| 110 | + | ||
| 111 | + | fn normalize(name: &str) -> String { |
|
| 112 | + | name.chars() |
|
| 113 | + | .filter(|c| *c != '_') |
|
| 114 | + | .flat_map(char::to_lowercase) |
|
| 115 | + | .collect() |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | /// Length arguments are capped: a stray `randomString(999999999)` shouldn't |
|
| 119 | + | /// build a gigabyte of request body. |
|
| 120 | + | fn parse_len(s: &str) -> Option<usize> { |
|
| 121 | + | let n = s.parse::<usize>().ok()?; |
|
| 122 | + | (1..=4096).contains(&n).then_some(n) |
|
| 123 | + | } |
|
| 124 | + | ||
| 125 | + | // ----- clock ----- |
|
| 126 | + | ||
| 127 | + | fn unix_millis() -> u128 { |
|
| 128 | + | SystemTime::now() |
|
| 129 | + | .duration_since(UNIX_EPOCH) |
|
| 130 | + | .map(|d| d.as_millis()) |
|
| 131 | + | .unwrap_or(0) |
|
| 132 | + | } |
|
| 133 | + | ||
| 134 | + | fn unix_secs() -> i64 { |
|
| 135 | + | (unix_millis() / 1000) as i64 |
|
| 136 | + | } |
|
| 137 | + | ||
| 138 | + | /// RFC 3339 in UTC. Hand-rolled rather than pulling in a date crate: the only |
|
| 139 | + | /// calendar work cielago does is stamping a request. |
|
| 140 | + | fn iso_timestamp(secs: i64) -> String { |
|
| 141 | + | let days = secs.div_euclid(86_400); |
|
| 142 | + | let time = secs.rem_euclid(86_400); |
|
| 143 | + | let (y, m, d) = civil_from_days(days); |
|
| 144 | + | let (h, min, s) = (time / 3600, (time % 3600) / 60, time % 60); |
|
| 145 | + | format!("{y:04}-{m:02}-{d:02}T{h:02}:{min:02}:{s:02}Z") |
|
| 146 | + | } |
|
| 147 | + | ||
| 148 | + | /// Days since 1970-01-01 → (year, month, day). Hinnant's civil-from-days. |
|
| 149 | + | fn civil_from_days(z: i64) -> (i64, u32, u32) { |
|
| 150 | + | let z = z + 719_468; |
|
| 151 | + | let era = z.div_euclid(146_097); |
|
| 152 | + | let doe = z.rem_euclid(146_097); // [0, 146096] |
|
| 153 | + | let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399] |
|
| 154 | + | let y = yoe + era * 400; |
|
| 155 | + | let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] |
|
| 156 | + | let mp = (5 * doy + 2) / 153; // [0, 11], March-based |
|
| 157 | + | let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31] |
|
| 158 | + | let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12] |
|
| 159 | + | (if m <= 2 { y + 1 } else { y }, m, d) |
|
| 160 | + | } |
|
| 161 | + | ||
| 162 | + | // ----- randomness ----- |
|
| 163 | + | ||
| 164 | + | /// Random bytes borrowed from UUID v4 generation, so no extra RNG dependency. |
|
| 165 | + | /// Bytes 6 and 8 carry the version/variant bits and are dropped. |
|
| 166 | + | fn random_bytes(n: usize) -> Vec<u8> { |
|
| 167 | + | let mut out = Vec::with_capacity(n + 14); |
|
| 168 | + | while out.len() < n { |
|
| 169 | + | let id = *Uuid::new_v4().as_bytes(); |
|
| 170 | + | out.extend( |
|
| 171 | + | id.iter() |
|
| 172 | + | .enumerate() |
|
| 173 | + | .filter(|(i, _)| *i != 6 && *i != 8) |
|
| 174 | + | .map(|(_, b)| *b), |
|
| 175 | + | ); |
|
| 176 | + | } |
|
| 177 | + | out.truncate(n); |
|
| 178 | + | out |
|
| 179 | + | } |
|
| 180 | + | ||
| 181 | + | fn random_u64() -> u64 { |
|
| 182 | + | let b = random_bytes(8); |
|
| 183 | + | u64::from_le_bytes(b.try_into().expect("8 bytes requested")) |
|
| 184 | + | } |
|
| 185 | + | ||
| 186 | + | /// Uniform-ish over `[min, max]`; the modulo bias is irrelevant for test data. |
|
| 187 | + | /// Widened to i128 so a full-range `randomInt(i64::MIN, i64::MAX)` can't wrap. |
|
| 188 | + | fn random_int(min: i64, max: i64) -> i64 { |
|
| 189 | + | let span = (max as i128 - min as i128 + 1) as u128; |
|
| 190 | + | (min as i128 + (random_u64() as u128 % span) as i128) as i64 |
|
| 191 | + | } |
|
| 192 | + | ||
| 193 | + | fn random_hex(n: usize) -> String { |
|
| 194 | + | random_bytes(n.div_ceil(2)) |
|
| 195 | + | .iter() |
|
| 196 | + | .map(|b| format!("{b:02x}")) |
|
| 197 | + | .collect::<String>() |
|
| 198 | + | .chars() |
|
| 199 | + | .take(n) |
|
| 200 | + | .collect() |
|
| 201 | + | } |
|
| 202 | + | ||
| 203 | + | fn random_string(n: usize) -> String { |
|
| 204 | + | const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; |
|
| 205 | + | random_bytes(n) |
|
| 206 | + | .iter() |
|
| 207 | + | .map(|b| ALPHABET[*b as usize % ALPHABET.len()] as char) |
|
| 208 | + | .collect() |
|
| 209 | + | } |
|
| 210 | + | ||
| 211 | + | #[cfg(test)] |
|
| 212 | + | mod tests { |
|
| 213 | + | use super::*; |
|
| 214 | + | ||
| 215 | + | fn vars() -> HashMap<String, String> { |
|
| 216 | + | HashMap::from([ |
|
| 217 | + | ("tenant".to_string(), "acme".to_string()), |
|
| 218 | + | ("version".to_string(), "v2".to_string()), |
|
| 219 | + | ]) |
|
| 220 | + | } |
|
| 221 | + | ||
| 222 | + | #[test] |
|
| 223 | + | fn substitutes_named_vars() { |
|
| 224 | + | assert_eq!( |
|
| 225 | + | substitute("/{{tenant}}/{{ version }}/x", &vars()), |
|
| 226 | + | "/acme/v2/x" |
|
| 227 | + | ); |
|
| 228 | + | } |
|
| 229 | + | ||
| 230 | + | #[test] |
|
| 231 | + | fn uuid_is_fresh_per_occurrence() { |
|
| 232 | + | let out = substitute("{{uuid}}-{{uuid}}", &vars()); |
|
| 233 | + | assert_eq!(out.len(), 36 + 1 + 36); |
|
| 234 | + | let (first, rest) = out.split_at(36); |
|
| 235 | + | let second = &rest[1..]; |
|
| 236 | + | assert_eq!(&rest[..1], "-"); |
|
| 237 | + | assert!(uuid::Uuid::parse_str(first).is_ok()); |
|
| 238 | + | assert!(uuid::Uuid::parse_str(second).is_ok()); |
|
| 239 | + | assert_ne!(first, second); |
|
| 240 | + | } |
|
| 241 | + | ||
| 242 | + | #[test] |
|
| 243 | + | fn unknown_vars_left_intact() { |
|
| 244 | + | assert_eq!(substitute("{{nope}}", &vars()), "{{nope}}"); |
|
| 245 | + | assert_eq!(substitute("{{$nope}}", &vars()), "{{$nope}}"); |
|
| 246 | + | } |
|
| 247 | + | ||
| 248 | + | #[test] |
|
| 249 | + | fn unclosed_brace_left_intact() { |
|
| 250 | + | assert_eq!(substitute("a {{oops", &vars()), "a {{oops"); |
|
| 251 | + | } |
|
| 252 | + | ||
| 253 | + | #[test] |
|
| 254 | + | fn collection_var_shadows_dynamic_unless_dollar_prefixed() { |
|
| 255 | + | let vars = HashMap::from([("uuid".to_string(), "pinned".to_string())]); |
|
| 256 | + | assert_eq!(substitute("{{uuid}}", &vars), "pinned"); |
|
| 257 | + | assert_eq!(substitute("{{$uuid}}", &vars).len(), 36); |
|
| 258 | + | } |
|
| 259 | + | ||
| 260 | + | #[test] |
|
| 261 | + | fn timestamps_are_plausible() { |
|
| 262 | + | let secs: i64 = substitute("{{timestamp}}", &vars()).parse().unwrap(); |
|
| 263 | + | // Somewhere after 2020 and before 2100. |
|
| 264 | + | assert!((1_577_836_800..4_102_444_800).contains(&secs)); |
|
| 265 | + | let ms: i64 = substitute("{{timestampMs}}", &vars()).parse().unwrap(); |
|
| 266 | + | assert_eq!(ms / 1000, secs); |
|
| 267 | + | ||
| 268 | + | let iso = substitute("{{isoTimestamp}}", &vars()); |
|
| 269 | + | assert_eq!(iso.len(), 20, "{iso}"); |
|
| 270 | + | assert!(iso.ends_with('Z')); |
|
| 271 | + | assert_eq!(&iso[4..5], "-"); |
|
| 272 | + | assert_eq!(&iso[10..11], "T"); |
|
| 273 | + | } |
|
| 274 | + | ||
| 275 | + | #[test] |
|
| 276 | + | fn iso_timestamp_matches_known_instants() { |
|
| 277 | + | assert_eq!(iso_timestamp(0), "1970-01-01T00:00:00Z"); |
|
| 278 | + | assert_eq!(iso_timestamp(1_000_000_000), "2001-09-09T01:46:40Z"); |
|
| 279 | + | // Leap day. |
|
| 280 | + | assert_eq!(iso_timestamp(1_709_164_800), "2024-02-29T00:00:00Z"); |
|
| 281 | + | assert_eq!(iso_timestamp(1_754_484_896), "2025-08-06T12:54:56Z"); |
|
| 282 | + | } |
|
| 283 | + | ||
| 284 | + | #[test] |
|
| 285 | + | fn name_matching_ignores_case_and_underscores() { |
|
| 286 | + | for name in ["isoTimestamp", "iso_timestamp", "ISO_TIMESTAMP"] { |
|
| 287 | + | assert!( |
|
| 288 | + | substitute(&format!("{{{{{name}}}}}"), &vars()).ends_with('Z'), |
|
| 289 | + | "{name}" |
|
| 290 | + | ); |
|
| 291 | + | } |
|
| 292 | + | } |
|
| 293 | + | ||
| 294 | + | #[test] |
|
| 295 | + | fn random_int_respects_bounds() { |
|
| 296 | + | for _ in 0..200 { |
|
| 297 | + | let n: i64 = substitute("{{randomInt(5, 7)}}", &vars()).parse().unwrap(); |
|
| 298 | + | assert!((5..=7).contains(&n), "{n}"); |
|
| 299 | + | } |
|
| 300 | + | assert_eq!(substitute("{{randomInt(-1,-1)}}", &vars()), "-1"); |
|
| 301 | + | let d: i64 = substitute("{{randomInt}}", &vars()).parse().unwrap(); |
|
| 302 | + | assert!((0..=1000).contains(&d)); |
|
| 303 | + | } |
|
| 304 | + | ||
| 305 | + | #[test] |
|
| 306 | + | fn random_strings_have_requested_length() { |
|
| 307 | + | assert_eq!(substitute("{{randomHex}}", &vars()).len(), 16); |
|
| 308 | + | assert_eq!(substitute("{{randomHex(7)}}", &vars()).len(), 7); |
|
| 309 | + | assert_eq!(substitute("{{randomString}}", &vars()).len(), 16); |
|
| 310 | + | assert_eq!(substitute("{{randomString(40)}}", &vars()).len(), 40); |
|
| 311 | + | assert!( |
|
| 312 | + | substitute("{{randomHex(9)}}", &vars()) |
|
| 313 | + | .chars() |
|
| 314 | + | .all(|c| c.is_ascii_hexdigit()) |
|
| 315 | + | ); |
|
| 316 | + | assert!( |
|
| 317 | + | substitute("{{randomString(64)}}", &vars()) |
|
| 318 | + | .chars() |
|
| 319 | + | .all(|c| c.is_ascii_alphanumeric()) |
|
| 320 | + | ); |
|
| 321 | + | } |
|
| 322 | + | ||
| 323 | + | #[test] |
|
| 324 | + | fn random_bool_is_a_bool_and_varies() { |
|
| 325 | + | let mut seen = std::collections::HashSet::new(); |
|
| 326 | + | for _ in 0..100 { |
|
| 327 | + | let v = substitute("{{randomBool}}", &vars()); |
|
| 328 | + | assert!(v == "true" || v == "false", "{v}"); |
|
| 329 | + | seen.insert(v); |
|
| 330 | + | } |
|
| 331 | + | assert_eq!(seen.len(), 2, "randomBool never flipped"); |
|
| 332 | + | } |
|
| 333 | + | ||
| 334 | + | #[test] |
|
| 335 | + | fn bad_arguments_leave_the_placeholder() { |
|
| 336 | + | for bad in [ |
|
| 337 | + | "{{randomInt(9,1)}}", |
|
| 338 | + | "{{randomInt(a,b)}}", |
|
| 339 | + | "{{randomInt(1)}}", |
|
| 340 | + | "{{randomHex(0)}}", |
|
| 341 | + | "{{randomHex(99999)}}", |
|
| 342 | + | "{{randomString()}}", |
|
| 343 | + | "{{uuid(2)}}", |
|
| 344 | + | ] { |
|
| 345 | + | assert_eq!(substitute(bad, &vars()), bad); |
|
| 346 | + | } |
|
| 347 | + | } |
|
| 348 | + | ||
| 349 | + | #[test] |
|
| 350 | + | fn every_documented_dynamic_var_resolves() { |
|
| 351 | + | for (name, _) in DYNAMIC_VARS { |
|
| 352 | + | let out = substitute(&format!("{{{{${name}}}}}"), &vars()); |
|
| 353 | + | assert!(!out.contains("{{"), "{name} did not resolve: {out}"); |
|
| 354 | + | } |
|
| 355 | + | } |
|
| 356 | + | } |
| 1 | + | //! Vim-style key handling. |
|
| 2 | + | ||
| 3 | + | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; |
|
| 4 | + | use tui_textarea::CursorMove; |
|
| 5 | + | ||
| 6 | + | use crate::app::{ |
|
| 7 | + | App, CellCol, EditTarget, EditorTab, ExternalEdit, Focus, Mode, Popup, SidebarRow, |
|
| 8 | + | }; |
|
| 9 | + | ||
| 10 | + | pub fn handle_key(app: &mut App, key: KeyEvent) { |
|
| 11 | + | if app.popup != Popup::None { |
|
| 12 | + | handle_popup(app, key); |
|
| 13 | + | return; |
|
| 14 | + | } |
|
| 15 | + | match app.mode { |
|
| 16 | + | Mode::Command => handle_command(app, key), |
|
| 17 | + | Mode::Insert => handle_insert(app, key), |
|
| 18 | + | Mode::Search => handle_search(app, key), |
|
| 19 | + | Mode::Normal => handle_normal(app, key), |
|
| 20 | + | } |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | // ----- Normal mode ----- |
|
| 24 | + | ||
| 25 | + | fn handle_normal(app: &mut App, key: KeyEvent) { |
|
| 26 | + | match (key.modifiers, key.code) { |
|
| 27 | + | (_, KeyCode::Char(':')) => { |
|
| 28 | + | app.command.clear(); |
|
| 29 | + | app.mode = Mode::Command; |
|
| 30 | + | } |
|
| 31 | + | (_, KeyCode::Char('?')) => { |
|
| 32 | + | app.help_scroll = 0; |
|
| 33 | + | app.popup = Popup::Help; |
|
| 34 | + | } |
|
| 35 | + | (_, KeyCode::Char('q')) => app.try_quit(), |
|
| 36 | + | (_, KeyCode::Tab) => { |
|
| 37 | + | app.focus = match app.focus { |
|
| 38 | + | Focus::Sidebar => Focus::Editor, |
|
| 39 | + | Focus::Editor => Focus::Response, |
|
| 40 | + | Focus::Response => Focus::Sidebar, |
|
| 41 | + | }; |
|
| 42 | + | } |
|
| 43 | + | (_, KeyCode::BackTab) => { |
|
| 44 | + | app.focus = match app.focus { |
|
| 45 | + | Focus::Sidebar => Focus::Response, |
|
| 46 | + | Focus::Editor => Focus::Sidebar, |
|
| 47 | + | Focus::Response => Focus::Editor, |
|
| 48 | + | }; |
|
| 49 | + | } |
|
| 50 | + | (_, KeyCode::Char('z')) => { |
|
| 51 | + | app.zoom = !app.zoom; |
|
| 52 | + | app.status = if app.zoom { |
|
| 53 | + | "Pane maximized — z to restore".into() |
|
| 54 | + | } else { |
|
| 55 | + | "Panes restored".into() |
|
| 56 | + | }; |
|
| 57 | + | } |
|
| 58 | + | (_, KeyCode::Char('1')) => app.focus = Focus::Sidebar, |
|
| 59 | + | (_, KeyCode::Char('2')) => app.focus = Focus::Editor, |
|
| 60 | + | (_, KeyCode::Char('3')) => app.focus = Focus::Response, |
|
| 61 | + | ||
| 62 | + | // Editor tab cycling. `]`/`[` are the primary bindings; `L`/`H` are |
|
| 63 | + | // aliases for keyboards where the brackets are awkward to reach. |
|
| 64 | + | (_, KeyCode::Char(']') | KeyCode::Char('L')) => { |
|
| 65 | + | app.tab = app.tab.next(); |
|
| 66 | + | app.table_row = 0; |
|
| 67 | + | } |
|
| 68 | + | (_, KeyCode::Char('[') | KeyCode::Char('H')) => { |
|
| 69 | + | app.tab = app.tab.prev(); |
|
| 70 | + | app.table_row = 0; |
|
| 71 | + | } |
|
| 72 | + | (m, KeyCode::Right) if m.contains(KeyModifiers::SHIFT) => { |
|
| 73 | + | app.tab = app.tab.next(); |
|
| 74 | + | app.table_row = 0; |
|
| 75 | + | } |
|
| 76 | + | (m, KeyCode::Left) if m.contains(KeyModifiers::SHIFT) => { |
|
| 77 | + | app.tab = app.tab.prev(); |
|
| 78 | + | app.table_row = 0; |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | (_, KeyCode::Char('/')) => app.start_search(), |
|
| 82 | + | ||
| 83 | + | (_, KeyCode::Char('E')) => { |
|
| 84 | + | app.env_sel = app.collection.active_server; |
|
| 85 | + | app.popup = Popup::Env; |
|
| 86 | + | } |
|
| 87 | + | (_, KeyCode::Char('A')) => app.open_auth_popup(), |
|
| 88 | + | ||
| 89 | + | _ => match app.focus { |
|
| 90 | + | Focus::Sidebar => normal_sidebar(app, key), |
|
| 91 | + | Focus::Editor => normal_editor(app, key), |
|
| 92 | + | Focus::Response => normal_response(app, key), |
|
| 93 | + | }, |
|
| 94 | + | } |
|
| 95 | + | } |
|
| 96 | + | ||
| 97 | + | fn normal_sidebar(app: &mut App, key: KeyEvent) { |
|
| 98 | + | match key.code { |
|
| 99 | + | KeyCode::Char('j') | KeyCode::Down if app.sidebar_sel + 1 < app.sidebar_rows.len() => { |
|
| 100 | + | app.sidebar_sel += 1; |
|
| 101 | + | } |
|
| 102 | + | KeyCode::Char('k') | KeyCode::Up => { |
|
| 103 | + | app.sidebar_sel = app.sidebar_sel.saturating_sub(1); |
|
| 104 | + | } |
|
| 105 | + | KeyCode::Char('g') => app.sidebar_sel = 0, |
|
| 106 | + | KeyCode::Char('G') => { |
|
| 107 | + | app.sidebar_sel = app.sidebar_rows.len().saturating_sub(1); |
|
| 108 | + | } |
|
| 109 | + | KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Char('l') | KeyCode::Char('h') => { |
|
| 110 | + | app.activate_sidebar() |
|
| 111 | + | } |
|
| 112 | + | KeyCode::Esc => app.clear_filter(), |
|
| 113 | + | KeyCode::Char('t') => app.cycle_label_mode(), |
|
| 114 | + | KeyCode::Char('n') => app.start_edit(EditTarget::NewRequest), |
|
| 115 | + | KeyCode::Char('r') => { |
|
| 116 | + | if let Some(SidebarRow::Request(idx)) = app.sidebar_rows.get(app.sidebar_sel).cloned() { |
|
| 117 | + | // Select the highlighted request but keep focus in the sidebar. |
|
| 118 | + | app.select_request(idx); |
|
| 119 | + | app.focus = Focus::Sidebar; |
|
| 120 | + | app.start_edit(EditTarget::Rename); |
|
| 121 | + | } |
|
| 122 | + | } |
|
| 123 | + | KeyCode::Char('d') => { |
|
| 124 | + | if let Some(SidebarRow::Request(idx)) = app.sidebar_rows.get(app.sidebar_sel).cloned() { |
|
| 125 | + | app.collection.requests.remove(idx); |
|
| 126 | + | app.dirty = true; |
|
| 127 | + | if app.selected == Some(idx) { |
|
| 128 | + | app.selected = None; |
|
| 129 | + | app.set_textarea_text(""); |
|
| 130 | + | } |
|
| 131 | + | app.selected = app.selected.map(|s| if s > idx { s - 1 } else { s }); |
|
| 132 | + | app.rebuild_sidebar(); |
|
| 133 | + | app.status = "Request deleted".into(); |
|
| 134 | + | } |
|
| 135 | + | } |
|
| 136 | + | // `y` for yank, rather than something next to destructive `d`. |
|
| 137 | + | KeyCode::Char('y') => { |
|
| 138 | + | if let Some(SidebarRow::Request(idx)) = app.sidebar_rows.get(app.sidebar_sel).cloned() { |
|
| 139 | + | app.duplicate_request(idx); |
|
| 140 | + | // `duplicate_request` opens the clone, which moves focus to the |
|
| 141 | + | // editor; stay here so repeated `y` works (same as `r`). |
|
| 142 | + | app.focus = Focus::Sidebar; |
|
| 143 | + | } |
|
| 144 | + | } |
|
| 145 | + | _ => {} |
|
| 146 | + | } |
|
| 147 | + | } |
|
| 148 | + | ||
| 149 | + | fn normal_editor(app: &mut App, key: KeyEvent) { |
|
| 150 | + | if app.tab == EditorTab::Body && body_scroll(app, key) { |
|
| 151 | + | return; |
|
| 152 | + | } |
|
| 153 | + | if app.tab == EditorTab::Docs && docs_scroll(app, key) { |
|
| 154 | + | return; |
|
| 155 | + | } |
|
| 156 | + | match key.code { |
|
| 157 | + | KeyCode::Enter => app.send_selected(), |
|
| 158 | + | KeyCode::Char('j') | KeyCode::Down => { |
|
| 159 | + | if let Some(t) = app.tab.table() { |
|
| 160 | + | let len = app.table_len(t); |
|
| 161 | + | if len > 0 && app.table_row + 1 < len { |
|
| 162 | + | app.table_row += 1; |
|
| 163 | + | } |
|
| 164 | + | } |
|
| 165 | + | } |
|
| 166 | + | KeyCode::Char('k') | KeyCode::Up if app.tab.table().is_some() => { |
|
| 167 | + | app.table_row = app.table_row.saturating_sub(1); |
|
| 168 | + | } |
|
| 169 | + | KeyCode::Char('g') => app.table_row = 0, |
|
| 170 | + | KeyCode::Char('G') => { |
|
| 171 | + | if let Some(t) = app.tab.table() { |
|
| 172 | + | app.table_row = app.table_len(t).saturating_sub(1); |
|
| 173 | + | } |
|
| 174 | + | } |
|
| 175 | + | KeyCode::Char(' ') => { |
|
| 176 | + | if let Some(t) = app.tab.table() { |
|
| 177 | + | app.toggle_row(t, app.table_row); |
|
| 178 | + | } |
|
| 179 | + | } |
|
| 180 | + | KeyCode::Char('i') | KeyCode::Char('a') => { |
|
| 181 | + | let is_add = key.code == KeyCode::Char('a'); |
|
| 182 | + | match app.tab { |
|
| 183 | + | EditorTab::Body => { |
|
| 184 | + | app.mode = Mode::Insert; |
|
| 185 | + | app.status = "Editing body — Esc to finish".into(); |
|
| 186 | + | } |
|
| 187 | + | _ => { |
|
| 188 | + | if let Some(t) = app.tab.table() { |
|
| 189 | + | if is_add { |
|
| 190 | + | app.add_row(t); |
|
| 191 | + | } else if app.table_row < app.table_len(t) { |
|
| 192 | + | app.chain_to_value = false; |
|
| 193 | + | app.start_edit(EditTarget::Cell { |
|
| 194 | + | table: t, |
|
| 195 | + | row: app.table_row, |
|
| 196 | + | col: CellCol::Value, |
|
| 197 | + | }); |
|
| 198 | + | } |
|
| 199 | + | } |
|
| 200 | + | } |
|
| 201 | + | } |
|
| 202 | + | } |
|
| 203 | + | KeyCode::Char('d') => { |
|
| 204 | + | if let Some(t) = app.tab.table() { |
|
| 205 | + | app.delete_row(t, app.table_row); |
|
| 206 | + | } |
|
| 207 | + | } |
|
| 208 | + | KeyCode::Char('e') if app.tab == EditorTab::Body && app.selected.is_some() => { |
|
| 209 | + | app.pending_external = Some(ExternalEdit::Body); |
|
| 210 | + | } |
|
| 211 | + | KeyCode::Char('m') => cycle_method(app), |
|
| 212 | + | KeyCode::Char('r') if app.selected.is_some() => { |
|
| 213 | + | app.start_edit(EditTarget::Rename); |
|
| 214 | + | } |
|
| 215 | + | // `p` for path/paste. Not `u` — that is page-up on every scrollable pane. |
|
| 216 | + | KeyCode::Char('p') if app.selected.is_some() => { |
|
| 217 | + | app.start_edit(EditTarget::Url); |
|
| 218 | + | } |
|
| 219 | + | _ => {} |
|
| 220 | + | } |
|
| 221 | + | } |
|
| 222 | + | ||
| 223 | + | /// Body-tab movement. The read-only (highlighted) body view follows the |
|
| 224 | + | /// textarea's cursor, so scrolling it is just moving that cursor. Returns |
|
| 225 | + | /// whether the key was consumed. |
|
| 226 | + | fn body_scroll(app: &mut App, key: KeyEvent) -> bool { |
|
| 227 | + | let moves: &[CursorMove] = match key.code { |
|
| 228 | + | KeyCode::Char('j') | KeyCode::Down => &[CursorMove::Down], |
|
| 229 | + | KeyCode::Char('k') | KeyCode::Up => &[CursorMove::Up], |
|
| 230 | + | KeyCode::Char('g') => &[CursorMove::Top], |
|
| 231 | + | KeyCode::Char('G') => &[CursorMove::Bottom], |
|
| 232 | + | KeyCode::Char('d') => &[CursorMove::Down; 15], |
|
| 233 | + | KeyCode::Char('u') => &[CursorMove::Up; 15], |
|
| 234 | + | _ => return false, |
|
| 235 | + | }; |
|
| 236 | + | for m in moves { |
|
| 237 | + | app.textarea.move_cursor(*m); |
|
| 238 | + | } |
|
| 239 | + | true |
|
| 240 | + | } |
|
| 241 | + | ||
| 242 | + | /// Docs-tab scrolling. The tab is read-only, so `d`/`u` page here instead of |
|
| 243 | + | /// deleting rows. Clamped against the rendered height in `ui::draw_docs`. |
|
| 244 | + | fn docs_scroll(app: &mut App, key: KeyEvent) -> bool { |
|
| 245 | + | match key.code { |
|
| 246 | + | KeyCode::Char('j') | KeyCode::Down => app.docs_scroll += 1, |
|
| 247 | + | KeyCode::Char('k') | KeyCode::Up => app.docs_scroll = app.docs_scroll.saturating_sub(1), |
|
| 248 | + | KeyCode::Char('d') => app.docs_scroll += 15, |
|
| 249 | + | KeyCode::Char('u') => app.docs_scroll = app.docs_scroll.saturating_sub(15), |
|
| 250 | + | KeyCode::Char('g') => app.docs_scroll = 0, |
|
| 251 | + | KeyCode::Char('G') => app.docs_scroll = usize::MAX / 2, |
|
| 252 | + | _ => return false, |
|
| 253 | + | } |
|
| 254 | + | true |
|
| 255 | + | } |
|
| 256 | + | ||
| 257 | + | fn cycle_method(app: &mut App) { |
|
| 258 | + | let Some(i) = app.selected else { return }; |
|
| 259 | + | use crate::model::Method::*; |
|
| 260 | + | let req = &mut app.collection.requests[i]; |
|
| 261 | + | req.method = match req.method { |
|
| 262 | + | Get => Post, |
|
| 263 | + | Post => Put, |
|
| 264 | + | Put => Patch, |
|
| 265 | + | Patch => Delete, |
|
| 266 | + | Delete => Head, |
|
| 267 | + | Head => Options, |
|
| 268 | + | Options => Get, |
|
| 269 | + | }; |
|
| 270 | + | app.dirty = true; |
|
| 271 | + | } |
|
| 272 | + | ||
| 273 | + | fn normal_response(app: &mut App, key: KeyEvent) { |
|
| 274 | + | match key.code { |
|
| 275 | + | KeyCode::Char('j') | KeyCode::Down => app.response_scroll += 1, |
|
| 276 | + | KeyCode::Char('k') | KeyCode::Up => { |
|
| 277 | + | app.response_scroll = app.response_scroll.saturating_sub(1) |
|
| 278 | + | } |
|
| 279 | + | KeyCode::Char('g') => app.response_scroll = 0, |
|
| 280 | + | KeyCode::Char('G') => app.response_scroll = usize::MAX / 2, // clamped at render |
|
| 281 | + | KeyCode::Char('d') => app.response_scroll += 15, |
|
| 282 | + | KeyCode::Char('u') => app.response_scroll = app.response_scroll.saturating_sub(15), |
|
| 283 | + | KeyCode::Char('e') if app.response.is_some() => { |
|
| 284 | + | app.pending_external = Some(ExternalEdit::Response); |
|
| 285 | + | } |
|
| 286 | + | _ => {} |
|
| 287 | + | } |
|
| 288 | + | } |
|
| 289 | + | ||
| 290 | + | // ----- Insert mode ----- |
|
| 291 | + | ||
| 292 | + | fn handle_insert(app: &mut App, key: KeyEvent) { |
|
| 293 | + | if app.editing.is_some() { |
|
| 294 | + | match key.code { |
|
| 295 | + | KeyCode::Enter => app.commit_edit(), |
|
| 296 | + | KeyCode::Esc => app.cancel_edit(), |
|
| 297 | + | KeyCode::Char(c) => app.input.insert(c), |
|
| 298 | + | KeyCode::Backspace => app.input.backspace(), |
|
| 299 | + | KeyCode::Delete => app.input.delete(), |
|
| 300 | + | KeyCode::Left => app.input.left(), |
|
| 301 | + | KeyCode::Right => app.input.right(), |
|
| 302 | + | KeyCode::Home => app.input.home(), |
|
| 303 | + | KeyCode::End => app.input.end(), |
|
| 304 | + | _ => {} |
|
| 305 | + | } |
|
| 306 | + | return; |
|
| 307 | + | } |
|
| 308 | + | // Body textarea editing. |
|
| 309 | + | if key.code == KeyCode::Esc { |
|
| 310 | + | app.commit_body(); |
|
| 311 | + | app.mode = Mode::Normal; |
|
| 312 | + | app.status = "Body updated".into(); |
|
| 313 | + | return; |
|
| 314 | + | } |
|
| 315 | + | app.textarea.input(key); |
|
| 316 | + | } |
|
| 317 | + | ||
| 318 | + | // ----- Search mode (sidebar filter) ----- |
|
| 319 | + | ||
| 320 | + | /// The filter is applied on every keystroke; `Enter` keeps it, `Esc` drops it. |
|
| 321 | + | fn handle_search(app: &mut App, key: KeyEvent) { |
|
| 322 | + | match key.code { |
|
| 323 | + | KeyCode::Enter => { |
|
| 324 | + | app.finish_search(); |
|
| 325 | + | return; |
|
| 326 | + | } |
|
| 327 | + | KeyCode::Esc => { |
|
| 328 | + | app.search.set(""); |
|
| 329 | + | app.apply_search(); |
|
| 330 | + | app.mode = Mode::Normal; |
|
| 331 | + | app.status = "Filter cleared".into(); |
|
| 332 | + | return; |
|
| 333 | + | } |
|
| 334 | + | KeyCode::Char(c) => app.search.insert(c), |
|
| 335 | + | KeyCode::Backspace => app.search.backspace(), |
|
| 336 | + | KeyCode::Delete => app.search.delete(), |
|
| 337 | + | KeyCode::Left => app.search.left(), |
|
| 338 | + | KeyCode::Right => app.search.right(), |
|
| 339 | + | KeyCode::Home => app.search.home(), |
|
| 340 | + | KeyCode::End => app.search.end(), |
|
| 341 | + | KeyCode::Down => { |
|
| 342 | + | // Step through matches without leaving the prompt. |
|
| 343 | + | if app.sidebar_sel + 1 < app.sidebar_rows.len() { |
|
| 344 | + | app.sidebar_sel += 1; |
|
| 345 | + | } |
|
| 346 | + | return; |
|
| 347 | + | } |
|
| 348 | + | KeyCode::Up => { |
|
| 349 | + | app.sidebar_sel = app.sidebar_sel.saturating_sub(1); |
|
| 350 | + | return; |
|
| 351 | + | } |
|
| 352 | + | _ => return, |
|
| 353 | + | } |
|
| 354 | + | app.apply_search(); |
|
| 355 | + | } |
|
| 356 | + | ||
| 357 | + | // ----- Command mode ----- |
|
| 358 | + | ||
| 359 | + | fn handle_command(app: &mut App, key: KeyEvent) { |
|
| 360 | + | match key.code { |
|
| 361 | + | KeyCode::Enter => app.exec_command(), |
|
| 362 | + | KeyCode::Esc => { |
|
| 363 | + | app.command.clear(); |
|
| 364 | + | app.mode = Mode::Normal; |
|
| 365 | + | } |
|
| 366 | + | KeyCode::Char(c) => app.command.push(c), |
|
| 367 | + | KeyCode::Backspace => { |
|
| 368 | + | app.command.pop(); |
|
| 369 | + | } |
|
| 370 | + | _ => {} |
|
| 371 | + | } |
|
| 372 | + | } |
|
| 373 | + | ||
| 374 | + | // ----- Popups ----- |
|
| 375 | + | ||
| 376 | + | fn handle_popup(app: &mut App, key: KeyEvent) { |
|
| 377 | + | // While a popup field is being edited, input goes to the line editor. |
|
| 378 | + | if app.editing.is_some() { |
|
| 379 | + | match key.code { |
|
| 380 | + | KeyCode::Enter => app.commit_edit(), |
|
| 381 | + | KeyCode::Esc => app.cancel_edit(), |
|
| 382 | + | KeyCode::Char(c) => app.input.insert(c), |
|
| 383 | + | KeyCode::Backspace => app.input.backspace(), |
|
| 384 | + | KeyCode::Delete => app.input.delete(), |
|
| 385 | + | KeyCode::Left => app.input.left(), |
|
| 386 | + | KeyCode::Right => app.input.right(), |
|
| 387 | + | _ => {} |
|
| 388 | + | } |
|
| 389 | + | return; |
|
| 390 | + | } |
|
| 391 | + | ||
| 392 | + | match app.popup { |
|
| 393 | + | Popup::Help => match key.code { |
|
| 394 | + | KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') => app.popup = Popup::None, |
|
| 395 | + | // Clamped against the rendered height in `ui::draw_help`. |
|
| 396 | + | KeyCode::Char('j') | KeyCode::Down => app.help_scroll += 1, |
|
| 397 | + | KeyCode::Char('k') | KeyCode::Up => app.help_scroll = app.help_scroll.saturating_sub(1), |
|
| 398 | + | KeyCode::Char('d') => app.help_scroll += 10, |
|
| 399 | + | KeyCode::Char('u') => app.help_scroll = app.help_scroll.saturating_sub(10), |
|
| 400 | + | KeyCode::Char('g') => app.help_scroll = 0, |
|
| 401 | + | KeyCode::Char('G') => app.help_scroll = usize::MAX / 2, |
|
| 402 | + | _ => {} |
|
| 403 | + | }, |
|
| 404 | + | Popup::Env => match key.code { |
|
| 405 | + | KeyCode::Esc | KeyCode::Char('q') => app.popup = Popup::None, |
|
| 406 | + | KeyCode::Char('j') | KeyCode::Down |
|
| 407 | + | if app.env_sel + 1 < app.collection.servers.len() => |
|
| 408 | + | { |
|
| 409 | + | app.env_sel += 1; |
|
| 410 | + | } |
|
| 411 | + | KeyCode::Char('k') | KeyCode::Up => { |
|
| 412 | + | app.env_sel = app.env_sel.saturating_sub(1); |
|
| 413 | + | } |
|
| 414 | + | KeyCode::Enter => { |
|
| 415 | + | if app.env_sel < app.collection.servers.len() { |
|
| 416 | + | app.collection.active_server = app.env_sel; |
|
| 417 | + | app.dirty = true; |
|
| 418 | + | app.status = format!("Base URL: {}", app.collection.servers[app.env_sel]); |
|
| 419 | + | } |
|
| 420 | + | app.popup = Popup::None; |
|
| 421 | + | } |
|
| 422 | + | KeyCode::Char('a') => app.start_edit(EditTarget::EnvNew), |
|
| 423 | + | KeyCode::Char('d') if app.env_sel < app.collection.servers.len() => { |
|
| 424 | + | app.collection.servers.remove(app.env_sel); |
|
| 425 | + | if app.collection.active_server >= app.collection.servers.len() { |
|
| 426 | + | app.collection.active_server = app.collection.servers.len().saturating_sub(1); |
|
| 427 | + | } |
|
| 428 | + | app.env_sel = app.env_sel.saturating_sub(1); |
|
| 429 | + | app.dirty = true; |
|
| 430 | + | } |
|
| 431 | + | _ => {} |
|
| 432 | + | }, |
|
| 433 | + | Popup::Auth => match key.code { |
|
| 434 | + | KeyCode::Esc => { |
|
| 435 | + | app.apply_auth_form(); |
|
| 436 | + | app.popup = Popup::None; |
|
| 437 | + | app.status = "Auth config saved".into(); |
|
| 438 | + | } |
|
| 439 | + | KeyCode::Char('j') | KeyCode::Down | KeyCode::Tab => { |
|
| 440 | + | app.auth_field = (app.auth_field + 1) % App::AUTH_FIELDS.len(); |
|
| 441 | + | } |
|
| 442 | + | KeyCode::Char('k') | KeyCode::Up | KeyCode::BackTab => { |
|
| 443 | + | app.auth_field = |
|
| 444 | + | (app.auth_field + App::AUTH_FIELDS.len() - 1) % App::AUTH_FIELDS.len(); |
|
| 445 | + | } |
|
| 446 | + | KeyCode::Enter | KeyCode::Char('i') => { |
|
| 447 | + | if app.auth_field == 4 { |
|
| 448 | + | app.toggle_auth_style(); |
|
| 449 | + | } else { |
|
| 450 | + | app.start_edit(EditTarget::AuthField(app.auth_field)); |
|
| 451 | + | } |
|
| 452 | + | } |
|
| 453 | + | KeyCode::Char(' ') if app.auth_field == 4 => app.toggle_auth_style(), |
|
| 454 | + | _ => {} |
|
| 455 | + | }, |
|
| 456 | + | Popup::None => {} |
|
| 457 | + | } |
|
| 458 | + | } |
|
| 459 | + | ||
| 460 | + | // Keep KeyModifiers referenced for future Ctrl bindings. |
|
| 461 | + | #[allow(dead_code)] |
|
| 462 | + | fn _has_ctrl(key: &KeyEvent) -> bool { |
|
| 463 | + | key.modifiers.contains(KeyModifiers::CONTROL) |
|
| 464 | + | } |
| 1 | + | pub mod app; |
|
| 2 | + | pub mod highlight; |
|
| 3 | + | pub mod http; |
|
| 4 | + | pub mod input; |
|
| 5 | + | pub mod model; |
|
| 6 | + | pub mod openapi; |
|
| 7 | + | pub mod store; |
|
| 8 | + | pub mod ui; |
| 1 | + | use std::collections::BTreeMap; |
|
| 2 | + | use std::fs; |
|
| 3 | + | use std::io::{self, Write}; |
|
| 4 | + | use std::process::Command as ProcessCommand; |
|
| 5 | + | ||
| 6 | + | use anyhow::{Context, Result, bail}; |
|
| 7 | + | use clap::{Parser, Subcommand}; |
|
| 8 | + | ||
| 9 | + | use cielago::app; |
|
| 10 | + | use cielago::model::Collection; |
|
| 11 | + | use cielago::openapi; |
|
| 12 | + | use cielago::store::{self, AppConfig}; |
|
| 13 | + | ||
| 14 | + | #[derive(Parser)] |
|
| 15 | + | #[command( |
|
| 16 | + | name = "cielago", |
|
| 17 | + | version, |
|
| 18 | + | about = "A Postman-like TUI driven by OpenAPI collections" |
|
| 19 | + | )] |
|
| 20 | + | struct Cli { |
|
| 21 | + | #[command(subcommand)] |
|
| 22 | + | command: Option<Command>, |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | #[derive(Subcommand)] |
|
| 26 | + | enum Command { |
|
| 27 | + | /// Import an OpenAPI 3.x spec (JSON/YAML, file path or URL) as a collection |
|
| 28 | + | Import { |
|
| 29 | + | /// File path or http(s) URL of the spec |
|
| 30 | + | source: String, |
|
| 31 | + | /// Collection name (defaults to the spec's info.title) |
|
| 32 | + | #[arg(long)] |
|
| 33 | + | name: Option<String>, |
|
| 34 | + | }, |
|
| 35 | + | /// Create an empty collection and open it in the TUI |
|
| 36 | + | New { |
|
| 37 | + | /// Collection name |
|
| 38 | + | name: String, |
|
| 39 | + | /// Base URL to start with (becomes the active server) |
|
| 40 | + | #[arg(long, short)] |
|
| 41 | + | server: Option<String>, |
|
| 42 | + | }, |
|
| 43 | + | /// List saved collections |
|
| 44 | + | List { |
|
| 45 | + | /// Show servers, request counts and file paths |
|
| 46 | + | #[arg(short, long)] |
|
| 47 | + | long: bool, |
|
| 48 | + | }, |
|
| 49 | + | /// Open a collection in the TUI (defaults to the last opened one) |
|
| 50 | + | Open { name: Option<String> }, |
|
| 51 | + | /// Delete a saved collection |
|
| 52 | + | Delete { |
|
| 53 | + | name: String, |
|
| 54 | + | /// Skip the confirmation prompt |
|
| 55 | + | #[arg(short, long)] |
|
| 56 | + | force: bool, |
|
| 57 | + | }, |
|
| 58 | + | /// Edit a collection's JSON in $EDITOR |
|
| 59 | + | Edit { name: String }, |
|
| 60 | + | /// Rename a collection (renames its file too) |
|
| 61 | + | Rename { name: String, new_name: String }, |
|
| 62 | + | /// Show details about a collection |
|
| 63 | + | Info { name: String }, |
|
| 64 | + | /// Print the path of a collection's JSON file |
|
| 65 | + | Path { name: String }, |
|
| 66 | + | } |
|
| 67 | + | ||
| 68 | + | #[tokio::main] |
|
| 69 | + | async fn main() -> Result<()> { |
|
| 70 | + | let cli = Cli::parse(); |
|
| 71 | + | match cli.command { |
|
| 72 | + | Some(Command::Import { source, name }) => cmd_import(&source, name).await, |
|
| 73 | + | Some(Command::New { name, server }) => cmd_new(&name, server).await, |
|
| 74 | + | Some(Command::List { long }) => cmd_list(long), |
|
| 75 | + | Some(Command::Open { name }) => cmd_open(name).await, |
|
| 76 | + | Some(Command::Delete { name, force }) => cmd_delete(&name, force), |
|
| 77 | + | Some(Command::Edit { name }) => cmd_edit(&name), |
|
| 78 | + | Some(Command::Rename { name, new_name }) => cmd_rename(&name, &new_name), |
|
| 79 | + | Some(Command::Info { name }) => cmd_info(&name), |
|
| 80 | + | Some(Command::Path { name }) => cmd_path(&name), |
|
| 81 | + | None => cmd_open(None).await, |
|
| 82 | + | } |
|
| 83 | + | } |
|
| 84 | + | ||
| 85 | + | async fn cmd_import(source: &str, name: Option<String>) -> Result<()> { |
|
| 86 | + | let doc = openapi::load_spec(source).await?; |
|
| 87 | + | let name = name |
|
| 88 | + | .or_else(|| { |
|
| 89 | + | doc.pointer("/info/title") |
|
| 90 | + | .and_then(|t| t.as_str()) |
|
| 91 | + | .map(String::from) |
|
| 92 | + | }) |
|
| 93 | + | .unwrap_or_else(|| "imported".to_string()); |
|
| 94 | + | ||
| 95 | + | let collection = openapi::import_spec(&doc, &name, Some(source.to_string())); |
|
| 96 | + | let path = store::save_collection(&collection)?; |
|
| 97 | + | ||
| 98 | + | println!( |
|
| 99 | + | "Imported collection \"{}\" -> {}", |
|
| 100 | + | collection.name, |
|
| 101 | + | path.display() |
|
| 102 | + | ); |
|
| 103 | + | println!(" {} requests", collection.requests.len()); |
|
| 104 | + | if !collection.servers.is_empty() { |
|
| 105 | + | println!(" servers: {}", collection.servers.join(", ")); |
|
| 106 | + | } |
|
| 107 | + | if let Some(auth) = &collection.auth { |
|
| 108 | + | println!( |
|
| 109 | + | " oauth2 client-credentials: {} (set client id/secret with A in the TUI)", |
|
| 110 | + | auth.token_url |
|
| 111 | + | ); |
|
| 112 | + | } |
|
| 113 | + | Ok(()) |
|
| 114 | + | } |
|
| 115 | + | ||
| 116 | + | /// Create an empty collection and drop straight into the TUI to fill it in. |
|
| 117 | + | /// The existence check is on the slug path rather than via |
|
| 118 | + | /// `store::resolve_collection`, which bails by contract on a name that doesn't |
|
| 119 | + | /// exist yet — and the path check also catches names that collide after |
|
| 120 | + | /// slugify, same as `cielago rename`. |
|
| 121 | + | async fn cmd_new(name: &str, server: Option<String>) -> Result<()> { |
|
| 122 | + | let path = store::collection_path(name)?; |
|
| 123 | + | if path.exists() { |
|
| 124 | + | bail!( |
|
| 125 | + | "a collection already exists at {} — open it with `cielago open {name:?}` or pick another name", |
|
| 126 | + | path.display() |
|
| 127 | + | ); |
|
| 128 | + | } |
|
| 129 | + | ||
| 130 | + | let mut collection = Collection::new(name); |
|
| 131 | + | if let Some(url) = server { |
|
| 132 | + | // Trailing slash trimmed to match imported servers, so pasting a URL in |
|
| 133 | + | // the TUI later recognises this one instead of adding a duplicate. |
|
| 134 | + | let url = url.trim().trim_end_matches('/').to_string(); |
|
| 135 | + | if !url.is_empty() { |
|
| 136 | + | collection.servers.push(url); |
|
| 137 | + | } |
|
| 138 | + | } |
|
| 139 | + | let path = store::save_collection(&collection)?; |
|
| 140 | + | println!( |
|
| 141 | + | "Created collection \"{}\" -> {}", |
|
| 142 | + | collection.name, |
|
| 143 | + | path.display() |
|
| 144 | + | ); |
|
| 145 | + | ||
| 146 | + | let mut config = AppConfig::load(); |
|
| 147 | + | config.last_collection = Some(collection.name.clone()); |
|
| 148 | + | let _ = config.save(); |
|
| 149 | + | app::run(collection, path, config).await |
|
| 150 | + | } |
|
| 151 | + | ||
| 152 | + | fn cmd_list(long: bool) -> Result<()> { |
|
| 153 | + | let names = store::list_collections()?; |
|
| 154 | + | if names.is_empty() { |
|
| 155 | + | println!( |
|
| 156 | + | "No collections yet. Import one: cielago import <spec>\n\ |
|
| 157 | + | …or start from scratch: cielago new <name>" |
|
| 158 | + | ); |
|
| 159 | + | return Ok(()); |
|
| 160 | + | } |
|
| 161 | + | let last = AppConfig::load().last_collection; |
|
| 162 | + | for n in names { |
|
| 163 | + | if !long { |
|
| 164 | + | println!("{n}"); |
|
| 165 | + | continue; |
|
| 166 | + | } |
|
| 167 | + | let marker = if last.as_deref() == Some(n.as_str()) { |
|
| 168 | + | "*" |
|
| 169 | + | } else { |
|
| 170 | + | " " |
|
| 171 | + | }; |
|
| 172 | + | let path = store::collection_path(&n)?; |
|
| 173 | + | match store::load_collection(&n) { |
|
| 174 | + | Ok(c) => println!( |
|
| 175 | + | "{marker} {n}\n {} requests, {} server(s){}\n {}", |
|
| 176 | + | c.requests.len(), |
|
| 177 | + | c.servers.len(), |
|
| 178 | + | if c.auth.is_some() { ", oauth2" } else { "" }, |
|
| 179 | + | path.display() |
|
| 180 | + | ), |
|
| 181 | + | Err(e) => println!("{marker} {n}\n unreadable: {e}\n {}", path.display()), |
|
| 182 | + | } |
|
| 183 | + | } |
|
| 184 | + | Ok(()) |
|
| 185 | + | } |
|
| 186 | + | ||
| 187 | + | fn cmd_delete(name: &str, force: bool) -> Result<()> { |
|
| 188 | + | let name = store::resolve_collection(name)?; |
|
| 189 | + | let collection = store::load_collection(&name).ok(); |
|
| 190 | + | let path = store::collection_path(&name)?; |
|
| 191 | + | ||
| 192 | + | if !force { |
|
| 193 | + | let count = collection |
|
| 194 | + | .as_ref() |
|
| 195 | + | .map(|c| format!(" ({} requests)", c.requests.len())) |
|
| 196 | + | .unwrap_or_default(); |
|
| 197 | + | print!("Delete collection \"{name}\"{count}? [y/N] "); |
|
| 198 | + | io::stdout().flush()?; |
|
| 199 | + | let mut answer = String::new(); |
|
| 200 | + | io::stdin().read_line(&mut answer)?; |
|
| 201 | + | if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") { |
|
| 202 | + | println!("Aborted."); |
|
| 203 | + | return Ok(()); |
|
| 204 | + | } |
|
| 205 | + | } |
|
| 206 | + | ||
| 207 | + | store::delete_collection(&name)?; |
|
| 208 | + | let mut config = AppConfig::load(); |
|
| 209 | + | if config.last_collection.as_deref() == Some(name.as_str()) { |
|
| 210 | + | config.last_collection = None; |
|
| 211 | + | let _ = config.save(); |
|
| 212 | + | } |
|
| 213 | + | println!("Deleted \"{name}\" ({})", path.display()); |
|
| 214 | + | Ok(()) |
|
| 215 | + | } |
|
| 216 | + | ||
| 217 | + | /// Edit the collection JSON in `$EDITOR`. The edit happens on a temp copy so a |
|
| 218 | + | /// file that no longer parses never replaces the saved one; a `name` changed in |
|
| 219 | + | /// the editor moves the file, same as `cielago rename`. |
|
| 220 | + | fn cmd_edit(name: &str) -> Result<()> { |
|
| 221 | + | let name = store::resolve_collection(name)?; |
|
| 222 | + | let path = store::collection_path(&name)?; |
|
| 223 | + | let original = |
|
| 224 | + | fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; |
|
| 225 | + | ||
| 226 | + | let mut tmp = std::env::temp_dir(); |
|
| 227 | + | tmp.push(format!( |
|
| 228 | + | "cielago-{}-{}.json", |
|
| 229 | + | store::slugify(&name), |
|
| 230 | + | std::process::id() |
|
| 231 | + | )); |
|
| 232 | + | fs::write(&tmp, &original)?; |
|
| 233 | + | ||
| 234 | + | let editor = AppConfig::load().editor_cmd(); |
|
| 235 | + | let mut parts = editor.split_whitespace(); |
|
| 236 | + | let program = parts.next().unwrap_or("vi"); |
|
| 237 | + | let status = ProcessCommand::new(program) |
|
| 238 | + | .args(parts) |
|
| 239 | + | .arg(&tmp) |
|
| 240 | + | .status() |
|
| 241 | + | .with_context(|| format!("launching editor {editor:?}"))?; |
|
| 242 | + | if !status.success() { |
|
| 243 | + | let _ = fs::remove_file(&tmp); |
|
| 244 | + | bail!("editor exited with {status}; collection left unchanged"); |
|
| 245 | + | } |
|
| 246 | + | ||
| 247 | + | let edited = fs::read_to_string(&tmp)?; |
|
| 248 | + | if edited == original { |
|
| 249 | + | let _ = fs::remove_file(&tmp); |
|
| 250 | + | println!("No changes."); |
|
| 251 | + | return Ok(()); |
|
| 252 | + | } |
|
| 253 | + | ||
| 254 | + | let collection: Collection = match serde_json::from_str(&edited) { |
|
| 255 | + | Ok(c) => c, |
|
| 256 | + | Err(e) => bail!( |
|
| 257 | + | "edited JSON is not a valid collection: {e}\n\nYour edits are kept at {}; the saved collection is unchanged.", |
|
| 258 | + | tmp.display() |
|
| 259 | + | ), |
|
| 260 | + | }; |
|
| 261 | + | let new_path = store::collection_path(&collection.name)?; |
|
| 262 | + | if new_path != path && new_path.exists() { |
|
| 263 | + | bail!( |
|
| 264 | + | "renaming to {:?} would overwrite the collection at {}.\n\nYour edits are kept at {}; the saved collection is unchanged.", |
|
| 265 | + | collection.name, |
|
| 266 | + | new_path.display(), |
|
| 267 | + | tmp.display() |
|
| 268 | + | ); |
|
| 269 | + | } |
|
| 270 | + | let _ = fs::remove_file(&tmp); |
|
| 271 | + | ||
| 272 | + | store::save_collection(&collection)?; |
|
| 273 | + | if new_path != path { |
|
| 274 | + | fs::remove_file(&path).ok(); |
|
| 275 | + | update_last_collection(&name, &collection.name); |
|
| 276 | + | println!( |
|
| 277 | + | "Saved \"{}\" -> {} (was \"{name}\")", |
|
| 278 | + | collection.name, |
|
| 279 | + | new_path.display() |
|
| 280 | + | ); |
|
| 281 | + | } else { |
|
| 282 | + | println!("Saved \"{}\" -> {}", collection.name, new_path.display()); |
|
| 283 | + | } |
|
| 284 | + | Ok(()) |
|
| 285 | + | } |
|
| 286 | + | ||
| 287 | + | fn cmd_rename(name: &str, new_name: &str) -> Result<()> { |
|
| 288 | + | let name = store::resolve_collection(name)?; |
|
| 289 | + | let mut collection = store::load_collection(&name)?; |
|
| 290 | + | let old_path = store::collection_path(&name)?; |
|
| 291 | + | let new_path = store::collection_path(new_name)?; |
|
| 292 | + | ||
| 293 | + | if new_path != old_path && new_path.exists() { |
|
| 294 | + | bail!( |
|
| 295 | + | "a collection already exists at {} — pick another name", |
|
| 296 | + | new_path.display() |
|
| 297 | + | ); |
|
| 298 | + | } |
|
| 299 | + | ||
| 300 | + | collection.name = new_name.to_string(); |
|
| 301 | + | store::save_collection(&collection)?; |
|
| 302 | + | if new_path != old_path { |
|
| 303 | + | fs::remove_file(&old_path).ok(); |
|
| 304 | + | } |
|
| 305 | + | update_last_collection(&name, new_name); |
|
| 306 | + | println!( |
|
| 307 | + | "Renamed \"{name}\" -> \"{new_name}\" ({})", |
|
| 308 | + | new_path.display() |
|
| 309 | + | ); |
|
| 310 | + | Ok(()) |
|
| 311 | + | } |
|
| 312 | + | ||
| 313 | + | fn cmd_info(name: &str) -> Result<()> { |
|
| 314 | + | let name = store::resolve_collection(name)?; |
|
| 315 | + | let collection = store::load_collection(&name)?; |
|
| 316 | + | let path = store::collection_path(&name)?; |
|
| 317 | + | ||
| 318 | + | println!("{}", collection.name); |
|
| 319 | + | println!(" file: {}", path.display()); |
|
| 320 | + | if let Some(src) = &collection.spec_source { |
|
| 321 | + | println!(" spec: {src}"); |
|
| 322 | + | } |
|
| 323 | + | if collection.servers.is_empty() { |
|
| 324 | + | println!(" servers: (none)"); |
|
| 325 | + | } else { |
|
| 326 | + | for (i, s) in collection.servers.iter().enumerate() { |
|
| 327 | + | let marker = if i == collection.active_server { |
|
| 328 | + | "*" |
|
| 329 | + | } else { |
|
| 330 | + | " " |
|
| 331 | + | }; |
|
| 332 | + | println!(" server{marker} {s}"); |
|
| 333 | + | } |
|
| 334 | + | } |
|
| 335 | + | println!(" requests: {}", collection.requests.len()); |
|
| 336 | + | println!(" variables: {}", collection.variables.len()); |
|
| 337 | + | match &collection.auth { |
|
| 338 | + | Some(auth) => println!( |
|
| 339 | + | " auth: oauth2 client-credentials, token url {} ({} client id)", |
|
| 340 | + | auth.token_url, |
|
| 341 | + | if auth.client_id.is_empty() { |
|
| 342 | + | "no" |
|
| 343 | + | } else { |
|
| 344 | + | "has" |
|
| 345 | + | } |
|
| 346 | + | ), |
|
| 347 | + | None => println!(" auth: none"), |
|
| 348 | + | } |
|
| 349 | + | ||
| 350 | + | let mut groups: BTreeMap<&str, usize> = BTreeMap::new(); |
|
| 351 | + | for r in &collection.requests { |
|
| 352 | + | *groups |
|
| 353 | + | .entry(r.tags.first().map(String::as_str).unwrap_or("default")) |
|
| 354 | + | .or_default() += 1; |
|
| 355 | + | } |
|
| 356 | + | if !groups.is_empty() { |
|
| 357 | + | println!(" groups:"); |
|
| 358 | + | for (group, count) in groups { |
|
| 359 | + | println!(" {group} ({count})"); |
|
| 360 | + | } |
|
| 361 | + | } |
|
| 362 | + | Ok(()) |
|
| 363 | + | } |
|
| 364 | + | ||
| 365 | + | fn cmd_path(name: &str) -> Result<()> { |
|
| 366 | + | let name = store::resolve_collection(name)?; |
|
| 367 | + | println!("{}", store::collection_path(&name)?.display()); |
|
| 368 | + | Ok(()) |
|
| 369 | + | } |
|
| 370 | + | ||
| 371 | + | /// Keep `config.last_collection` pointing at a collection that was renamed. |
|
| 372 | + | fn update_last_collection(old: &str, new: &str) { |
|
| 373 | + | let mut config = AppConfig::load(); |
|
| 374 | + | if config.last_collection.as_deref() == Some(old) { |
|
| 375 | + | config.last_collection = Some(new.to_string()); |
|
| 376 | + | let _ = config.save(); |
|
| 377 | + | } |
|
| 378 | + | } |
|
| 379 | + | ||
| 380 | + | async fn cmd_open(name: Option<String>) -> Result<()> { |
|
| 381 | + | let mut config = AppConfig::load(); |
|
| 382 | + | let name = match name.or_else(|| config.last_collection.clone()) { |
|
| 383 | + | Some(n) => n, |
|
| 384 | + | None => { |
|
| 385 | + | let names = store::list_collections()?; |
|
| 386 | + | match names.as_slice() { |
|
| 387 | + | [] => bail!( |
|
| 388 | + | "No collections yet. Import one first:\n\n cielago import <spec.json|yaml|url>\n\nOr create an empty one:\n\n cielago new <name>" |
|
| 389 | + | ), |
|
| 390 | + | [only] => only.clone(), |
|
| 391 | + | many => bail!( |
|
| 392 | + | "Multiple collections exist; choose one:\n\n cielago open <name>\n\nAvailable: {}", |
|
| 393 | + | many.join(", ") |
|
| 394 | + | ), |
|
| 395 | + | } |
|
| 396 | + | } |
|
| 397 | + | }; |
|
| 398 | + | ||
| 399 | + | let collection = |
|
| 400 | + | store::load_collection(&name).with_context(|| format!("loading collection {name:?}"))?; |
|
| 401 | + | config.last_collection = Some(collection.name.clone()); |
|
| 402 | + | let _ = config.save(); |
|
| 403 | + | let path = store::collection_path(&collection.name)?; |
|
| 404 | + | app::run(collection, path, config).await |
|
| 405 | + | } |
| 1 | + | use std::collections::HashMap; |
|
| 2 | + | use std::fmt; |
|
| 3 | + | ||
| 4 | + | use serde::{Deserialize, Serialize}; |
|
| 5 | + | use uuid::Uuid; |
|
| 6 | + | ||
| 7 | + | // The saved view records which pane and editor tab were open, so the two |
|
| 8 | + | // enums that describe them are re-used here rather than mirrored. |
|
| 9 | + | use crate::app::{EditorTab, Focus}; |
|
| 10 | + | ||
| 11 | + | /// Collection-level `{{variables}}` as an ordered, editable list. |
|
| 12 | + | pub type Variables = Vec<KeyValueRow>; |
|
| 13 | + | ||
| 14 | + | pub fn variables_map(vars: &Variables) -> HashMap<String, String> { |
|
| 15 | + | vars.iter() |
|
| 16 | + | .filter(|r| r.enabled && !r.key.is_empty()) |
|
| 17 | + | .map(|r| (r.key.clone(), r.value.clone())) |
|
| 18 | + | .collect() |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | /// Names inside single `{…}` in a path template, deduped, in order of first |
|
| 22 | + | /// appearance. `{{var}}` is variable syntax rather than a path param and is |
|
| 23 | + | /// skipped, so `/{{tenant}}/pets/{petId}` yields just `petId`. |
|
| 24 | + | pub fn path_placeholders(path: &str) -> Vec<String> { |
|
| 25 | + | let bytes = path.as_bytes(); |
|
| 26 | + | let mut names: Vec<String> = Vec::new(); |
|
| 27 | + | let mut i = 0; |
|
| 28 | + | while i < bytes.len() { |
|
| 29 | + | if bytes[i] != b'{' { |
|
| 30 | + | i += 1; |
|
| 31 | + | continue; |
|
| 32 | + | } |
|
| 33 | + | // `{{…}}` is a variable; skip past its closing braces entirely. |
|
| 34 | + | if bytes.get(i + 1) == Some(&b'{') { |
|
| 35 | + | match path[i + 2..].find("}}") { |
|
| 36 | + | Some(off) => i += 2 + off + 2, |
|
| 37 | + | None => break, |
|
| 38 | + | } |
|
| 39 | + | continue; |
|
| 40 | + | } |
|
| 41 | + | let Some(off) = path[i + 1..].find('}') else { |
|
| 42 | + | break; |
|
| 43 | + | }; |
|
| 44 | + | let name = path[i + 1..i + 1 + off].trim(); |
|
| 45 | + | if !name.is_empty() && !name.contains('/') && !names.iter().any(|n| n == name) { |
|
| 46 | + | names.push(name.to_string()); |
|
| 47 | + | } |
|
| 48 | + | i += 1 + off + 1; |
|
| 49 | + | } |
|
| 50 | + | names |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | #[derive(Debug, Clone, Serialize, Deserialize)] |
|
| 54 | + | pub struct Collection { |
|
| 55 | + | pub name: String, |
|
| 56 | + | /// Path or URL the spec was imported from, if any. |
|
| 57 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 58 | + | pub spec_source: Option<String>, |
|
| 59 | + | #[serde(default)] |
|
| 60 | + | pub servers: Vec<String>, |
|
| 61 | + | #[serde(default)] |
|
| 62 | + | pub active_server: usize, |
|
| 63 | + | #[serde(default)] |
|
| 64 | + | pub variables: Variables, |
|
| 65 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 66 | + | pub auth: Option<OAuthConfig>, |
|
| 67 | + | /// How the sidebar labels requests; persisted with the collection. |
|
| 68 | + | #[serde(default)] |
|
| 69 | + | pub label_mode: LabelMode, |
|
| 70 | + | /// Whether tag groups start collapsed when the collection is opened. |
|
| 71 | + | #[serde(default)] |
|
| 72 | + | pub groups_collapsed: bool, |
|
| 73 | + | /// The request that was open the last time the collection was saved, so |
|
| 74 | + | /// reopening it lands back on the same page. `None` for collections saved |
|
| 75 | + | /// before this existed, or saved with nothing selected. |
|
| 76 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 77 | + | pub last_request: Option<Uuid>, |
|
| 78 | + | /// Pane (`1`/`2`/`3`) that had focus at the last save. |
|
| 79 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 80 | + | pub last_focus: Option<Focus>, |
|
| 81 | + | /// Editor tab that was open at the last save. |
|
| 82 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 83 | + | pub last_tab: Option<EditorTab>, |
|
| 84 | + | #[serde(default)] |
|
| 85 | + | pub requests: Vec<SavedRequest>, |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | impl Collection { |
|
| 89 | + | pub fn new(name: impl Into<String>) -> Self { |
|
| 90 | + | Self { |
|
| 91 | + | name: name.into(), |
|
| 92 | + | spec_source: None, |
|
| 93 | + | servers: Vec::new(), |
|
| 94 | + | active_server: 0, |
|
| 95 | + | variables: Vec::new(), |
|
| 96 | + | auth: None, |
|
| 97 | + | label_mode: LabelMode::default(), |
|
| 98 | + | groups_collapsed: false, |
|
| 99 | + | last_request: None, |
|
| 100 | + | last_focus: None, |
|
| 101 | + | last_tab: None, |
|
| 102 | + | requests: Vec::new(), |
|
| 103 | + | } |
|
| 104 | + | } |
|
| 105 | + | ||
| 106 | + | pub fn base_url(&self) -> Option<&str> { |
|
| 107 | + | self.servers.get(self.active_server).map(|s| s.as_str()) |
|
| 108 | + | } |
|
| 109 | + | } |
|
| 110 | + | ||
| 111 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] |
|
| 112 | + | #[serde(rename_all = "lowercase")] |
|
| 113 | + | pub enum AuthStyle { |
|
| 114 | + | /// client_id/client_secret sent via HTTP Basic header (RFC 6749 default). |
|
| 115 | + | #[default] |
|
| 116 | + | Basic, |
|
| 117 | + | /// client_id/client_secret sent in the form body. |
|
| 118 | + | Post, |
|
| 119 | + | } |
|
| 120 | + | ||
| 121 | + | #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] |
|
| 122 | + | pub struct OAuthConfig { |
|
| 123 | + | #[serde(default)] |
|
| 124 | + | pub token_url: String, |
|
| 125 | + | #[serde(default)] |
|
| 126 | + | pub client_id: String, |
|
| 127 | + | #[serde(default)] |
|
| 128 | + | pub client_secret: String, |
|
| 129 | + | #[serde(default)] |
|
| 130 | + | pub scopes: Vec<String>, |
|
| 131 | + | #[serde(default)] |
|
| 132 | + | pub auth_style: AuthStyle, |
|
| 133 | + | } |
|
| 134 | + | ||
| 135 | + | impl OAuthConfig { |
|
| 136 | + | pub fn is_configured(&self) -> bool { |
|
| 137 | + | !self.token_url.is_empty() && !self.client_id.is_empty() |
|
| 138 | + | } |
|
| 139 | + | } |
|
| 140 | + | ||
| 141 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
|
| 142 | + | #[serde(rename_all = "UPPERCASE")] |
|
| 143 | + | pub enum Method { |
|
| 144 | + | Get, |
|
| 145 | + | Post, |
|
| 146 | + | Put, |
|
| 147 | + | Patch, |
|
| 148 | + | Delete, |
|
| 149 | + | Head, |
|
| 150 | + | Options, |
|
| 151 | + | } |
|
| 152 | + | ||
| 153 | + | impl Method { |
|
| 154 | + | pub fn parse(s: &str) -> Option<Self> { |
|
| 155 | + | Some(match s.to_ascii_lowercase().as_str() { |
|
| 156 | + | "get" => Self::Get, |
|
| 157 | + | "post" => Self::Post, |
|
| 158 | + | "put" => Self::Put, |
|
| 159 | + | "patch" => Self::Patch, |
|
| 160 | + | "delete" => Self::Delete, |
|
| 161 | + | "head" => Self::Head, |
|
| 162 | + | "options" => Self::Options, |
|
| 163 | + | _ => return None, |
|
| 164 | + | }) |
|
| 165 | + | } |
|
| 166 | + | } |
|
| 167 | + | ||
| 168 | + | impl fmt::Display for Method { |
|
| 169 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
|
| 170 | + | let s = match self { |
|
| 171 | + | Self::Get => "GET", |
|
| 172 | + | Self::Post => "POST", |
|
| 173 | + | Self::Put => "PUT", |
|
| 174 | + | Self::Patch => "PATCH", |
|
| 175 | + | Self::Delete => "DELETE", |
|
| 176 | + | Self::Head => "HEAD", |
|
| 177 | + | Self::Options => "OPTIONS", |
|
| 178 | + | }; |
|
| 179 | + | f.write_str(s) |
|
| 180 | + | } |
|
| 181 | + | } |
|
| 182 | + | ||
| 183 | + | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
|
| 184 | + | pub struct KeyValueRow { |
|
| 185 | + | pub key: String, |
|
| 186 | + | #[serde(default)] |
|
| 187 | + | pub value: String, |
|
| 188 | + | #[serde(default = "default_enabled")] |
|
| 189 | + | pub enabled: bool, |
|
| 190 | + | } |
|
| 191 | + | ||
| 192 | + | fn default_enabled() -> bool { |
|
| 193 | + | true |
|
| 194 | + | } |
|
| 195 | + | ||
| 196 | + | impl KeyValueRow { |
|
| 197 | + | pub fn new(key: impl Into<String>, value: impl Into<String>, enabled: bool) -> Self { |
|
| 198 | + | Self { |
|
| 199 | + | key: key.into(), |
|
| 200 | + | value: value.into(), |
|
| 201 | + | enabled, |
|
| 202 | + | } |
|
| 203 | + | } |
|
| 204 | + | } |
|
| 205 | + | ||
| 206 | + | /// Spec-derived documentation for one input to a request: a parameter, or a |
|
| 207 | + | /// field of the request body. Stored on the request (rather than looked up in |
|
| 208 | + | /// the spec on demand) so the Docs tab works for collections whose spec is a |
|
| 209 | + | /// URL that may be gone, moved or behind auth by the time you open them. |
|
| 210 | + | #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] |
|
| 211 | + | pub struct FieldDoc { |
|
| 212 | + | /// Parameter name, or dotted path for a body field (`owner.address[].zip`). |
|
| 213 | + | pub name: String, |
|
| 214 | + | /// `path`, `query`, `header` or `body`. |
|
| 215 | + | pub location: String, |
|
| 216 | + | /// Rendered type, e.g. `string(uuid)`, `integer`, `array<string>`. |
|
| 217 | + | #[serde(default)] |
|
| 218 | + | pub ty: String, |
|
| 219 | + | #[serde(default)] |
|
| 220 | + | pub required: bool, |
|
| 221 | + | /// `enum` values — the "options" this field accepts. |
|
| 222 | + | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| 223 | + | pub options: Vec<String>, |
|
| 224 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 225 | + | pub description: Option<String>, |
|
| 226 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 227 | + | pub default: Option<String>, |
|
| 228 | + | } |
|
| 229 | + | ||
| 230 | + | /// How the sidebar labels a request. Spec-derived names (`operationId`) are |
|
| 231 | + | /// often long and unreadable, so the label is a view concern, independent of |
|
| 232 | + | /// the stored `name`. |
|
| 233 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] |
|
| 234 | + | #[serde(rename_all = "lowercase")] |
|
| 235 | + | pub enum LabelMode { |
|
| 236 | + | /// The request's `name` (renameable with `r`). |
|
| 237 | + | #[default] |
|
| 238 | + | Name, |
|
| 239 | + | /// The spec's `summary`, falling back to `name`. |
|
| 240 | + | Summary, |
|
| 241 | + | /// The path template, e.g. `/pets/{petId}`. |
|
| 242 | + | Path, |
|
| 243 | + | } |
|
| 244 | + | ||
| 245 | + | impl LabelMode { |
|
| 246 | + | pub const ALL: [LabelMode; 3] = [LabelMode::Name, LabelMode::Summary, LabelMode::Path]; |
|
| 247 | + | ||
| 248 | + | pub fn next(self) -> Self { |
|
| 249 | + | let i = Self::ALL.iter().position(|m| *m == self).unwrap_or(0); |
|
| 250 | + | Self::ALL[(i + 1) % Self::ALL.len()] |
|
| 251 | + | } |
|
| 252 | + | ||
| 253 | + | pub fn title(self) -> &'static str { |
|
| 254 | + | match self { |
|
| 255 | + | LabelMode::Name => "name", |
|
| 256 | + | LabelMode::Summary => "summary", |
|
| 257 | + | LabelMode::Path => "path", |
|
| 258 | + | } |
|
| 259 | + | } |
|
| 260 | + | } |
|
| 261 | + | ||
| 262 | + | #[derive(Debug, Clone, Serialize, Deserialize)] |
|
| 263 | + | pub struct SavedRequest { |
|
| 264 | + | pub id: Uuid, |
|
| 265 | + | pub name: String, |
|
| 266 | + | /// The spec's `summary` for this operation, kept so the sidebar can label |
|
| 267 | + | /// requests by it without destroying a user-chosen `name`. |
|
| 268 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 269 | + | pub summary: Option<String>, |
|
| 270 | + | /// The spec's `operationId`, kept for the same reason. |
|
| 271 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 272 | + | pub operation_id: Option<String>, |
|
| 273 | + | /// The spec's operation `description`, shown in the Docs tab. |
|
| 274 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 275 | + | pub description: Option<String>, |
|
| 276 | + | /// Types, enums and descriptions for params and body fields (Docs tab). |
|
| 277 | + | /// Empty for hand-made requests and for collections imported before this |
|
| 278 | + | /// existed — re-importing the spec fills it in. |
|
| 279 | + | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| 280 | + | pub docs: Vec<FieldDoc>, |
|
| 281 | + | pub method: Method, |
|
| 282 | + | /// Path template, may contain `{param}` placeholders and `{{variables}}`. |
|
| 283 | + | pub path: String, |
|
| 284 | + | #[serde(default)] |
|
| 285 | + | pub path_params: Vec<KeyValueRow>, |
|
| 286 | + | #[serde(default)] |
|
| 287 | + | pub query: Vec<KeyValueRow>, |
|
| 288 | + | #[serde(default)] |
|
| 289 | + | pub headers: Vec<KeyValueRow>, |
|
| 290 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 291 | + | pub body: Option<String>, |
|
| 292 | + | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| 293 | + | pub tags: Vec<String>, |
|
| 294 | + | } |
|
| 295 | + | ||
| 296 | + | impl SavedRequest { |
|
| 297 | + | pub fn blank(name: impl Into<String>) -> Self { |
|
| 298 | + | Self { |
|
| 299 | + | id: Uuid::new_v4(), |
|
| 300 | + | name: name.into(), |
|
| 301 | + | summary: None, |
|
| 302 | + | operation_id: None, |
|
| 303 | + | description: None, |
|
| 304 | + | docs: Vec::new(), |
|
| 305 | + | method: Method::Get, |
|
| 306 | + | path: "/".into(), |
|
| 307 | + | path_params: Vec::new(), |
|
| 308 | + | query: Vec::new(), |
|
| 309 | + | headers: Vec::new(), |
|
| 310 | + | body: None, |
|
| 311 | + | tags: Vec::new(), |
|
| 312 | + | } |
|
| 313 | + | } |
|
| 314 | + | ||
| 315 | + | /// Rewrite `path_params` to hold exactly one row per `{placeholder}` in |
|
| 316 | + | /// `path`, in path order. Surviving rows keep their value and `enabled` |
|
| 317 | + | /// flag; rows whose placeholder is gone are dropped, since |
|
| 318 | + | /// [`crate::http::client`]'s `build_url` ignores them anyway and a stale |
|
| 319 | + | /// row only makes the Params table lie. Returns whether anything changed. |
|
| 320 | + | pub fn sync_path_params(&mut self) -> bool { |
|
| 321 | + | let names = path_placeholders(&self.path); |
|
| 322 | + | let synced: Vec<KeyValueRow> = names |
|
| 323 | + | .iter() |
|
| 324 | + | .map(|name| { |
|
| 325 | + | self.path_params |
|
| 326 | + | .iter() |
|
| 327 | + | .find(|r| &r.key == name) |
|
| 328 | + | .cloned() |
|
| 329 | + | .unwrap_or_else(|| KeyValueRow::new(name.clone(), "", true)) |
|
| 330 | + | }) |
|
| 331 | + | .collect(); |
|
| 332 | + | let changed = synced != self.path_params; |
|
| 333 | + | if changed { |
|
| 334 | + | self.path_params = synced; |
|
| 335 | + | } |
|
| 336 | + | changed |
|
| 337 | + | } |
|
| 338 | + | ||
| 339 | + | /// Sidebar label under the collection's current [`LabelMode`]. Every mode |
|
| 340 | + | /// falls back to something non-empty so rows are never blank. |
|
| 341 | + | pub fn label(&self, mode: LabelMode) -> &str { |
|
| 342 | + | let candidate = match mode { |
|
| 343 | + | LabelMode::Name => Some(self.name.as_str()), |
|
| 344 | + | LabelMode::Summary => self.summary.as_deref(), |
|
| 345 | + | LabelMode::Path => Some(self.path.as_str()), |
|
| 346 | + | }; |
|
| 347 | + | candidate |
|
| 348 | + | .filter(|s| !s.is_empty()) |
|
| 349 | + | .unwrap_or(if self.name.is_empty() { |
|
| 350 | + | self.path.as_str() |
|
| 351 | + | } else { |
|
| 352 | + | self.name.as_str() |
|
| 353 | + | }) |
|
| 354 | + | } |
|
| 355 | + | ||
| 356 | + | /// Lowercased haystack for sidebar search: label fields plus method/tags. |
|
| 357 | + | pub fn matches(&self, needle: &str) -> bool { |
|
| 358 | + | let needle = needle.to_ascii_lowercase(); |
|
| 359 | + | let fields = [ |
|
| 360 | + | self.name.as_str(), |
|
| 361 | + | self.path.as_str(), |
|
| 362 | + | self.summary.as_deref().unwrap_or(""), |
|
| 363 | + | self.operation_id.as_deref().unwrap_or(""), |
|
| 364 | + | ]; |
|
| 365 | + | fields |
|
| 366 | + | .iter() |
|
| 367 | + | .any(|f| f.to_ascii_lowercase().contains(&needle)) |
|
| 368 | + | || self |
|
| 369 | + | .method |
|
| 370 | + | .to_string() |
|
| 371 | + | .to_ascii_lowercase() |
|
| 372 | + | .contains(&needle) |
|
| 373 | + | || self |
|
| 374 | + | .tags |
|
| 375 | + | .iter() |
|
| 376 | + | .any(|t| t.to_ascii_lowercase().contains(&needle)) |
|
| 377 | + | } |
|
| 378 | + | } |
|
| 379 | + | ||
| 380 | + | #[cfg(test)] |
|
| 381 | + | mod tests { |
|
| 382 | + | use super::*; |
|
| 383 | + | ||
| 384 | + | fn keys(rows: &[KeyValueRow]) -> Vec<&str> { |
|
| 385 | + | rows.iter().map(|r| r.key.as_str()).collect() |
|
| 386 | + | } |
|
| 387 | + | ||
| 388 | + | #[test] |
|
| 389 | + | fn path_placeholders_finds_single_braces() { |
|
| 390 | + | assert_eq!( |
|
| 391 | + | path_placeholders("/pets/{petId}/photos/{photoId}"), |
|
| 392 | + | vec!["petId", "photoId"] |
|
| 393 | + | ); |
|
| 394 | + | assert!(path_placeholders("/pets").is_empty()); |
|
| 395 | + | // Deduped, and blank or path-spanning braces are ignored. |
|
| 396 | + | assert_eq!(path_placeholders("/a/{id}/b/{id}"), vec!["id"]); |
|
| 397 | + | assert!(path_placeholders("/a/{}/b").is_empty()); |
|
| 398 | + | assert!(path_placeholders("/a/{oops/b}").is_empty()); |
|
| 399 | + | // An unterminated brace ends the scan rather than looping. |
|
| 400 | + | assert!(path_placeholders("/pets/{petId").is_empty()); |
|
| 401 | + | } |
|
| 402 | + | ||
| 403 | + | #[test] |
|
| 404 | + | fn path_placeholders_skips_double_brace_variables() { |
|
| 405 | + | assert_eq!(path_placeholders("/{{tenant}}/pets/{petId}"), vec!["petId"]); |
|
| 406 | + | assert!(path_placeholders("{{baseUrl}}/pets").is_empty()); |
|
| 407 | + | assert!(path_placeholders("/a/{{unterminated").is_empty()); |
|
| 408 | + | } |
|
| 409 | + | ||
| 410 | + | #[test] |
|
| 411 | + | fn sync_path_params_adds_prunes_and_reorders() { |
|
| 412 | + | let mut req = SavedRequest::blank("r"); |
|
| 413 | + | req.path = "/orgs/{orgId}/pets/{petId}".into(); |
|
| 414 | + | req.path_params = vec![ |
|
| 415 | + | KeyValueRow::new("petId", "42", true), |
|
| 416 | + | KeyValueRow::new("stale", "x", true), |
|
| 417 | + | ]; |
|
| 418 | + | ||
| 419 | + | assert!(req.sync_path_params()); |
|
| 420 | + | assert_eq!(keys(&req.path_params), vec!["orgId", "petId"]); |
|
| 421 | + | // A second call is a no-op. |
|
| 422 | + | assert!(!req.sync_path_params()); |
|
| 423 | + | ||
| 424 | + | req.path = "/pets".into(); |
|
| 425 | + | assert!(req.sync_path_params()); |
|
| 426 | + | assert!(req.path_params.is_empty()); |
|
| 427 | + | } |
|
| 428 | + | ||
| 429 | + | #[test] |
|
| 430 | + | fn sync_path_params_keeps_existing_values_and_flags() { |
|
| 431 | + | let mut req = SavedRequest::blank("r"); |
|
| 432 | + | req.path = "/pets/{petId}".into(); |
|
| 433 | + | req.path_params = vec![KeyValueRow::new("petId", "42", false)]; |
|
| 434 | + | ||
| 435 | + | assert!(!req.sync_path_params()); |
|
| 436 | + | assert_eq!(req.path_params[0].value, "42"); |
|
| 437 | + | assert!(!req.path_params[0].enabled); |
|
| 438 | + | } |
|
| 439 | + | } |
| 1 | + | //! Turning schemas into the [`FieldDoc`]s the Docs tab renders: what type a |
|
| 2 | + | //! parameter or body field is, whether it's required, and which values it |
|
| 3 | + | //! accepts. |
|
| 4 | + | //! |
|
| 5 | + | //! This is a summary, not a spec viewer — the aim is answering "what can I put |
|
| 6 | + | //! here?" without leaving the terminal. |
|
| 7 | + | ||
| 8 | + | use std::collections::HashSet; |
|
| 9 | + | ||
| 10 | + | use serde_json::Value; |
|
| 11 | + | ||
| 12 | + | use super::resolve::deref; |
|
| 13 | + | use crate::model::FieldDoc; |
|
| 14 | + | ||
| 15 | + | /// Nesting cap when flattening a body schema. Also what terminates recursive |
|
| 16 | + | /// schemas, the same way [`super::examples`] caps generation depth. |
|
| 17 | + | const MAX_BODY_DEPTH: usize = 4; |
|
| 18 | + | ||
| 19 | + | /// Upper bound on body fields per request, so a sprawling schema can't turn |
|
| 20 | + | /// the Docs tab into thousands of lines. |
|
| 21 | + | const MAX_BODY_FIELDS: usize = 200; |
|
| 22 | + | ||
| 23 | + | /// Documentation for one OpenAPI parameter object. |
|
| 24 | + | pub fn param_doc(doc: &Value, p: &Value) -> FieldDoc { |
|
| 25 | + | let location = p.get("in").and_then(Value::as_str).unwrap_or("query"); |
|
| 26 | + | let schema = p.get("schema").map(|s| deref(doc, s)); |
|
| 27 | + | let mut field = match schema { |
|
| 28 | + | Some(schema) => field_doc(doc, schema), |
|
| 29 | + | None => FieldDoc { |
|
| 30 | + | ty: "string".into(), |
|
| 31 | + | ..FieldDoc::default() |
|
| 32 | + | }, |
|
| 33 | + | }; |
|
| 34 | + | field.name = p |
|
| 35 | + | .get("name") |
|
| 36 | + | .and_then(Value::as_str) |
|
| 37 | + | .unwrap_or_default() |
|
| 38 | + | .to_string(); |
|
| 39 | + | field.location = location.to_string(); |
|
| 40 | + | // Path parameters are required by definition (OpenAPI says so even when |
|
| 41 | + | // the spec omits the flag). |
|
| 42 | + | field.required = |
|
| 43 | + | location == "path" || p.get("required").and_then(Value::as_bool).unwrap_or(false); |
|
| 44 | + | // A description on the parameter beats one inherited from its schema. |
|
| 45 | + | if let Some(d) = description(p) { |
|
| 46 | + | field.description = Some(d); |
|
| 47 | + | } |
|
| 48 | + | field |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | /// Documentation for a request body schema, flattened to dotted paths: |
|
| 52 | + | /// `owner.name`, `pets[].tag`. A body that isn't an object gets a single row. |
|
| 53 | + | pub fn body_docs(doc: &Value, schema: &Value) -> Vec<FieldDoc> { |
|
| 54 | + | let mut out = Vec::new(); |
|
| 55 | + | flatten(doc, schema, "", 0, &mut out); |
|
| 56 | + | if out.is_empty() { |
|
| 57 | + | let mut field = field_doc(doc, schema); |
|
| 58 | + | if !field.ty.is_empty() && field.ty != "object" { |
|
| 59 | + | field.name = "(body)".into(); |
|
| 60 | + | field.location = "body".into(); |
|
| 61 | + | out.push(field); |
|
| 62 | + | } |
|
| 63 | + | } |
|
| 64 | + | out |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | fn flatten(doc: &Value, schema: &Value, prefix: &str, depth: usize, out: &mut Vec<FieldDoc>) { |
|
| 68 | + | if depth > MAX_BODY_DEPTH || out.len() >= MAX_BODY_FIELDS { |
|
| 69 | + | return; |
|
| 70 | + | } |
|
| 71 | + | let schema = deref(doc, schema); |
|
| 72 | + | ||
| 73 | + | // An array contributes no fields of its own; describe its items under |
|
| 74 | + | // `name[]` so the path reads like the JSON it documents. |
|
| 75 | + | if let Some(items) = schema.get("items") { |
|
| 76 | + | flatten(doc, items, &format!("{prefix}[]"), depth + 1, out); |
|
| 77 | + | return; |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | for part in object_parts(doc, schema) { |
|
| 81 | + | let required: HashSet<&str> = part |
|
| 82 | + | .get("required") |
|
| 83 | + | .and_then(Value::as_array) |
|
| 84 | + | .map(|a| a.iter().filter_map(Value::as_str).collect()) |
|
| 85 | + | .unwrap_or_default(); |
|
| 86 | + | let Some(props) = part.get("properties").and_then(Value::as_object) else { |
|
| 87 | + | continue; |
|
| 88 | + | }; |
|
| 89 | + | for (name, sub) in props { |
|
| 90 | + | if out.len() >= MAX_BODY_FIELDS { |
|
| 91 | + | return; |
|
| 92 | + | } |
|
| 93 | + | let sub = deref(doc, sub); |
|
| 94 | + | let path = if prefix.is_empty() { |
|
| 95 | + | name.clone() |
|
| 96 | + | } else { |
|
| 97 | + | format!("{prefix}.{name}") |
|
| 98 | + | }; |
|
| 99 | + | let mut field = field_doc(doc, sub); |
|
| 100 | + | field.name = path.clone(); |
|
| 101 | + | field.location = "body".into(); |
|
| 102 | + | field.required = required.contains(name.as_str()); |
|
| 103 | + | out.push(field); |
|
| 104 | + | // Scalars fall straight back out of this call. |
|
| 105 | + | flatten(doc, sub, &path, depth + 1, out); |
|
| 106 | + | } |
|
| 107 | + | } |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | /// Schemas contributing properties to `schema`: itself, plus `allOf` members, |
|
| 111 | + | /// which OpenAPI uses for composition/inheritance. |
|
| 112 | + | fn object_parts<'a>(doc: &'a Value, schema: &'a Value) -> Vec<&'a Value> { |
|
| 113 | + | let mut parts = vec![schema]; |
|
| 114 | + | if let Some(all) = schema.get("allOf").and_then(Value::as_array) { |
|
| 115 | + | parts.extend(all.iter().map(|s| deref(doc, s))); |
|
| 116 | + | } |
|
| 117 | + | parts |
|
| 118 | + | } |
|
| 119 | + | ||
| 120 | + | /// Everything about a schema except the name and location, which only the |
|
| 121 | + | /// caller knows. |
|
| 122 | + | fn field_doc(doc: &Value, schema: &Value) -> FieldDoc { |
|
| 123 | + | let schema = deref(doc, schema); |
|
| 124 | + | FieldDoc { |
|
| 125 | + | name: String::new(), |
|
| 126 | + | location: String::new(), |
|
| 127 | + | ty: type_label(doc, schema, 0), |
|
| 128 | + | required: false, |
|
| 129 | + | options: enum_options(doc, schema), |
|
| 130 | + | description: description(schema), |
|
| 131 | + | default: schema.get("default").map(scalar), |
|
| 132 | + | } |
|
| 133 | + | } |
|
| 134 | + | ||
| 135 | + | /// A short, readable type: `string(uuid)`, `array<integer>`, `object`. |
|
| 136 | + | fn type_label(doc: &Value, schema: &Value, depth: usize) -> String { |
|
| 137 | + | if depth > MAX_BODY_DEPTH { |
|
| 138 | + | return "…".into(); |
|
| 139 | + | } |
|
| 140 | + | let schema = deref(doc, schema); |
|
| 141 | + | ||
| 142 | + | for key in ["oneOf", "anyOf"] { |
|
| 143 | + | if let Some(alts) = schema.get(key).and_then(Value::as_array) { |
|
| 144 | + | let labels: Vec<String> = alts |
|
| 145 | + | .iter() |
|
| 146 | + | .take(3) |
|
| 147 | + | .map(|s| type_label(doc, s, depth + 1)) |
|
| 148 | + | .collect(); |
|
| 149 | + | let more = if alts.len() > 3 { " | …" } else { "" }; |
|
| 150 | + | return format!("{}{more}", labels.join(" | ")); |
|
| 151 | + | } |
|
| 152 | + | } |
|
| 153 | + | if schema.get("allOf").is_some() { |
|
| 154 | + | return "object".into(); |
|
| 155 | + | } |
|
| 156 | + | ||
| 157 | + | let types = type_names(schema); |
|
| 158 | + | let Some(primary) = types.first() else { |
|
| 159 | + | return if schema.get("properties").is_some() { |
|
| 160 | + | "object".into() |
|
| 161 | + | } else { |
|
| 162 | + | "any".into() |
|
| 163 | + | }; |
|
| 164 | + | }; |
|
| 165 | + | ||
| 166 | + | let mut label = match primary.as_str() { |
|
| 167 | + | "array" => { |
|
| 168 | + | let inner = schema |
|
| 169 | + | .get("items") |
|
| 170 | + | .map(|i| type_label(doc, i, depth + 1)) |
|
| 171 | + | .unwrap_or_else(|| "any".into()); |
|
| 172 | + | format!("array<{inner}>") |
|
| 173 | + | } |
|
| 174 | + | other => match schema.get("format").and_then(Value::as_str) { |
|
| 175 | + | Some(f) => format!("{other}({f})"), |
|
| 176 | + | None => other.to_string(), |
|
| 177 | + | }, |
|
| 178 | + | }; |
|
| 179 | + | // OpenAPI 3.1 `type: [string, "null"]`. |
|
| 180 | + | for extra in types.iter().skip(1) { |
|
| 181 | + | label.push_str(" | "); |
|
| 182 | + | label.push_str(extra); |
|
| 183 | + | } |
|
| 184 | + | label |
|
| 185 | + | } |
|
| 186 | + | ||
| 187 | + | /// `type` as a list — a plain string in 3.0, possibly an array in 3.1. |
|
| 188 | + | fn type_names(schema: &Value) -> Vec<String> { |
|
| 189 | + | match schema.get("type") { |
|
| 190 | + | Some(Value::String(s)) => vec![s.clone()], |
|
| 191 | + | Some(Value::Array(a)) => a |
|
| 192 | + | .iter() |
|
| 193 | + | .filter_map(Value::as_str) |
|
| 194 | + | .map(String::from) |
|
| 195 | + | .collect(), |
|
| 196 | + | _ => Vec::new(), |
|
| 197 | + | } |
|
| 198 | + | } |
|
| 199 | + | ||
| 200 | + | /// Accepted values: the schema's own `enum`, or an array's item `enum` (the |
|
| 201 | + | /// options are what goes *in* the array either way). |
|
| 202 | + | fn enum_options(doc: &Value, schema: &Value) -> Vec<String> { |
|
| 203 | + | let direct = schema.get("enum").and_then(Value::as_array); |
|
| 204 | + | let from_items = || { |
|
| 205 | + | deref(doc, schema.get("items")?) |
|
| 206 | + | .get("enum") |
|
| 207 | + | .and_then(Value::as_array) |
|
| 208 | + | }; |
|
| 209 | + | direct |
|
| 210 | + | .or_else(from_items) |
|
| 211 | + | .map(|a| a.iter().map(scalar).collect()) |
|
| 212 | + | .unwrap_or_default() |
|
| 213 | + | } |
|
| 214 | + | ||
| 215 | + | fn description(v: &Value) -> Option<String> { |
|
| 216 | + | let d = v.get("description").and_then(Value::as_str)?.trim(); |
|
| 217 | + | (!d.is_empty()).then(|| d.to_string()) |
|
| 218 | + | } |
|
| 219 | + | ||
| 220 | + | /// Enum entries and defaults are shown as they'd be typed into a field, so |
|
| 221 | + | /// strings lose their quotes. |
|
| 222 | + | fn scalar(v: &Value) -> String { |
|
| 223 | + | match v { |
|
| 224 | + | Value::String(s) => s.clone(), |
|
| 225 | + | other => other.to_string(), |
|
| 226 | + | } |
|
| 227 | + | } |
|
| 228 | + | ||
| 229 | + | #[cfg(test)] |
|
| 230 | + | mod tests { |
|
| 231 | + | use super::*; |
|
| 232 | + | use serde_json::json; |
|
| 233 | + | ||
| 234 | + | #[test] |
|
| 235 | + | fn parameter_types_enums_and_requiredness() { |
|
| 236 | + | let doc = json!({}); |
|
| 237 | + | let p = json!({ |
|
| 238 | + | "name": "status", |
|
| 239 | + | "in": "query", |
|
| 240 | + | "required": true, |
|
| 241 | + | "description": "Status values to filter by", |
|
| 242 | + | "schema": {"type": "string", "enum": ["available", "pending", "sold"], "default": "available"} |
|
| 243 | + | }); |
|
| 244 | + | let d = param_doc(&doc, &p); |
|
| 245 | + | assert_eq!(d.name, "status"); |
|
| 246 | + | assert_eq!(d.location, "query"); |
|
| 247 | + | assert_eq!(d.ty, "string"); |
|
| 248 | + | assert!(d.required); |
|
| 249 | + | assert_eq!(d.options, ["available", "pending", "sold"]); |
|
| 250 | + | assert_eq!(d.default.as_deref(), Some("available")); |
|
| 251 | + | assert_eq!(d.description.as_deref(), Some("Status values to filter by")); |
|
| 252 | + | } |
|
| 253 | + | ||
| 254 | + | #[test] |
|
| 255 | + | fn path_params_are_required_even_when_unflagged() { |
|
| 256 | + | let doc = json!({}); |
|
| 257 | + | let p = json!({"name": "petId", "in": "path", "schema": {"type": "integer", "format": "int64"}}); |
|
| 258 | + | let d = param_doc(&doc, &p); |
|
| 259 | + | assert!(d.required); |
|
| 260 | + | assert_eq!(d.ty, "integer(int64)"); |
|
| 261 | + | } |
|
| 262 | + | ||
| 263 | + | #[test] |
|
| 264 | + | fn array_params_expose_item_options() { |
|
| 265 | + | let doc = json!({}); |
|
| 266 | + | let p = json!({ |
|
| 267 | + | "name": "tags", |
|
| 268 | + | "in": "query", |
|
| 269 | + | "schema": {"type": "array", "items": {"type": "string", "enum": ["a", "b"]}} |
|
| 270 | + | }); |
|
| 271 | + | let d = param_doc(&doc, &p); |
|
| 272 | + | assert_eq!(d.ty, "array<string>"); |
|
| 273 | + | assert_eq!(d.options, ["a", "b"]); |
|
| 274 | + | } |
|
| 275 | + | ||
| 276 | + | #[test] |
|
| 277 | + | fn body_is_flattened_to_dotted_paths() { |
|
| 278 | + | let doc = json!({ |
|
| 279 | + | "components": {"schemas": { |
|
| 280 | + | "Address": {"type": "object", "required": ["zip"], "properties": { |
|
| 281 | + | "zip": {"type": "string"} |
|
| 282 | + | }} |
|
| 283 | + | }} |
|
| 284 | + | }); |
|
| 285 | + | let schema = json!({ |
|
| 286 | + | "type": "object", |
|
| 287 | + | "required": ["name"], |
|
| 288 | + | "properties": { |
|
| 289 | + | "name": {"type": "string"}, |
|
| 290 | + | "owner": {"type": "object", "properties": { |
|
| 291 | + | "address": {"$ref": "#/components/schemas/Address"} |
|
| 292 | + | }}, |
|
| 293 | + | "pets": {"type": "array", "items": {"type": "object", "properties": { |
|
| 294 | + | "tag": {"type": "string", "enum": ["cat", "dog"]} |
|
| 295 | + | }}} |
|
| 296 | + | } |
|
| 297 | + | }); |
|
| 298 | + | let docs = body_docs(&doc, &schema); |
|
| 299 | + | let names: Vec<&str> = docs.iter().map(|d| d.name.as_str()).collect(); |
|
| 300 | + | assert_eq!( |
|
| 301 | + | names, |
|
| 302 | + | [ |
|
| 303 | + | "name", |
|
| 304 | + | "owner", |
|
| 305 | + | "owner.address", |
|
| 306 | + | "owner.address.zip", |
|
| 307 | + | "pets", |
|
| 308 | + | "pets[].tag" |
|
| 309 | + | ] |
|
| 310 | + | ); |
|
| 311 | + | assert!(docs[0].required); |
|
| 312 | + | assert!(!docs[1].required); |
|
| 313 | + | let zip = docs.iter().find(|d| d.name == "owner.address.zip").unwrap(); |
|
| 314 | + | assert!(zip.required, "requiredness comes from the owning object"); |
|
| 315 | + | let tag = docs.iter().find(|d| d.name == "pets[].tag").unwrap(); |
|
| 316 | + | assert_eq!(tag.options, ["cat", "dog"]); |
|
| 317 | + | assert_eq!( |
|
| 318 | + | docs.iter().find(|d| d.name == "pets").unwrap().ty, |
|
| 319 | + | "array<object>" |
|
| 320 | + | ); |
|
| 321 | + | assert!(docs.iter().all(|d| d.location == "body")); |
|
| 322 | + | } |
|
| 323 | + | ||
| 324 | + | #[test] |
|
| 325 | + | fn all_of_members_contribute_fields() { |
|
| 326 | + | let doc = json!({}); |
|
| 327 | + | let schema = json!({"allOf": [ |
|
| 328 | + | {"type": "object", "required": ["id"], "properties": {"id": {"type": "integer"}}}, |
|
| 329 | + | {"type": "object", "properties": {"note": {"type": "string"}}} |
|
| 330 | + | ]}); |
|
| 331 | + | let docs = body_docs(&doc, &schema); |
|
| 332 | + | let names: Vec<&str> = docs.iter().map(|d| d.name.as_str()).collect(); |
|
| 333 | + | assert_eq!(names, ["id", "note"]); |
|
| 334 | + | assert!(docs[0].required); |
|
| 335 | + | } |
|
| 336 | + | ||
| 337 | + | #[test] |
|
| 338 | + | fn non_object_body_gets_one_row() { |
|
| 339 | + | let doc = json!({}); |
|
| 340 | + | let docs = body_docs(&doc, &json!({"type": "string", "format": "binary"})); |
|
| 341 | + | assert_eq!(docs.len(), 1); |
|
| 342 | + | assert_eq!(docs[0].name, "(body)"); |
|
| 343 | + | assert_eq!(docs[0].ty, "string(binary)"); |
|
| 344 | + | } |
|
| 345 | + | ||
| 346 | + | #[test] |
|
| 347 | + | fn recursive_schemas_terminate() { |
|
| 348 | + | let doc = json!({ |
|
| 349 | + | "components": {"schemas": { |
|
| 350 | + | "Node": {"type": "object", "properties": { |
|
| 351 | + | "child": {"$ref": "#/components/schemas/Node"} |
|
| 352 | + | }} |
|
| 353 | + | }} |
|
| 354 | + | }); |
|
| 355 | + | let docs = body_docs(&doc, &json!({"$ref": "#/components/schemas/Node"})); |
|
| 356 | + | assert!(!docs.is_empty()); |
|
| 357 | + | assert!(docs.len() <= MAX_BODY_DEPTH + 1, "{}", docs.len()); |
|
| 358 | + | } |
|
| 359 | + | ||
| 360 | + | #[test] |
|
| 361 | + | fn union_and_nullable_types_read_as_written() { |
|
| 362 | + | let doc = json!({}); |
|
| 363 | + | assert_eq!( |
|
| 364 | + | type_label(&doc, &json!({"type": ["string", "null"]}), 0), |
|
| 365 | + | "string | null" |
|
| 366 | + | ); |
|
| 367 | + | assert_eq!( |
|
| 368 | + | type_label( |
|
| 369 | + | &doc, |
|
| 370 | + | &json!({"oneOf": [{"type": "string"}, {"type": "integer"}]}), |
|
| 371 | + | 0 |
|
| 372 | + | ), |
|
| 373 | + | "string | integer" |
|
| 374 | + | ); |
|
| 375 | + | assert_eq!(type_label(&doc, &json!({}), 0), "any"); |
|
| 376 | + | } |
|
| 377 | + | } |
| 1 | + | //! Example payload generation from JSON schemas (OpenAPI 3.0/3.1 subset). |
|
| 2 | + | ||
| 3 | + | use serde_json::{Map, Value}; |
|
| 4 | + | ||
| 5 | + | use super::resolve::deref; |
|
| 6 | + | ||
| 7 | + | /// Depth cap for generated structures (also breaks schema cycles). |
|
| 8 | + | pub const MAX_GEN_DEPTH: usize = 6; |
|
| 9 | + | ||
| 10 | + | /// Produce an example value for a schema, preferring explicitly authored |
|
| 11 | + | /// examples/defaults, then enums, then combinators, then type-based stubs. |
|
| 12 | + | pub fn example_for_schema(doc: &Value, schema: &Value) -> Value { |
|
| 13 | + | gen_value(doc, schema, 0) |
|
| 14 | + | } |
|
| 15 | + | ||
| 16 | + | fn gen_value(doc: &Value, schema: &Value, depth: usize) -> Value { |
|
| 17 | + | if depth > MAX_GEN_DEPTH { |
|
| 18 | + | return Value::Null; |
|
| 19 | + | } |
|
| 20 | + | let schema = deref(doc, schema); |
|
| 21 | + | ||
| 22 | + | if let Some(ex) = schema.get("example") { |
|
| 23 | + | return ex.clone(); |
|
| 24 | + | } |
|
| 25 | + | if let Some(def) = schema.get("default") { |
|
| 26 | + | return def.clone(); |
|
| 27 | + | } |
|
| 28 | + | // OpenAPI 3.1 / JSON Schema style `examples` array. |
|
| 29 | + | if let Some(first) = schema |
|
| 30 | + | .get("examples") |
|
| 31 | + | .and_then(Value::as_array) |
|
| 32 | + | .and_then(|a| a.first()) |
|
| 33 | + | { |
|
| 34 | + | return first.clone(); |
|
| 35 | + | } |
|
| 36 | + | if let Some(first) = schema |
|
| 37 | + | .get("enum") |
|
| 38 | + | .and_then(Value::as_array) |
|
| 39 | + | .and_then(|a| a.first()) |
|
| 40 | + | { |
|
| 41 | + | return first.clone(); |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | if let Some(all) = schema.get("allOf").and_then(Value::as_array) { |
|
| 45 | + | let mut merged = Map::new(); |
|
| 46 | + | for sub in all { |
|
| 47 | + | if let Value::Object(props) = gen_value(doc, sub, depth + 1) { |
|
| 48 | + | for (k, v) in props { |
|
| 49 | + | merged.insert(k, v); |
|
| 50 | + | } |
|
| 51 | + | } |
|
| 52 | + | } |
|
| 53 | + | return Value::Object(merged); |
|
| 54 | + | } |
|
| 55 | + | for key in ["oneOf", "anyOf"] { |
|
| 56 | + | if let Some(first) = schema |
|
| 57 | + | .get(key) |
|
| 58 | + | .and_then(Value::as_array) |
|
| 59 | + | .and_then(|a| a.first()) |
|
| 60 | + | { |
|
| 61 | + | return gen_value(doc, first, depth + 1); |
|
| 62 | + | } |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | let ty = schema.get("type").and_then(Value::as_str); |
|
| 66 | + | // OpenAPI 3.1 allows `type` arrays like ["string", "null"]. |
|
| 67 | + | let ty = ty.or_else(|| { |
|
| 68 | + | schema |
|
| 69 | + | .get("type") |
|
| 70 | + | .and_then(Value::as_array) |
|
| 71 | + | .and_then(|a| a.first()) |
|
| 72 | + | .and_then(Value::as_str) |
|
| 73 | + | }); |
|
| 74 | + | let ty = ty.or_else(|| { |
|
| 75 | + | if schema.get("properties").is_some() { |
|
| 76 | + | Some("object") |
|
| 77 | + | } else { |
|
| 78 | + | None |
|
| 79 | + | } |
|
| 80 | + | }); |
|
| 81 | + | ||
| 82 | + | match ty { |
|
| 83 | + | Some("object") => { |
|
| 84 | + | let mut map = Map::new(); |
|
| 85 | + | if let Some(props) = schema.get("properties").and_then(Value::as_object) { |
|
| 86 | + | for (k, sub) in props { |
|
| 87 | + | map.insert(k.clone(), gen_value(doc, sub, depth + 1)); |
|
| 88 | + | } |
|
| 89 | + | } |
|
| 90 | + | Value::Object(map) |
|
| 91 | + | } |
|
| 92 | + | Some("array") => { |
|
| 93 | + | let item = schema |
|
| 94 | + | .get("items") |
|
| 95 | + | .map(|s| gen_value(doc, s, depth + 1)) |
|
| 96 | + | .unwrap_or(Value::Null); |
|
| 97 | + | Value::Array(vec![item]) |
|
| 98 | + | } |
|
| 99 | + | Some("integer") => Value::from(1), |
|
| 100 | + | Some("number") => Value::from(1.0), |
|
| 101 | + | Some("boolean") => Value::from(true), |
|
| 102 | + | _ => string_stub(schema), |
|
| 103 | + | } |
|
| 104 | + | } |
|
| 105 | + | ||
| 106 | + | fn string_stub(schema: &Value) -> Value { |
|
| 107 | + | match schema.get("format").and_then(Value::as_str) { |
|
| 108 | + | // Substituted with a fresh UUID v4 at send time. |
|
| 109 | + | Some("uuid") => Value::from("{{uuid}}"), |
|
| 110 | + | Some("date-time") => Value::from("2024-01-01T00:00:00Z"), |
|
| 111 | + | Some("date") => Value::from("2024-01-01"), |
|
| 112 | + | Some("email") => Value::from("user@example.com"), |
|
| 113 | + | _ => Value::from("string"), |
|
| 114 | + | } |
|
| 115 | + | } |
|
| 116 | + | ||
| 117 | + | #[cfg(test)] |
|
| 118 | + | mod tests { |
|
| 119 | + | use super::*; |
|
| 120 | + | use serde_json::json; |
|
| 121 | + | ||
| 122 | + | #[test] |
|
| 123 | + | fn prefers_authored_example() { |
|
| 124 | + | let doc = json!({}); |
|
| 125 | + | let schema = json!({"type": "integer", "example": 42}); |
|
| 126 | + | assert_eq!(example_for_schema(&doc, &schema), json!(42)); |
|
| 127 | + | } |
|
| 128 | + | ||
| 129 | + | #[test] |
|
| 130 | + | fn generates_object_with_refs() { |
|
| 131 | + | let doc = json!({ |
|
| 132 | + | "components": { "schemas": { |
|
| 133 | + | "Pet": { |
|
| 134 | + | "type": "object", |
|
| 135 | + | "properties": { |
|
| 136 | + | "id": {"type": "integer", "format": "int64"}, |
|
| 137 | + | "name": {"type": "string"}, |
|
| 138 | + | "tag": {"type": "string", "default": "friendly"} |
|
| 139 | + | } |
|
| 140 | + | } |
|
| 141 | + | }} |
|
| 142 | + | }); |
|
| 143 | + | let schema = json!({"$ref": "#/components/schemas/Pet"}); |
|
| 144 | + | let v = example_for_schema(&doc, &schema); |
|
| 145 | + | assert_eq!(v, json!({"id": 1, "name": "string", "tag": "friendly"})); |
|
| 146 | + | } |
|
| 147 | + | ||
| 148 | + | #[test] |
|
| 149 | + | fn uuid_format_becomes_variable() { |
|
| 150 | + | let doc = json!({}); |
|
| 151 | + | let schema = json!({"type": "string", "format": "uuid"}); |
|
| 152 | + | assert_eq!(example_for_schema(&doc, &schema), json!("{{uuid}}")); |
|
| 153 | + | } |
|
| 154 | + | ||
| 155 | + | #[test] |
|
| 156 | + | fn all_of_merges() { |
|
| 157 | + | let doc = json!({}); |
|
| 158 | + | let schema = json!({"allOf": [ |
|
| 159 | + | {"type": "object", "properties": {"a": {"type": "integer"}}}, |
|
| 160 | + | {"type": "object", "properties": {"b": {"type": "boolean"}}} |
|
| 161 | + | ]}); |
|
| 162 | + | assert_eq!( |
|
| 163 | + | example_for_schema(&doc, &schema), |
|
| 164 | + | json!({"a": 1, "b": true}) |
|
| 165 | + | ); |
|
| 166 | + | } |
|
| 167 | + | ||
| 168 | + | #[test] |
|
| 169 | + | fn terminates_on_self_reference() { |
|
| 170 | + | let doc = json!({ |
|
| 171 | + | "components": { "schemas": { |
|
| 172 | + | "Node": {"type": "object", "properties": { |
|
| 173 | + | "child": {"$ref": "#/components/schemas/Node"} |
|
| 174 | + | }} |
|
| 175 | + | }} |
|
| 176 | + | }); |
|
| 177 | + | let schema = json!({"$ref": "#/components/schemas/Node"}); |
|
| 178 | + | let _ = example_for_schema(&doc, &schema); // must terminate |
|
| 179 | + | } |
|
| 180 | + | } |
| 1 | + | //! Conversion of a parsed OpenAPI document into a cielago [`Collection`]. |
|
| 2 | + | ||
| 3 | + | use std::collections::HashSet; |
|
| 4 | + | ||
| 5 | + | use serde_json::Value; |
|
| 6 | + | ||
| 7 | + | use super::docs::{body_docs, param_doc}; |
|
| 8 | + | use super::examples::example_for_schema; |
|
| 9 | + | use super::resolve::deref; |
|
| 10 | + | use crate::model::{AuthStyle, Collection, KeyValueRow, Method, OAuthConfig, SavedRequest}; |
|
| 11 | + | ||
| 12 | + | const METHODS: [&str; 7] = ["get", "post", "put", "patch", "delete", "head", "options"]; |
|
| 13 | + | ||
| 14 | + | pub fn import_spec(doc: &Value, name: &str, source: Option<String>) -> Collection { |
|
| 15 | + | let mut collection = Collection::new(name); |
|
| 16 | + | collection.spec_source = source; |
|
| 17 | + | ||
| 18 | + | if let Some(servers) = doc.get("servers").and_then(Value::as_array) { |
|
| 19 | + | for s in servers { |
|
| 20 | + | if let Some(url) = s.get("url").and_then(Value::as_str) { |
|
| 21 | + | let url = url.trim_end_matches('/').to_string(); |
|
| 22 | + | if !url.is_empty() && !collection.servers.contains(&url) { |
|
| 23 | + | collection.servers.push(url); |
|
| 24 | + | } |
|
| 25 | + | } |
|
| 26 | + | } |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | collection.auth = extract_oauth(doc); |
|
| 30 | + | ||
| 31 | + | if let Some(paths) = doc.get("paths").and_then(Value::as_object) { |
|
| 32 | + | for (path, item) in paths { |
|
| 33 | + | let item = deref(doc, item); |
|
| 34 | + | let path_level_params = item.get("parameters").and_then(Value::as_array); |
|
| 35 | + | for method in METHODS { |
|
| 36 | + | let Some(op) = item.get(method) else { continue }; |
|
| 37 | + | collection |
|
| 38 | + | .requests |
|
| 39 | + | .push(build_request(doc, path, method, path_level_params, op)); |
|
| 40 | + | } |
|
| 41 | + | } |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | collection |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | /// Find the first `oauth2` security scheme with a clientCredentials flow and |
|
| 48 | + | /// prefill token URL + scopes (credentials are filled in by the user). |
|
| 49 | + | fn extract_oauth(doc: &Value) -> Option<OAuthConfig> { |
|
| 50 | + | let schemes = doc.get("components")?.get("securitySchemes")?.as_object()?; |
|
| 51 | + | for (_, scheme) in schemes { |
|
| 52 | + | let scheme = deref(doc, scheme); |
|
| 53 | + | if scheme.get("type").and_then(Value::as_str) != Some("oauth2") { |
|
| 54 | + | continue; |
|
| 55 | + | } |
|
| 56 | + | let Some(flow) = scheme.get("flows").and_then(|f| f.get("clientCredentials")) else { |
|
| 57 | + | continue; |
|
| 58 | + | }; |
|
| 59 | + | let token_url = flow |
|
| 60 | + | .get("tokenUrl") |
|
| 61 | + | .and_then(Value::as_str) |
|
| 62 | + | .unwrap_or_default() |
|
| 63 | + | .to_string(); |
|
| 64 | + | let scopes = flow |
|
| 65 | + | .get("scopes") |
|
| 66 | + | .and_then(Value::as_object) |
|
| 67 | + | .map(|o| o.keys().cloned().collect()) |
|
| 68 | + | .unwrap_or_default(); |
|
| 69 | + | return Some(OAuthConfig { |
|
| 70 | + | token_url, |
|
| 71 | + | client_id: String::new(), |
|
| 72 | + | client_secret: String::new(), |
|
| 73 | + | scopes, |
|
| 74 | + | auth_style: AuthStyle::Basic, |
|
| 75 | + | }); |
|
| 76 | + | } |
|
| 77 | + | None |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | fn build_request( |
|
| 81 | + | doc: &Value, |
|
| 82 | + | path: &str, |
|
| 83 | + | method: &str, |
|
| 84 | + | path_level_params: Option<&Vec<Value>>, |
|
| 85 | + | op: &Value, |
|
| 86 | + | ) -> SavedRequest { |
|
| 87 | + | let summary = op |
|
| 88 | + | .get("summary") |
|
| 89 | + | .and_then(Value::as_str) |
|
| 90 | + | .filter(|s| !s.trim().is_empty()) |
|
| 91 | + | .map(str::to_string); |
|
| 92 | + | let operation_id = op |
|
| 93 | + | .get("operationId") |
|
| 94 | + | .and_then(Value::as_str) |
|
| 95 | + | .filter(|s| !s.trim().is_empty()) |
|
| 96 | + | .map(str::to_string); |
|
| 97 | + | ||
| 98 | + | // `summary` first: it's the human-readable descriptor. `operationId` is |
|
| 99 | + | // often a long generated controller name. |
|
| 100 | + | let name = summary |
|
| 101 | + | .clone() |
|
| 102 | + | .or_else(|| operation_id.clone()) |
|
| 103 | + | .unwrap_or_else(|| format!("{} {}", method.to_uppercase(), path)); |
|
| 104 | + | ||
| 105 | + | let mut req = SavedRequest::blank(name); |
|
| 106 | + | req.summary = summary; |
|
| 107 | + | req.operation_id = operation_id; |
|
| 108 | + | req.description = op |
|
| 109 | + | .get("description") |
|
| 110 | + | .and_then(Value::as_str) |
|
| 111 | + | .map(str::trim) |
|
| 112 | + | .filter(|s| !s.is_empty()) |
|
| 113 | + | .map(str::to_string); |
|
| 114 | + | req.method = Method::parse(method).unwrap_or(Method::Get); |
|
| 115 | + | req.path = path.to_string(); |
|
| 116 | + | req.tags = op |
|
| 117 | + | .get("tags") |
|
| 118 | + | .and_then(Value::as_array) |
|
| 119 | + | .map(|a| { |
|
| 120 | + | a.iter() |
|
| 121 | + | .filter_map(Value::as_str) |
|
| 122 | + | .map(String::from) |
|
| 123 | + | .collect() |
|
| 124 | + | }) |
|
| 125 | + | .unwrap_or_default(); |
|
| 126 | + | ||
| 127 | + | // Merge operation-level and path-level parameters; operation wins on |
|
| 128 | + | // duplicate (name, in) pairs. |
|
| 129 | + | let mut merged: Vec<&Value> = Vec::new(); |
|
| 130 | + | let mut seen: HashSet<(String, String)> = HashSet::new(); |
|
| 131 | + | ||
| 132 | + | let op_params = op.get("parameters").and_then(Value::as_array); |
|
| 133 | + | for p in op_params.into_iter().flatten() { |
|
| 134 | + | let p = deref(doc, p); |
|
| 135 | + | let key = param_key(p); |
|
| 136 | + | seen.insert(key); |
|
| 137 | + | merged.push(p); |
|
| 138 | + | } |
|
| 139 | + | for p in path_level_params.into_iter().flatten() { |
|
| 140 | + | let p = deref(doc, p); |
|
| 141 | + | if seen.insert(param_key(p)) { |
|
| 142 | + | merged.push(p); |
|
| 143 | + | } |
|
| 144 | + | } |
|
| 145 | + | ||
| 146 | + | for p in merged { |
|
| 147 | + | let name = p.get("name").and_then(Value::as_str).unwrap_or_default(); |
|
| 148 | + | if name.is_empty() { |
|
| 149 | + | continue; |
|
| 150 | + | } |
|
| 151 | + | let required = p.get("required").and_then(Value::as_bool).unwrap_or(false); |
|
| 152 | + | let location = p.get("in").and_then(Value::as_str).unwrap_or(""); |
|
| 153 | + | let value = param_value(doc, p, required); |
|
| 154 | + | match location { |
|
| 155 | + | "path" => req.path_params.push(KeyValueRow::new(name, value, true)), |
|
| 156 | + | "query" => req.query.push(KeyValueRow::new(name, value, required)), |
|
| 157 | + | "header" => req.headers.push(KeyValueRow::new(name, value, required)), |
|
| 158 | + | _ => continue, // cookies etc. unsupported in v1 |
|
| 159 | + | } |
|
| 160 | + | req.docs.push(param_doc(doc, p)); |
|
| 161 | + | } |
|
| 162 | + | ||
| 163 | + | // Headers the spec implies without listing them as `in: header` params. |
|
| 164 | + | // Explicit params win on a name collision. |
|
| 165 | + | for row in implied_headers(doc, op) { |
|
| 166 | + | if !req |
|
| 167 | + | .headers |
|
| 168 | + | .iter() |
|
| 169 | + | .any(|h| h.key.eq_ignore_ascii_case(&row.key)) |
|
| 170 | + | { |
|
| 171 | + | req.headers.push(row); |
|
| 172 | + | } |
|
| 173 | + | } |
|
| 174 | + | ||
| 175 | + | req.body = extract_body(doc, op); |
|
| 176 | + | if let Some(schema) = body_media(doc, op).and_then(|m| m.get("schema")) { |
|
| 177 | + | req.docs.extend(body_docs(doc, schema)); |
|
| 178 | + | } |
|
| 179 | + | req |
|
| 180 | + | } |
|
| 181 | + | ||
| 182 | + | /// Headers an operation carries by definition rather than by parameter: the |
|
| 183 | + | /// media type it consumes, the one it produces, and any apiKey-in-header |
|
| 184 | + | /// security scheme it requires. API-key rows arrive disabled — the value is |
|
| 185 | + | /// the user's to supply. |
|
| 186 | + | fn implied_headers(doc: &Value, op: &Value) -> Vec<KeyValueRow> { |
|
| 187 | + | let mut out = Vec::new(); |
|
| 188 | + | if let Some(ct) = request_media_type(doc, op) { |
|
| 189 | + | out.push(KeyValueRow::new("Content-Type", ct, true)); |
|
| 190 | + | } |
|
| 191 | + | if let Some(accept) = response_media_type(doc, op) { |
|
| 192 | + | out.push(KeyValueRow::new("Accept", accept, true)); |
|
| 193 | + | } |
|
| 194 | + | for name in api_key_headers(doc, op) { |
|
| 195 | + | out.push(KeyValueRow::new(name, "", false)); |
|
| 196 | + | } |
|
| 197 | + | out |
|
| 198 | + | } |
|
| 199 | + | ||
| 200 | + | fn request_media_type(doc: &Value, op: &Value) -> Option<String> { |
|
| 201 | + | let rb = deref(doc, op.get("requestBody")?); |
|
| 202 | + | let content = rb.get("content").and_then(Value::as_object)?; |
|
| 203 | + | pick_media(content).map(|(k, _)| k.clone()) |
|
| 204 | + | } |
|
| 205 | + | ||
| 206 | + | /// Media type from the first success response (or `default`), so `Accept` |
|
| 207 | + | /// matches what the endpoint actually returns. |
|
| 208 | + | fn response_media_type(doc: &Value, op: &Value) -> Option<String> { |
|
| 209 | + | let responses = op.get("responses").and_then(Value::as_object)?; |
|
| 210 | + | let resp = responses |
|
| 211 | + | .iter() |
|
| 212 | + | .find(|(code, _)| code.starts_with('2')) |
|
| 213 | + | .or_else(|| { |
|
| 214 | + | responses |
|
| 215 | + | .iter() |
|
| 216 | + | .find(|(code, _)| code.as_str() == "default") |
|
| 217 | + | }) |
|
| 218 | + | .map(|(_, v)| v)?; |
|
| 219 | + | let content = deref(doc, resp).get("content").and_then(Value::as_object)?; |
|
| 220 | + | pick_media(content).map(|(k, _)| k.clone()) |
|
| 221 | + | } |
|
| 222 | + | ||
| 223 | + | /// Header names from apiKey security schemes this operation requires, |
|
| 224 | + | /// preferring operation-level `security` over the document default. |
|
| 225 | + | fn api_key_headers(doc: &Value, op: &Value) -> Vec<String> { |
|
| 226 | + | let Some(requirements) = op |
|
| 227 | + | .get("security") |
|
| 228 | + | .or_else(|| doc.get("security")) |
|
| 229 | + | .and_then(Value::as_array) |
|
| 230 | + | else { |
|
| 231 | + | return Vec::new(); |
|
| 232 | + | }; |
|
| 233 | + | let Some(schemes) = doc |
|
| 234 | + | .get("components") |
|
| 235 | + | .and_then(|c| c.get("securitySchemes")) |
|
| 236 | + | .and_then(Value::as_object) |
|
| 237 | + | else { |
|
| 238 | + | return Vec::new(); |
|
| 239 | + | }; |
|
| 240 | + | ||
| 241 | + | let mut out: Vec<String> = Vec::new(); |
|
| 242 | + | for requirement in requirements { |
|
| 243 | + | let Some(obj) = requirement.as_object() else { |
|
| 244 | + | continue; |
|
| 245 | + | }; |
|
| 246 | + | for scheme_name in obj.keys() { |
|
| 247 | + | let Some(scheme) = schemes.get(scheme_name) else { |
|
| 248 | + | continue; |
|
| 249 | + | }; |
|
| 250 | + | let scheme = deref(doc, scheme); |
|
| 251 | + | if scheme.get("type").and_then(Value::as_str) != Some("apiKey") |
|
| 252 | + | || scheme.get("in").and_then(Value::as_str) != Some("header") |
|
| 253 | + | { |
|
| 254 | + | continue; |
|
| 255 | + | } |
|
| 256 | + | if let Some(name) = scheme.get("name").and_then(Value::as_str) |
|
| 257 | + | && !name.is_empty() |
|
| 258 | + | && !out.iter().any(|e| e.eq_ignore_ascii_case(name)) |
|
| 259 | + | { |
|
| 260 | + | out.push(name.to_string()); |
|
| 261 | + | } |
|
| 262 | + | } |
|
| 263 | + | } |
|
| 264 | + | out |
|
| 265 | + | } |
|
| 266 | + | ||
| 267 | + | fn param_key(p: &Value) -> (String, String) { |
|
| 268 | + | ( |
|
| 269 | + | p.get("name") |
|
| 270 | + | .and_then(Value::as_str) |
|
| 271 | + | .unwrap_or_default() |
|
| 272 | + | .into(), |
|
| 273 | + | p.get("in") |
|
| 274 | + | .and_then(Value::as_str) |
|
| 275 | + | .unwrap_or_default() |
|
| 276 | + | .into(), |
|
| 277 | + | ) |
|
| 278 | + | } |
|
| 279 | + | ||
| 280 | + | /// Value for a parameter. Required params fall back to type-based stubs so the |
|
| 281 | + | /// request is sendable out of the box; optional params only get explicitly |
|
| 282 | + | /// authored examples/defaults (otherwise empty). |
|
| 283 | + | fn param_value(doc: &Value, p: &Value, required: bool) -> String { |
|
| 284 | + | if let Some(v) = explicit_param_value(doc, p) { |
|
| 285 | + | return v; |
|
| 286 | + | } |
|
| 287 | + | if !required { |
|
| 288 | + | return String::new(); |
|
| 289 | + | } |
|
| 290 | + | match p.get("schema") { |
|
| 291 | + | Some(schema) => value_to_string(&example_for_schema(doc, deref(doc, schema))), |
|
| 292 | + | None => String::new(), |
|
| 293 | + | } |
|
| 294 | + | } |
|
| 295 | + | ||
| 296 | + | /// Explicitly authored example/default on the parameter or its schema. |
|
| 297 | + | fn explicit_param_value(doc: &Value, p: &Value) -> Option<String> { |
|
| 298 | + | if let Some(ex) = p.get("example") { |
|
| 299 | + | return Some(value_to_string(ex)); |
|
| 300 | + | } |
|
| 301 | + | if let Some(exs) = p.get("examples").and_then(Value::as_object) |
|
| 302 | + | && let Some((_, first)) = exs.iter().next() |
|
| 303 | + | { |
|
| 304 | + | let first = deref(doc, first); |
|
| 305 | + | if let Some(v) = first.get("value") { |
|
| 306 | + | return Some(value_to_string(v)); |
|
| 307 | + | } |
|
| 308 | + | } |
|
| 309 | + | let schema = deref(doc, p.get("schema")?); |
|
| 310 | + | if let Some(ex) = schema.get("example") { |
|
| 311 | + | return Some(value_to_string(ex)); |
|
| 312 | + | } |
|
| 313 | + | if let Some(def) = schema.get("default") { |
|
| 314 | + | return Some(value_to_string(def)); |
|
| 315 | + | } |
|
| 316 | + | None |
|
| 317 | + | } |
|
| 318 | + | ||
| 319 | + | /// Request body from `requestBody`, preferring JSON media types; falls back to |
|
| 320 | + | /// a schema-generated example so payloads are always populated and editable. |
|
| 321 | + | fn extract_body(doc: &Value, op: &Value) -> Option<String> { |
|
| 322 | + | let media = body_media(doc, op)?; |
|
| 323 | + | ||
| 324 | + | if let Some(ex) = media.get("example") { |
|
| 325 | + | return Some(body_to_string(ex)); |
|
| 326 | + | } |
|
| 327 | + | if let Some(exs) = media.get("examples").and_then(Value::as_object) |
|
| 328 | + | && let Some((_, first)) = exs.iter().next() |
|
| 329 | + | { |
|
| 330 | + | let first = deref(doc, first); |
|
| 331 | + | if let Some(v) = first.get("value") { |
|
| 332 | + | return Some(body_to_string(v)); |
|
| 333 | + | } |
|
| 334 | + | } |
|
| 335 | + | let schema = media.get("schema")?; |
|
| 336 | + | Some(body_to_string(&example_for_schema(doc, schema))) |
|
| 337 | + | } |
|
| 338 | + | ||
| 339 | + | /// The media-type entry of `requestBody` that cielago sends — and therefore |
|
| 340 | + | /// the one both the generated body and the Docs tab describe. |
|
| 341 | + | fn body_media<'a>(doc: &'a Value, op: &'a Value) -> Option<&'a Value> { |
|
| 342 | + | let rb = deref(doc, op.get("requestBody")?); |
|
| 343 | + | let content = rb.get("content").and_then(Value::as_object)?; |
|
| 344 | + | pick_media(content).map(|(_, media)| media) |
|
| 345 | + | } |
|
| 346 | + | ||
| 347 | + | /// Preferred entry from a `content` map: JSON first, then anything JSON-ish, |
|
| 348 | + | /// then whatever the spec listed first. |
|
| 349 | + | fn pick_media(content: &serde_json::Map<String, Value>) -> Option<(&String, &Value)> { |
|
| 350 | + | content |
|
| 351 | + | .iter() |
|
| 352 | + | .find(|(k, _)| k.as_str() == "application/json") |
|
| 353 | + | .or_else(|| content.iter().find(|(k, _)| k.contains("json"))) |
|
| 354 | + | .or_else(|| content.iter().next()) |
|
| 355 | + | } |
|
| 356 | + | ||
| 357 | + | fn value_to_string(v: &Value) -> String { |
|
| 358 | + | match v { |
|
| 359 | + | Value::String(s) => s.clone(), |
|
| 360 | + | other => serde_json::to_string(other).unwrap_or_default(), |
|
| 361 | + | } |
|
| 362 | + | } |
|
| 363 | + | ||
| 364 | + | fn body_to_string(v: &Value) -> String { |
|
| 365 | + | match v { |
|
| 366 | + | Value::String(s) => s.clone(), |
|
| 367 | + | other => serde_json::to_string_pretty(other).unwrap_or_default(), |
|
| 368 | + | } |
|
| 369 | + | } |
| 1 | + | use anyhow::{Context, Result}; |
|
| 2 | + | use serde_json::Value; |
|
| 3 | + | ||
| 4 | + | /// Load a spec from a local file path or an http(s) URL. |
|
| 5 | + | pub async fn load_spec(source: &str) -> Result<Value> { |
|
| 6 | + | if source.starts_with("http://") || source.starts_with("https://") { |
|
| 7 | + | let client = reqwest::Client::new(); |
|
| 8 | + | let text = client |
|
| 9 | + | .get(source) |
|
| 10 | + | .send() |
|
| 11 | + | .await |
|
| 12 | + | .with_context(|| format!("fetching {source}"))? |
|
| 13 | + | .error_for_status() |
|
| 14 | + | .with_context(|| format!("fetching {source}"))? |
|
| 15 | + | .text() |
|
| 16 | + | .await |
|
| 17 | + | .with_context(|| format!("reading body of {source}"))?; |
|
| 18 | + | parse_spec(&text).with_context(|| format!("parsing spec from {source}")) |
|
| 19 | + | } else { |
|
| 20 | + | let text = std::fs::read_to_string(source).with_context(|| format!("reading {source}"))?; |
|
| 21 | + | parse_spec(&text).with_context(|| format!("parsing spec from {source}")) |
|
| 22 | + | } |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | /// Parse spec text as JSON, falling back to YAML. |
|
| 26 | + | pub fn parse_spec(text: &str) -> Result<Value> { |
|
| 27 | + | if let Ok(v) = serde_json::from_str::<Value>(text) { |
|
| 28 | + | return Ok(v); |
|
| 29 | + | } |
|
| 30 | + | let v: Value = serde_yaml::from_str(text).context("spec is neither valid JSON nor YAML")?; |
|
| 31 | + | Ok(v) |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | #[cfg(test)] |
|
| 35 | + | mod tests { |
|
| 36 | + | use super::*; |
|
| 37 | + | ||
| 38 | + | #[test] |
|
| 39 | + | fn parses_json_and_yaml() { |
|
| 40 | + | let json = r#"{"openapi":"3.0.0"}"#; |
|
| 41 | + | assert_eq!(parse_spec(json).unwrap()["openapi"], "3.0.0"); |
|
| 42 | + | ||
| 43 | + | let yaml = "openapi: 3.1.0\ninfo:\n title: t\n"; |
|
| 44 | + | let v = parse_spec(yaml).unwrap(); |
|
| 45 | + | assert_eq!(v["openapi"], "3.1.0"); |
|
| 46 | + | assert_eq!(v["info"]["title"], "t"); |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | #[test] |
|
| 50 | + | fn rejects_garbage() { |
|
| 51 | + | assert!(parse_spec("\u{1}\u{2}not a spec at all: [").is_err()); |
|
| 52 | + | } |
|
| 53 | + | } |
| 1 | + | //! OpenAPI 3.x (3.0 + 3.1, JSON/YAML) loading and conversion into collections. |
|
| 2 | + | //! |
|
| 3 | + | //! Specs are parsed into [`serde_json::Value`] rather than a strict spec model |
|
| 4 | + | //! so that unknown fields and 3.0/3.1 differences are tolerated gracefully. |
|
| 5 | + | ||
| 6 | + | pub mod docs; |
|
| 7 | + | pub mod examples; |
|
| 8 | + | pub mod import; |
|
| 9 | + | pub mod loader; |
|
| 10 | + | pub mod resolve; |
|
| 11 | + | ||
| 12 | + | pub use import::import_spec; |
|
| 13 | + | pub use loader::{load_spec, parse_spec}; |
| 1 | + | use serde_json::Value; |
|
| 2 | + | ||
| 3 | + | /// Safety cap on `$ref` chains to survive reference cycles. |
|
| 4 | + | pub const MAX_DEREF_DEPTH: usize = 16; |
|
| 5 | + | ||
| 6 | + | /// Resolve a local reference like `#/components/schemas/Pet` against the document. |
|
| 7 | + | /// Remote references are not supported and resolve to `None`. |
|
| 8 | + | pub fn resolve_pointer<'a>(doc: &'a Value, reference: &str) -> Option<&'a Value> { |
|
| 9 | + | let ptr = reference.strip_prefix('#')?; |
|
| 10 | + | if ptr.is_empty() { |
|
| 11 | + | return Some(doc); |
|
| 12 | + | } |
|
| 13 | + | doc.pointer(ptr) |
|
| 14 | + | } |
|
| 15 | + | ||
| 16 | + | /// Follow `$ref` chains (with a depth cap) and return the concrete node. |
|
| 17 | + | /// Nodes without a `$ref` are returned unchanged. |
|
| 18 | + | pub fn deref<'a>(doc: &'a Value, mut node: &'a Value) -> &'a Value { |
|
| 19 | + | let mut depth = 0; |
|
| 20 | + | while let Some(r) = node.get("$ref").and_then(Value::as_str) { |
|
| 21 | + | if depth >= MAX_DEREF_DEPTH { |
|
| 22 | + | break; |
|
| 23 | + | } |
|
| 24 | + | match resolve_pointer(doc, r) { |
|
| 25 | + | Some(target) => node = target, |
|
| 26 | + | None => break, |
|
| 27 | + | } |
|
| 28 | + | depth += 1; |
|
| 29 | + | } |
|
| 30 | + | node |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | #[cfg(test)] |
|
| 34 | + | mod tests { |
|
| 35 | + | use super::*; |
|
| 36 | + | use serde_json::json; |
|
| 37 | + | ||
| 38 | + | #[test] |
|
| 39 | + | fn derefs_local_ref() { |
|
| 40 | + | let doc = json!({ |
|
| 41 | + | "components": { "schemas": { "Pet": { "type": "object" } } }, |
|
| 42 | + | "node": { "$ref": "#/components/schemas/Pet" } |
|
| 43 | + | }); |
|
| 44 | + | let node = deref(&doc, &doc["node"]); |
|
| 45 | + | assert_eq!(node["type"], "object"); |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | #[test] |
|
| 49 | + | fn survives_ref_cycles() { |
|
| 50 | + | let doc = json!({ |
|
| 51 | + | "components": { "schemas": { |
|
| 52 | + | "A": { "$ref": "#/components/schemas/B" }, |
|
| 53 | + | "B": { "$ref": "#/components/schemas/A" } |
|
| 54 | + | } }, |
|
| 55 | + | "node": { "$ref": "#/components/schemas/A" } |
|
| 56 | + | }); |
|
| 57 | + | // must terminate |
|
| 58 | + | let _ = deref(&doc, &doc["node"]); |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | #[test] |
|
| 62 | + | fn passes_through_concrete_nodes() { |
|
| 63 | + | let doc = json!({"node": {"type": "string"}}); |
|
| 64 | + | let node = deref(&doc, &doc["node"]); |
|
| 65 | + | assert_eq!(node["type"], "string"); |
|
| 66 | + | } |
|
| 67 | + | } |
| 1 | + | use std::fs; |
|
| 2 | + | use std::path::PathBuf; |
|
| 3 | + | ||
| 4 | + | use anyhow::{Context, Result, anyhow, bail}; |
|
| 5 | + | use serde::{Deserialize, Serialize}; |
|
| 6 | + | ||
| 7 | + | use crate::model::Collection; |
|
| 8 | + | ||
| 9 | + | /// Root config directory: `~/.config/cielago` on every platform, matching the |
|
| 10 | + | /// documented storage layout (rather than e.g. `~/Library/Application Support` |
|
| 11 | + | /// on macOS). |
|
| 12 | + | pub fn config_dir() -> Result<PathBuf> { |
|
| 13 | + | let home = dirs::home_dir().ok_or_else(|| anyhow!("could not determine home directory"))?; |
|
| 14 | + | let dir = home.join(".config").join("cielago"); |
|
| 15 | + | migrate_legacy_dirs(&home.join(".config"), &dir); |
|
| 16 | + | Ok(dir) |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | /// Names this project shipped under before `cielago`, newest first. |
|
| 20 | + | const LEGACY_DIR_NAMES: [&str; 3] = ["manpost", "stableman", "getman"]; |
|
| 21 | + | ||
| 22 | + | /// Move a leftover directory from an earlier name onto the current one, so |
|
| 23 | + | /// existing collections survive the rename. No-op once `cielago` exists. |
|
| 24 | + | fn migrate_legacy_dirs(config_root: &std::path::Path, new: &PathBuf) { |
|
| 25 | + | if new.exists() { |
|
| 26 | + | return; |
|
| 27 | + | } |
|
| 28 | + | for legacy in LEGACY_DIR_NAMES { |
|
| 29 | + | let old = config_root.join(legacy); |
|
| 30 | + | if old.exists() && fs::rename(&old, new).is_ok() { |
|
| 31 | + | return; |
|
| 32 | + | } |
|
| 33 | + | } |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | pub fn collections_dir() -> Result<PathBuf> { |
|
| 37 | + | Ok(config_dir()?.join("collections")) |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | /// Filesystem-safe slug for a collection name. |
|
| 41 | + | pub fn slugify(name: &str) -> String { |
|
| 42 | + | let mut slug = String::new(); |
|
| 43 | + | let mut last_dash = false; |
|
| 44 | + | for c in name.chars() { |
|
| 45 | + | if c.is_ascii_alphanumeric() { |
|
| 46 | + | slug.push(c.to_ascii_lowercase()); |
|
| 47 | + | last_dash = false; |
|
| 48 | + | } else if !last_dash && !slug.is_empty() { |
|
| 49 | + | slug.push('-'); |
|
| 50 | + | last_dash = true; |
|
| 51 | + | } |
|
| 52 | + | } |
|
| 53 | + | let slug = slug.trim_matches('-').to_string(); |
|
| 54 | + | if slug.is_empty() { |
|
| 55 | + | "collection".into() |
|
| 56 | + | } else { |
|
| 57 | + | slug |
|
| 58 | + | } |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | pub fn collection_path(name: &str) -> Result<PathBuf> { |
|
| 62 | + | Ok(collections_dir()?.join(format!("{}.json", slugify(name)))) |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | pub fn save_collection(collection: &Collection) -> Result<PathBuf> { |
|
| 66 | + | let dir = collections_dir()?; |
|
| 67 | + | fs::create_dir_all(&dir).context("creating collections directory")?; |
|
| 68 | + | let path = collection_path(&collection.name)?; |
|
| 69 | + | let json = serde_json::to_string_pretty(collection)?; |
|
| 70 | + | fs::write(&path, json).with_context(|| format!("writing {}", path.display()))?; |
|
| 71 | + | Ok(path) |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | pub fn load_collection(name: &str) -> Result<Collection> { |
|
| 75 | + | let path = collection_path(name)?; |
|
| 76 | + | load_collection_path(&path) |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | pub fn load_collection_path(path: &PathBuf) -> Result<Collection> { |
|
| 80 | + | let text = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; |
|
| 81 | + | let collection = serde_json::from_str(&text) |
|
| 82 | + | .with_context(|| format!("parsing collection at {}", path.display()))?; |
|
| 83 | + | Ok(collection) |
|
| 84 | + | } |
|
| 85 | + | ||
| 86 | + | /// Resolve a user-typed collection name onto a saved one. Exact matches win; |
|
| 87 | + | /// otherwise anything that slugifies the same does, so `cielago delete "some |
|
| 88 | + | /// api"` finds `Some API`. |
|
| 89 | + | pub fn resolve_collection(name: &str) -> Result<String> { |
|
| 90 | + | let names = list_collections()?; |
|
| 91 | + | if let Some(found) = match_name(&names, name) { |
|
| 92 | + | return Ok(found); |
|
| 93 | + | } |
|
| 94 | + | if names.is_empty() { |
|
| 95 | + | bail!( |
|
| 96 | + | "No collections yet. Import one:\n\n cielago import <spec.json|yaml|url>\n\nOr create an empty one:\n\n cielago new <name>" |
|
| 97 | + | ) |
|
| 98 | + | } |
|
| 99 | + | bail!( |
|
| 100 | + | "No collection named {name:?}.\n\nAvailable: {}", |
|
| 101 | + | names.join(", ") |
|
| 102 | + | ) |
|
| 103 | + | } |
|
| 104 | + | ||
| 105 | + | /// Pick the saved name a user-typed one refers to: exact match first, then any |
|
| 106 | + | /// name with the same slug (which is what the file is named after anyway). |
|
| 107 | + | fn match_name(names: &[String], input: &str) -> Option<String> { |
|
| 108 | + | if names.iter().any(|n| n == input) { |
|
| 109 | + | return Some(input.to_string()); |
|
| 110 | + | } |
|
| 111 | + | let slug = slugify(input); |
|
| 112 | + | names.iter().find(|n| slugify(n) == slug).cloned() |
|
| 113 | + | } |
|
| 114 | + | ||
| 115 | + | /// Delete a saved collection. Returns the file that was removed. |
|
| 116 | + | pub fn delete_collection(name: &str) -> Result<PathBuf> { |
|
| 117 | + | let path = collection_path(name)?; |
|
| 118 | + | fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?; |
|
| 119 | + | Ok(path) |
|
| 120 | + | } |
|
| 121 | + | ||
| 122 | + | /// Names of all saved collections (derived from file names). |
|
| 123 | + | pub fn list_collections() -> Result<Vec<String>> { |
|
| 124 | + | let dir = collections_dir()?; |
|
| 125 | + | let mut names = Vec::new(); |
|
| 126 | + | if dir.exists() { |
|
| 127 | + | for entry in fs::read_dir(&dir)? { |
|
| 128 | + | let entry = entry?; |
|
| 129 | + | let path = entry.path(); |
|
| 130 | + | if path.extension().and_then(|e| e.to_str()) == Some("json") |
|
| 131 | + | && let Ok(c) = load_collection_path(&path) |
|
| 132 | + | { |
|
| 133 | + | names.push(c.name); |
|
| 134 | + | } |
|
| 135 | + | } |
|
| 136 | + | } |
|
| 137 | + | names.sort(); |
|
| 138 | + | Ok(names) |
|
| 139 | + | } |
|
| 140 | + | ||
| 141 | + | #[derive(Debug, Clone, Default, Serialize, Deserialize)] |
|
| 142 | + | pub struct AppConfig { |
|
| 143 | + | #[serde(default)] |
|
| 144 | + | pub last_collection: Option<String>, |
|
| 145 | + | #[serde(default)] |
|
| 146 | + | pub editor: Option<String>, |
|
| 147 | + | } |
|
| 148 | + | ||
| 149 | + | impl AppConfig { |
|
| 150 | + | fn path() -> Result<PathBuf> { |
|
| 151 | + | Ok(config_dir()?.join("config.json")) |
|
| 152 | + | } |
|
| 153 | + | ||
| 154 | + | pub fn load() -> AppConfig { |
|
| 155 | + | Self::path() |
|
| 156 | + | .and_then(|p| Ok(fs::read_to_string(p)?)) |
|
| 157 | + | .and_then(|t| Ok(serde_json::from_str(&t)?)) |
|
| 158 | + | .unwrap_or_default() |
|
| 159 | + | } |
|
| 160 | + | ||
| 161 | + | pub fn save(&self) -> Result<()> { |
|
| 162 | + | let dir = config_dir()?; |
|
| 163 | + | fs::create_dir_all(&dir)?; |
|
| 164 | + | fs::write(Self::path()?, serde_json::to_string_pretty(self)?)?; |
|
| 165 | + | Ok(()) |
|
| 166 | + | } |
|
| 167 | + | ||
| 168 | + | /// Editor to use for external body editing: config override, `$EDITOR`, else `vi`. |
|
| 169 | + | pub fn editor_cmd(&self) -> String { |
|
| 170 | + | self.editor |
|
| 171 | + | .clone() |
|
| 172 | + | .or_else(|| std::env::var("EDITOR").ok()) |
|
| 173 | + | .filter(|e| !e.is_empty()) |
|
| 174 | + | .unwrap_or_else(|| "vi".into()) |
|
| 175 | + | } |
|
| 176 | + | } |
|
| 177 | + | ||
| 178 | + | #[cfg(test)] |
|
| 179 | + | mod tests { |
|
| 180 | + | use super::*; |
|
| 181 | + | use crate::model::Method; |
|
| 182 | + | ||
| 183 | + | #[test] |
|
| 184 | + | fn slugify_basic() { |
|
| 185 | + | assert_eq!(slugify("My Pet API"), "my-pet-api"); |
|
| 186 | + | assert_eq!(slugify("pets_v2 (internal)"), "pets-v2-internal"); |
|
| 187 | + | assert_eq!(slugify("!!!"), "collection"); |
|
| 188 | + | assert_eq!(slugify("a"), "a"); |
|
| 189 | + | } |
|
| 190 | + | ||
| 191 | + | #[test] |
|
| 192 | + | fn match_name_exact_then_slug() { |
|
| 193 | + | let names = vec!["Some API".to_string(), "Other API".to_string()]; |
|
| 194 | + | assert_eq!(match_name(&names, "Some API").as_deref(), Some("Some API")); |
|
| 195 | + | assert_eq!(match_name(&names, "some api").as_deref(), Some("Some API")); |
|
| 196 | + | assert_eq!(match_name(&names, "some-api").as_deref(), Some("Some API")); |
|
| 197 | + | assert_eq!(match_name(&names, "nope"), None); |
|
| 198 | + | } |
|
| 199 | + | ||
| 200 | + | #[test] |
|
| 201 | + | fn collection_json_roundtrip() { |
|
| 202 | + | let mut c = Collection::new("Test API"); |
|
| 203 | + | c.servers = vec![ |
|
| 204 | + | "https://a.example.com".into(), |
|
| 205 | + | "https://b.example.com".into(), |
|
| 206 | + | ]; |
|
| 207 | + | c.active_server = 1; |
|
| 208 | + | c.variables |
|
| 209 | + | .push(crate::model::KeyValueRow::new("tenant", "acme", true)); |
|
| 210 | + | c.auth = Some(crate::model::OAuthConfig { |
|
| 211 | + | token_url: "https://auth.example.com/token".into(), |
|
| 212 | + | client_id: "id".into(), |
|
| 213 | + | client_secret: "secret".into(), |
|
| 214 | + | scopes: vec!["read".into()], |
|
| 215 | + | auth_style: crate::model::AuthStyle::Basic, |
|
| 216 | + | }); |
|
| 217 | + | let mut r = crate::model::SavedRequest::blank("list pets"); |
|
| 218 | + | r.method = Method::Post; |
|
| 219 | + | r.query |
|
| 220 | + | .push(crate::model::KeyValueRow::new("limit", "10", false)); |
|
| 221 | + | r.body = Some("{\"a\":1}".into()); |
|
| 222 | + | c.requests.push(r); |
|
| 223 | + | ||
| 224 | + | let json = serde_json::to_string_pretty(&c).unwrap(); |
|
| 225 | + | let back: Collection = serde_json::from_str(&json).unwrap(); |
|
| 226 | + | ||
| 227 | + | assert_eq!(back.name, "Test API"); |
|
| 228 | + | assert_eq!(back.base_url(), Some("https://b.example.com")); |
|
| 229 | + | assert_eq!(back.variables[0].value, "acme"); |
|
| 230 | + | assert_eq!(back.auth.as_ref().unwrap().client_secret, "secret"); |
|
| 231 | + | assert_eq!(back.requests.len(), 1); |
|
| 232 | + | assert_eq!(back.requests[0].method, Method::Post); |
|
| 233 | + | assert!(!back.requests[0].query[0].enabled); |
|
| 234 | + | } |
|
| 235 | + | } |
| 1 | + | //! Rendering: sidebar / URL bar / editor tabs / response / status / popups. |
|
| 2 | + | ||
| 3 | + | use ratatui::Frame; |
|
| 4 | + | use ratatui::layout::{Constraint, Layout, Rect}; |
|
| 5 | + | use ratatui::style::{Color, Modifier, Style}; |
|
| 6 | + | use ratatui::text::{Line, Span}; |
|
| 7 | + | use ratatui::widgets::{ |
|
| 8 | + | Block, Borders, Cell, Clear, List, ListItem, ListState, Paragraph, Row, Table, TableState, |
|
| 9 | + | Tabs, Wrap, |
|
| 10 | + | }; |
|
| 11 | + | ||
| 12 | + | use crate::app::{App, EditTarget, EditorTab, Focus, Mode, Popup, SidebarRow, TableId}; |
|
| 13 | + | use crate::highlight; |
|
| 14 | + | use crate::http::DYNAMIC_VARS; |
|
| 15 | + | use crate::model::Method; |
|
| 16 | + | ||
| 17 | + | const SIDEBAR_WIDTH: u16 = 38; |
|
| 18 | + | ||
| 19 | + | pub fn draw(f: &mut Frame, app: &mut App) { |
|
| 20 | + | let area = f.area(); |
|
| 21 | + | let [main_area, status_area] = |
|
| 22 | + | Layout::vertical([Constraint::Min(3), Constraint::Length(1)]).areas(area); |
|
| 23 | + | if app.zoom { |
|
| 24 | + | // Only the focused pane is drawn, filling everything above the status |
|
| 25 | + | // bar. The URL bar belongs to the editor pane (it renders the selected |
|
| 26 | + | // request and takes the editor's focus colour), so it comes along. |
|
| 27 | + | match app.focus { |
|
| 28 | + | Focus::Sidebar => draw_sidebar(f, app, main_area), |
|
| 29 | + | Focus::Editor => { |
|
| 30 | + | let [url_area, editor_area] = |
|
| 31 | + | Layout::vertical([Constraint::Length(3), Constraint::Min(3)]).areas(main_area); |
|
| 32 | + | draw_url_bar(f, app, url_area); |
|
| 33 | + | draw_editor(f, app, editor_area); |
|
| 34 | + | } |
|
| 35 | + | Focus::Response => draw_response(f, app, main_area), |
|
| 36 | + | } |
|
| 37 | + | } else { |
|
| 38 | + | let [side_area, right_area] = |
|
| 39 | + | Layout::horizontal([Constraint::Length(SIDEBAR_WIDTH), Constraint::Min(40)]) |
|
| 40 | + | .areas(main_area); |
|
| 41 | + | let [url_area, editor_area, response_area] = Layout::vertical([ |
|
| 42 | + | Constraint::Length(3), |
|
| 43 | + | Constraint::Percentage(45), |
|
| 44 | + | Constraint::Min(5), |
|
| 45 | + | ]) |
|
| 46 | + | .areas(right_area); |
|
| 47 | + | ||
| 48 | + | draw_sidebar(f, app, side_area); |
|
| 49 | + | draw_url_bar(f, app, url_area); |
|
| 50 | + | draw_editor(f, app, editor_area); |
|
| 51 | + | draw_response(f, app, response_area); |
|
| 52 | + | } |
|
| 53 | + | draw_status(f, app, status_area); |
|
| 54 | + | ||
| 55 | + | match app.popup { |
|
| 56 | + | Popup::Help => draw_help(f, app, area), |
|
| 57 | + | Popup::Env => draw_env(f, app, area), |
|
| 58 | + | Popup::Auth => draw_auth(f, app, area), |
|
| 59 | + | Popup::None => {} |
|
| 60 | + | } |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | // ----- shared styles ----- |
|
| 64 | + | ||
| 65 | + | fn focused(focus: bool) -> Style { |
|
| 66 | + | if focus { |
|
| 67 | + | Style::default().fg(Color::Cyan) |
|
| 68 | + | } else { |
|
| 69 | + | Style::default().fg(Color::DarkGray) |
|
| 70 | + | } |
|
| 71 | + | } |
|
| 72 | + | ||
| 73 | + | fn method_color(m: Method) -> Color { |
|
| 74 | + | match m { |
|
| 75 | + | Method::Get => Color::Green, |
|
| 76 | + | Method::Post => Color::Yellow, |
|
| 77 | + | Method::Put => Color::Blue, |
|
| 78 | + | Method::Patch => Color::Magenta, |
|
| 79 | + | Method::Delete => Color::Red, |
|
| 80 | + | Method::Head => Color::Cyan, |
|
| 81 | + | Method::Options => Color::Gray, |
|
| 82 | + | } |
|
| 83 | + | } |
|
| 84 | + | ||
| 85 | + | fn checkbox(enabled: bool) -> &'static str { |
|
| 86 | + | if enabled { "[x]" } else { "[ ]" } |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | // ----- sidebar ----- |
|
| 90 | + | ||
| 91 | + | fn draw_sidebar(f: &mut Frame, app: &mut App, area: Rect) { |
|
| 92 | + | let title = if app.filter.is_empty() { |
|
| 93 | + | format!(" {} ", app.collection.name) |
|
| 94 | + | } else { |
|
| 95 | + | format!(" {} — /{} ", app.collection.name, app.filter) |
|
| 96 | + | }; |
|
| 97 | + | let block = Block::default() |
|
| 98 | + | .title(title) |
|
| 99 | + | .borders(Borders::ALL) |
|
| 100 | + | .border_style(focused(app.focus == Focus::Sidebar)); |
|
| 101 | + | ||
| 102 | + | let mut items: Vec<ListItem> = Vec::new(); |
|
| 103 | + | for row in &app.sidebar_rows { |
|
| 104 | + | match row { |
|
| 105 | + | SidebarRow::Group(tag) => { |
|
| 106 | + | let marker = if app.collapsed.contains(tag) { |
|
| 107 | + | "▸" |
|
| 108 | + | } else { |
|
| 109 | + | "▾" |
|
| 110 | + | }; |
|
| 111 | + | items.push(ListItem::new(Line::from(vec![Span::styled( |
|
| 112 | + | format!("{marker} {tag}"), |
|
| 113 | + | Style::default().add_modifier(Modifier::BOLD), |
|
| 114 | + | )]))); |
|
| 115 | + | } |
|
| 116 | + | SidebarRow::Request(i) => { |
|
| 117 | + | let req = &app.collection.requests[*i]; |
|
| 118 | + | items.push(ListItem::new(Line::from(vec![ |
|
| 119 | + | Span::styled( |
|
| 120 | + | format!(" {:<7}", req.method.to_string()), |
|
| 121 | + | Style::default().fg(method_color(req.method)), |
|
| 122 | + | ), |
|
| 123 | + | Span::raw(req.label(app.collection.label_mode).to_string()), |
|
| 124 | + | ]))); |
|
| 125 | + | } |
|
| 126 | + | } |
|
| 127 | + | } |
|
| 128 | + | ||
| 129 | + | let list = List::new(items) |
|
| 130 | + | .block(block) |
|
| 131 | + | .highlight_style( |
|
| 132 | + | Style::default() |
|
| 133 | + | .bg(Color::DarkGray) |
|
| 134 | + | .add_modifier(Modifier::BOLD), |
|
| 135 | + | ) |
|
| 136 | + | .highlight_symbol(">"); |
|
| 137 | + | // Keep the selection centered while scrolling; pin to the ends near top/bottom. |
|
| 138 | + | let viewport = area.height.saturating_sub(2) as usize; // minus borders |
|
| 139 | + | let offset = centered_offset(app.sidebar_sel, app.sidebar_rows.len(), viewport); |
|
| 140 | + | let mut state = ListState::default() |
|
| 141 | + | .with_selected(Some(app.sidebar_sel)) |
|
| 142 | + | .with_offset(offset); |
|
| 143 | + | f.render_stateful_widget(list, area, &mut state); |
|
| 144 | + | } |
|
| 145 | + | ||
| 146 | + | /// Scroll offset that holds `sel` at the vertical middle of a `viewport`-tall |
|
| 147 | + | /// list, clamped so the first and last items never scroll past the edges. |
|
| 148 | + | fn centered_offset(sel: usize, len: usize, viewport: usize) -> usize { |
|
| 149 | + | if viewport == 0 || len <= viewport { |
|
| 150 | + | return 0; |
|
| 151 | + | } |
|
| 152 | + | let max_offset = len - viewport; |
|
| 153 | + | sel.saturating_sub(viewport / 2).min(max_offset) |
|
| 154 | + | } |
|
| 155 | + | ||
| 156 | + | // ----- URL bar ----- |
|
| 157 | + | ||
| 158 | + | fn draw_url_bar(f: &mut Frame, app: &App, area: Rect) { |
|
| 159 | + | let (title, line) = match app.selected_request() { |
|
| 160 | + | Some(req) => { |
|
| 161 | + | let url = format!( |
|
| 162 | + | "{}{}", |
|
| 163 | + | app.collection.base_url().unwrap_or("<no server — press E>"), |
|
| 164 | + | req.path |
|
| 165 | + | ); |
|
| 166 | + | ( |
|
| 167 | + | format!(" {} — p: edit url ", req.name), |
|
| 168 | + | Line::from(vec![ |
|
| 169 | + | Span::styled( |
|
| 170 | + | format!(" {:<7}", req.method.to_string()), |
|
| 171 | + | Style::default() |
|
| 172 | + | .fg(method_color(req.method)) |
|
| 173 | + | .add_modifier(Modifier::BOLD), |
|
| 174 | + | ), |
|
| 175 | + | Span::raw(url), |
|
| 176 | + | ]), |
|
| 177 | + | ) |
|
| 178 | + | } |
|
| 179 | + | None => ( |
|
| 180 | + | " cielago ".to_string(), |
|
| 181 | + | Line::from("No request selected — pick one from the sidebar"), |
|
| 182 | + | ), |
|
| 183 | + | }; |
|
| 184 | + | let block = Block::default() |
|
| 185 | + | .title(title) |
|
| 186 | + | .borders(Borders::ALL) |
|
| 187 | + | .border_style(focused(app.focus == Focus::Editor)); |
|
| 188 | + | f.render_widget(Paragraph::new(line).block(block), area); |
|
| 189 | + | } |
|
| 190 | + | ||
| 191 | + | // ----- editor ----- |
|
| 192 | + | ||
| 193 | + | fn draw_editor(f: &mut Frame, app: &mut App, area: Rect) { |
|
| 194 | + | let outer = Block::default() |
|
| 195 | + | .borders(Borders::ALL) |
|
| 196 | + | .border_style(focused(app.focus == Focus::Editor)); |
|
| 197 | + | let inner = outer.inner(area); |
|
| 198 | + | f.render_widget(outer, area); |
|
| 199 | + | ||
| 200 | + | let [tabs_area, content_area] = |
|
| 201 | + | Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(inner); |
|
| 202 | + | ||
| 203 | + | let tabs = Tabs::new(EditorTab::ALL.iter().map(|t| t.title()).collect::<Vec<_>>()) |
|
| 204 | + | .select(app.tab.index()) |
|
| 205 | + | .highlight_style( |
|
| 206 | + | Style::default() |
|
| 207 | + | .fg(Color::Cyan) |
|
| 208 | + | .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), |
|
| 209 | + | ) |
|
| 210 | + | .divider("│"); |
|
| 211 | + | f.render_widget(tabs, tabs_area); |
|
| 212 | + | ||
| 213 | + | match app.tab { |
|
| 214 | + | EditorTab::Body => draw_body(f, app, content_area), |
|
| 215 | + | EditorTab::Docs => draw_docs(f, app, content_area), |
|
| 216 | + | tab => { |
|
| 217 | + | if let Some(table) = tab.table() { |
|
| 218 | + | draw_table(f, app, content_area, table) |
|
| 219 | + | } |
|
| 220 | + | } |
|
| 221 | + | } |
|
| 222 | + | } |
|
| 223 | + | ||
| 224 | + | /// The body is syntax-highlighted while read-only and handed to the raw |
|
| 225 | + | /// `TextArea` during editing: `tui-textarea` styles whole lines only, so one |
|
| 226 | + | /// widget cannot do both. The textarea stays the source of truth either way — |
|
| 227 | + | /// the read-only view renders its lines and follows its cursor. |
|
| 228 | + | fn draw_body(f: &mut Frame, app: &mut App, area: Rect) { |
|
| 229 | + | // Insert mode with no `editing` target means the textarea has the keys. |
|
| 230 | + | if app.mode == Mode::Insert && app.editing.is_none() { |
|
| 231 | + | app.textarea.set_block( |
|
| 232 | + | Block::default() |
|
| 233 | + | .title(" Body — Esc: done ") |
|
| 234 | + | .borders(Borders::NONE), |
|
| 235 | + | ); |
|
| 236 | + | f.render_widget(&app.textarea, area); |
|
| 237 | + | return; |
|
| 238 | + | } |
|
| 239 | + | ||
| 240 | + | let block = Block::default() |
|
| 241 | + | .title(" Body — i: edit · e: $EDITOR · j/k: scroll ") |
|
| 242 | + | .borders(Borders::NONE); |
|
| 243 | + | let inner = block.inner(area); |
|
| 244 | + | f.render_widget(block, area); |
|
| 245 | + | ||
| 246 | + | let text = app.textarea.lines().join("\n"); |
|
| 247 | + | let mut lines = highlight::highlight(&text, true); |
|
| 248 | + | // Mark where `i` would drop the cursor. |
|
| 249 | + | let cursor_row = app.textarea.cursor().0; |
|
| 250 | + | if let Some(line) = lines.get_mut(cursor_row) { |
|
| 251 | + | *line = std::mem::take(line).style(Style::default().bg(Color::Rgb(40, 40, 40))); |
|
| 252 | + | } |
|
| 253 | + | let offset = centered_offset(cursor_row, lines.len(), inner.height as usize); |
|
| 254 | + | f.render_widget(Paragraph::new(lines).scroll((offset as u16, 0)), inner); |
|
| 255 | + | } |
|
| 256 | + | ||
| 257 | + | /// Read-only view of what the spec says about this request: the operation |
|
| 258 | + | /// description, then every parameter and body field with its type, accepted |
|
| 259 | + | /// values and default. |
|
| 260 | + | fn draw_docs(f: &mut Frame, app: &mut App, area: Rect) { |
|
| 261 | + | let block = Block::default() |
|
| 262 | + | .title(" Docs — j/k: scroll · read-only ") |
|
| 263 | + | .borders(Borders::NONE); |
|
| 264 | + | let inner = block.inner(area); |
|
| 265 | + | f.render_widget(block, area); |
|
| 266 | + | ||
| 267 | + | let mut lines: Vec<Line> = Vec::new(); |
|
| 268 | + | match app.selected_request() { |
|
| 269 | + | None => lines.push(Line::raw("No request selected.")), |
|
| 270 | + | Some(req) => { |
|
| 271 | + | lines.push(Line::from(vec![ |
|
| 272 | + | Span::styled( |
|
| 273 | + | format!("{} {}", req.method, req.path), |
|
| 274 | + | Style::default() |
|
| 275 | + | .fg(method_color(req.method)) |
|
| 276 | + | .add_modifier(Modifier::BOLD), |
|
| 277 | + | ), |
|
| 278 | + | Span::raw(" "), |
|
| 279 | + | Span::styled( |
|
| 280 | + | req.summary.clone().unwrap_or_else(|| req.name.clone()), |
|
| 281 | + | Style::default().fg(Color::Gray), |
|
| 282 | + | ), |
|
| 283 | + | ])); |
|
| 284 | + | if let Some(desc) = &req.description { |
|
| 285 | + | lines.push(Line::raw("")); |
|
| 286 | + | lines.extend( |
|
| 287 | + | desc.lines() |
|
| 288 | + | .map(|l| Line::styled(l.to_string(), Style::default().fg(Color::Gray))), |
|
| 289 | + | ); |
|
| 290 | + | } |
|
| 291 | + | ||
| 292 | + | if req.docs.is_empty() { |
|
| 293 | + | lines.push(Line::raw("")); |
|
| 294 | + | lines.push(Line::styled( |
|
| 295 | + | "No spec docs for this request. Hand-made requests have none;", |
|
| 296 | + | Style::default().fg(Color::DarkGray), |
|
| 297 | + | )); |
|
| 298 | + | lines.push(Line::styled( |
|
| 299 | + | "for imported ones, re-import the spec to fill this in.", |
|
| 300 | + | Style::default().fg(Color::DarkGray), |
|
| 301 | + | )); |
|
| 302 | + | } |
|
| 303 | + | for (location, heading) in [ |
|
| 304 | + | ("path", "Path params"), |
|
| 305 | + | ("query", "Query params"), |
|
| 306 | + | ("header", "Headers"), |
|
| 307 | + | ("body", "Body"), |
|
| 308 | + | ] { |
|
| 309 | + | let fields = req.docs.iter().filter(|d| d.location == location); |
|
| 310 | + | let mut first = true; |
|
| 311 | + | for d in fields { |
|
| 312 | + | if first { |
|
| 313 | + | lines.push(Line::raw("")); |
|
| 314 | + | lines.push(Line::styled( |
|
| 315 | + | heading.to_string(), |
|
| 316 | + | Style::default().add_modifier(Modifier::BOLD), |
|
| 317 | + | )); |
|
| 318 | + | first = false; |
|
| 319 | + | } |
|
| 320 | + | let mut head = vec![ |
|
| 321 | + | Span::styled(format!(" {}", d.name), Style::default().fg(Color::Cyan)), |
|
| 322 | + | // Required fields are starred, as in most API docs. |
|
| 323 | + | Span::styled( |
|
| 324 | + | if d.required { "*" } else { "" }, |
|
| 325 | + | Style::default().fg(Color::Red), |
|
| 326 | + | ), |
|
| 327 | + | Span::raw(" "), |
|
| 328 | + | Span::styled(d.ty.clone(), Style::default().fg(Color::Yellow)), |
|
| 329 | + | ]; |
|
| 330 | + | if let Some(default) = &d.default { |
|
| 331 | + | head.push(Span::styled( |
|
| 332 | + | format!(" = {default}"), |
|
| 333 | + | Style::default().fg(Color::DarkGray), |
|
| 334 | + | )); |
|
| 335 | + | } |
|
| 336 | + | lines.push(Line::from(head)); |
|
| 337 | + | if !d.options.is_empty() { |
|
| 338 | + | lines.push(Line::from(vec![ |
|
| 339 | + | Span::styled(" one of: ", Style::default().fg(Color::DarkGray)), |
|
| 340 | + | Span::styled( |
|
| 341 | + | d.options.join(" | "), |
|
| 342 | + | Style::default().fg(Color::Magenta), |
|
| 343 | + | ), |
|
| 344 | + | ])); |
|
| 345 | + | } |
|
| 346 | + | if let Some(desc) = &d.description { |
|
| 347 | + | lines.extend(desc.lines().map(|l| { |
|
| 348 | + | Line::styled(format!(" {l}"), Style::default().fg(Color::Gray)) |
|
| 349 | + | })); |
|
| 350 | + | } |
|
| 351 | + | } |
|
| 352 | + | } |
|
| 353 | + | } |
|
| 354 | + | } |
|
| 355 | + | ||
| 356 | + | // Wrapping means this is a lower bound on the rendered height, so the last |
|
| 357 | + | // line always stays reachable. |
|
| 358 | + | let max_scroll = lines.len().saturating_sub(inner.height as usize); |
|
| 359 | + | app.docs_scroll = app.docs_scroll.min(max_scroll); |
|
| 360 | + | f.render_widget( |
|
| 361 | + | Paragraph::new(lines) |
|
| 362 | + | .wrap(Wrap { trim: false }) |
|
| 363 | + | .scroll((app.docs_scroll as u16, 0)), |
|
| 364 | + | inner, |
|
| 365 | + | ); |
|
| 366 | + | } |
|
| 367 | + | ||
| 368 | + | fn draw_table(f: &mut Frame, app: &App, area: Rect, table: TableId) { |
|
| 369 | + | let rows_data: Vec<(bool, String, String, String)> = match table { |
|
| 370 | + | TableId::Params => { |
|
| 371 | + | let mut v: Vec<(bool, String, String, String)> = Vec::new(); |
|
| 372 | + | if let Some(req) = app.selected_request() { |
|
| 373 | + | v.extend( |
|
| 374 | + | req.path_params |
|
| 375 | + | .iter() |
|
| 376 | + | .map(|r| (r.enabled, "path".into(), r.key.clone(), r.value.clone())), |
|
| 377 | + | ); |
|
| 378 | + | v.extend( |
|
| 379 | + | req.query |
|
| 380 | + | .iter() |
|
| 381 | + | .map(|r| (r.enabled, "query".into(), r.key.clone(), r.value.clone())), |
|
| 382 | + | ); |
|
| 383 | + | } |
|
| 384 | + | v |
|
| 385 | + | } |
|
| 386 | + | TableId::Headers => app |
|
| 387 | + | .selected_request() |
|
| 388 | + | .map(|req| { |
|
| 389 | + | req.headers |
|
| 390 | + | .iter() |
|
| 391 | + | .map(|r| (r.enabled, String::new(), r.key.clone(), r.value.clone())) |
|
| 392 | + | .collect() |
|
| 393 | + | }) |
|
| 394 | + | .unwrap_or_default(), |
|
| 395 | + | TableId::Vars => app |
|
| 396 | + | .collection |
|
| 397 | + | .variables |
|
| 398 | + | .iter() |
|
| 399 | + | .map(|r| (r.enabled, String::new(), r.key.clone(), r.value.clone())) |
|
| 400 | + | .collect(), |
|
| 401 | + | }; |
|
| 402 | + | ||
| 403 | + | let rows: Vec<Row> = rows_data |
|
| 404 | + | .iter() |
|
| 405 | + | .map(|(enabled, loc, key, value)| { |
|
| 406 | + | let style = if *enabled { |
|
| 407 | + | Style::default() |
|
| 408 | + | } else { |
|
| 409 | + | Style::default().fg(Color::DarkGray) |
|
| 410 | + | }; |
|
| 411 | + | let mut cells = vec![Cell::from(checkbox(*enabled))]; |
|
| 412 | + | if table == TableId::Params { |
|
| 413 | + | cells.push(Cell::from(loc.clone())); |
|
| 414 | + | } |
|
| 415 | + | cells.push(Cell::from(key.clone())); |
|
| 416 | + | cells.push(Cell::from(value.clone())); |
|
| 417 | + | Row::new(cells).style(style) |
|
| 418 | + | }) |
|
| 419 | + | .collect(); |
|
| 420 | + | ||
| 421 | + | let widths: Vec<Constraint> = if table == TableId::Params { |
|
| 422 | + | vec![ |
|
| 423 | + | Constraint::Length(4), |
|
| 424 | + | Constraint::Length(6), |
|
| 425 | + | Constraint::Percentage(30), |
|
| 426 | + | Constraint::Min(10), |
|
| 427 | + | ] |
|
| 428 | + | } else { |
|
| 429 | + | vec![ |
|
| 430 | + | Constraint::Length(4), |
|
| 431 | + | Constraint::Percentage(30), |
|
| 432 | + | Constraint::Min(10), |
|
| 433 | + | ] |
|
| 434 | + | }; |
|
| 435 | + | ||
| 436 | + | let hint = match table { |
|
| 437 | + | TableId::Vars => { |
|
| 438 | + | " Variables — {{name}} usable anywhere · space: toggle · a: add · i: edit · d: del " |
|
| 439 | + | } |
|
| 440 | + | _ => " space: toggle · a: add · i: edit · d: del · Enter: send ", |
|
| 441 | + | }; |
|
| 442 | + | ||
| 443 | + | let t = Table::new(rows, widths) |
|
| 444 | + | .block(Block::default().title(hint).borders(Borders::NONE)) |
|
| 445 | + | .row_highlight_style( |
|
| 446 | + | Style::default() |
|
| 447 | + | .bg(Color::DarkGray) |
|
| 448 | + | .add_modifier(Modifier::BOLD), |
|
| 449 | + | ) |
|
| 450 | + | .highlight_symbol(">"); |
|
| 451 | + | let mut state = TableState::default().with_selected(if rows_data.is_empty() { |
|
| 452 | + | None |
|
| 453 | + | } else { |
|
| 454 | + | Some(app.table_row) |
|
| 455 | + | }); |
|
| 456 | + | f.render_stateful_widget(t, area, &mut state); |
|
| 457 | + | } |
|
| 458 | + | ||
| 459 | + | // ----- response ----- |
|
| 460 | + | ||
| 461 | + | fn draw_response(f: &mut Frame, app: &mut App, area: Rect) { |
|
| 462 | + | let title = match (&app.response, app.sending) { |
|
| 463 | + | (_, true) => " Response — sending… ".to_string(), |
|
| 464 | + | (Some(resp), false) => format!(" Response — {} ", resp.status_line()), |
|
| 465 | + | (None, false) => " Response ".to_string(), |
|
| 466 | + | }; |
|
| 467 | + | let block = Block::default() |
|
| 468 | + | .title(title) |
|
| 469 | + | .borders(Borders::ALL) |
|
| 470 | + | .border_style(focused(app.focus == Focus::Response)); |
|
| 471 | + | ||
| 472 | + | let body = app |
|
| 473 | + | .response |
|
| 474 | + | .as_ref() |
|
| 475 | + | .map(|r| r.body.as_str()) |
|
| 476 | + | .unwrap_or("No response yet — press Enter on the editor to send."); |
|
| 477 | + | ||
| 478 | + | // Clamp scroll to content length. |
|
| 479 | + | let max_scroll = body.lines().count().saturating_sub(1); |
|
| 480 | + | if app.response_scroll > max_scroll { |
|
| 481 | + | app.response_scroll = max_scroll; |
|
| 482 | + | } |
|
| 483 | + | ||
| 484 | + | // `{{…}}` in a response is literal server output, not template syntax. |
|
| 485 | + | let p = Paragraph::new(highlight::highlight(body, false)) |
|
| 486 | + | .block(block) |
|
| 487 | + | .wrap(Wrap { trim: false }) |
|
| 488 | + | .scroll((app.response_scroll as u16, 0)); |
|
| 489 | + | f.render_widget(p, area); |
|
| 490 | + | } |
|
| 491 | + | ||
| 492 | + | // ----- status bar ----- |
|
| 493 | + | ||
| 494 | + | fn draw_status(f: &mut Frame, app: &App, area: Rect) { |
|
| 495 | + | match app.mode { |
|
| 496 | + | Mode::Command => { |
|
| 497 | + | f.render_widget(Paragraph::new(format!(":{}", app.command)), area); |
|
| 498 | + | f.set_cursor_position((area.x + 1 + app.command.len() as u16, area.y)); |
|
| 499 | + | } |
|
| 500 | + | Mode::Search => { |
|
| 501 | + | f.render_widget(Paragraph::new(format!("/{}", app.search.buf)), area); |
|
| 502 | + | let cursor_chars = app.search.buf[..app.search.cursor].chars().count() as u16; |
|
| 503 | + | f.set_cursor_position((area.x + 1 + cursor_chars, area.y)); |
|
| 504 | + | } |
|
| 505 | + | Mode::Insert if app.editing.is_some() => { |
|
| 506 | + | let label = match app.editing.unwrap() { |
|
| 507 | + | EditTarget::Cell { col, .. } => match col { |
|
| 508 | + | crate::app::CellCol::Key => "key", |
|
| 509 | + | crate::app::CellCol::Value => "value", |
|
| 510 | + | }, |
|
| 511 | + | EditTarget::Rename => "rename", |
|
| 512 | + | EditTarget::NewRequest => "new request", |
|
| 513 | + | EditTarget::Url => "url", |
|
| 514 | + | EditTarget::EnvNew => "server url", |
|
| 515 | + | EditTarget::AuthField(_) => "auth", |
|
| 516 | + | }; |
|
| 517 | + | let prompt = format!("{label}> "); |
|
| 518 | + | f.render_widget(Paragraph::new(format!("{prompt}{}", app.input.buf)), area); |
|
| 519 | + | let cursor_chars = app.input.buf[..app.input.cursor].chars().count() as u16; |
|
| 520 | + | f.set_cursor_position((area.x + prompt.len() as u16 + cursor_chars, area.y)); |
|
| 521 | + | } |
|
| 522 | + | _ => { |
|
| 523 | + | let mode_badge = match app.mode { |
|
| 524 | + | Mode::Normal => Span::styled( |
|
| 525 | + | " NORMAL ", |
|
| 526 | + | Style::default().bg(Color::Green).fg(Color::Black), |
|
| 527 | + | ), |
|
| 528 | + | Mode::Insert => Span::styled( |
|
| 529 | + | " INSERT ", |
|
| 530 | + | Style::default().bg(Color::Yellow).fg(Color::Black), |
|
| 531 | + | ), |
|
| 532 | + | Mode::Command | Mode::Search => Span::raw(""), |
|
| 533 | + | }; |
|
| 534 | + | let dirty = if app.dirty { "*" } else { "" }; |
|
| 535 | + | let server = app.collection.base_url().unwrap_or("no server"); |
|
| 536 | + | let line = Line::from(vec![ |
|
| 537 | + | mode_badge, |
|
| 538 | + | Span::raw(format!( |
|
| 539 | + | " {}{} | {} | {} ", |
|
| 540 | + | app.collection.name, dirty, server, app.status |
|
| 541 | + | )), |
|
| 542 | + | ]); |
|
| 543 | + | f.render_widget(Paragraph::new(line), area); |
|
| 544 | + | } |
|
| 545 | + | } |
|
| 546 | + | } |
|
| 547 | + | ||
| 548 | + | // ----- popups ----- |
|
| 549 | + | ||
| 550 | + | fn centered(area: Rect, pct_x: u16, pct_y: u16) -> Rect { |
|
| 551 | + | let [_, v, _] = Layout::vertical([ |
|
| 552 | + | Constraint::Percentage((100 - pct_y) / 2), |
|
| 553 | + | Constraint::Percentage(pct_y), |
|
| 554 | + | Constraint::Percentage((100 - pct_y) / 2), |
|
| 555 | + | ]) |
|
| 556 | + | .areas(area); |
|
| 557 | + | let [_, h, _] = Layout::horizontal([ |
|
| 558 | + | Constraint::Percentage((100 - pct_x) / 2), |
|
| 559 | + | Constraint::Percentage(pct_x), |
|
| 560 | + | Constraint::Percentage((100 - pct_x) / 2), |
|
| 561 | + | ]) |
|
| 562 | + | .areas(v); |
|
| 563 | + | h |
|
| 564 | + | } |
|
| 565 | + | ||
| 566 | + | fn draw_help(f: &mut Frame, app: &mut App, area: Rect) { |
|
| 567 | + | let popup = centered(area, 64, 80); |
|
| 568 | + | f.render_widget(Clear, popup); |
|
| 569 | + | let mut lines = vec![ |
|
| 570 | + | Line::styled("Global", Style::default().add_modifier(Modifier::BOLD)), |
|
| 571 | + | Line::raw(" 1/2/3, Tab focus sidebar / editor / response"), |
|
| 572 | + | Line::raw(" z maximize the focused pane (z again to restore)"), |
|
| 573 | + | Line::raw(" ] or L next tab (Params/Headers/Body/Docs/Variables)"), |
|
| 574 | + | Line::raw(" [ or H previous editor tab"), |
|
| 575 | + | Line::raw(" / search / filter requests"), |
|
| 576 | + | Line::raw(" E servers / base URLs"), |
|
| 577 | + | Line::raw(" A OAuth client-credentials config"), |
|
| 578 | + | Line::raw(" : command line (:w save, :q quit, :q! force, :wq)"), |
|
| 579 | + | Line::raw(" q quit (warns when unsaved)"), |
|
| 580 | + | Line::raw(""), |
|
| 581 | + | Line::styled("Sidebar", Style::default().add_modifier(Modifier::BOLD)), |
|
| 582 | + | Line::raw(" j/k, g/G navigate"), |
|
| 583 | + | Line::raw(" Enter/h/l open request · collapse/expand group"), |
|
| 584 | + | Line::raw(" n/r/d/y new / rename / delete / duplicate request"), |
|
| 585 | + | Line::raw(" / filter (Enter keeps it, Esc clears)"), |
|
| 586 | + | Line::raw(" t cycle labels: name → summary → path"), |
|
| 587 | + | Line::raw(""), |
|
| 588 | + | Line::styled( |
|
| 589 | + | "Editor (tables)", |
|
| 590 | + | Style::default().add_modifier(Modifier::BOLD), |
|
| 591 | + | ), |
|
| 592 | + | Line::raw(" Enter send request"), |
|
| 593 | + | Line::raw(" i edit value of selected row"), |
|
| 594 | + | Line::raw(" a add row (key then value)"), |
|
| 595 | + | Line::raw(" space enable/disable row"), |
|
| 596 | + | Line::raw(" d delete row · m cycle method · r rename"), |
|
| 597 | + | Line::raw(" p edit URL / path (paste a full URL to set"), |
|
| 598 | + | Line::raw(" the server; ?query fills the Params tab)"), |
|
| 599 | + | Line::raw(""), |
|
| 600 | + | Line::styled("Body tab", Style::default().add_modifier(Modifier::BOLD)), |
|
| 601 | + | Line::raw(" j/k, d/u scroll · g/G top/bottom"), |
|
| 602 | + | Line::raw(" i edit inline (Esc to finish)"), |
|
| 603 | + | Line::raw(" e open in $EDITOR"), |
|
| 604 | + | Line::raw(""), |
|
| 605 | + | Line::styled("Docs tab", Style::default().add_modifier(Modifier::BOLD)), |
|
| 606 | + | Line::raw(" types, enums and descriptions from the spec (* = required)"), |
|
| 607 | + | Line::raw(" j/k, d/u scroll · g/G top/bottom"), |
|
| 608 | + | Line::raw(""), |
|
| 609 | + | Line::styled("Response", Style::default().add_modifier(Modifier::BOLD)), |
|
| 610 | + | Line::raw(" j/k, d/u scroll · g/G top/bottom"), |
|
| 611 | + | Line::raw(" e open in $EDITOR (view only)"), |
|
| 612 | + | Line::raw(""), |
|
| 613 | + | Line::styled("Variables", Style::default().add_modifier(Modifier::BOLD)), |
|
| 614 | + | Line::raw(" {{name}} in paths, params, headers and bodies —"), |
|
| 615 | + | Line::raw(" substituted at send time. Dynamic ones, computed"), |
|
| 616 | + | Line::raw(" per send ({{$name}} to bypass a same-named variable):"), |
|
| 617 | + | ]; |
|
| 618 | + | lines.extend(DYNAMIC_VARS.iter().map(|(name, help)| { |
|
| 619 | + | Line::from(vec![ |
|
| 620 | + | Span::styled(format!(" {name:<15}"), Style::default().fg(Color::Magenta)), |
|
| 621 | + | Span::raw(*help), |
|
| 622 | + | ]) |
|
| 623 | + | })); |
|
| 624 | + | lines.extend([ |
|
| 625 | + | Line::raw(""), |
|
| 626 | + | Line::styled("Commands", Style::default().add_modifier(Modifier::BOLD)), |
|
| 627 | + | Line::raw(" :new <name> create a collection"), |
|
| 628 | + | Line::raw(" :open <name> switch collection"), |
|
| 629 | + | Line::raw(" :label name|summary|path sidebar label source"), |
|
| 630 | + | Line::raw(" :groups collapsed|expanded group state on open"), |
|
| 631 | + | Line::raw(" :rename-all summary|operation|path|method-path"), |
|
| 632 | + | ]); |
|
| 633 | + | ||
| 634 | + | // The list outgrows short terminals, so the popup scrolls with j/k. |
|
| 635 | + | let viewport = popup.height.saturating_sub(2) as usize; |
|
| 636 | + | let max_scroll = lines.len().saturating_sub(viewport); |
|
| 637 | + | app.help_scroll = app.help_scroll.min(max_scroll); |
|
| 638 | + | let more = if app.help_scroll < max_scroll { |
|
| 639 | + | " Help — j/k scroll · Esc to close " |
|
| 640 | + | } else { |
|
| 641 | + | " Help — Esc to close " |
|
| 642 | + | }; |
|
| 643 | + | let block = Block::default() |
|
| 644 | + | .title(more) |
|
| 645 | + | .borders(Borders::ALL) |
|
| 646 | + | .border_style(Style::default().fg(Color::Cyan)); |
|
| 647 | + | f.render_widget( |
|
| 648 | + | Paragraph::new(lines) |
|
| 649 | + | .block(block) |
|
| 650 | + | .scroll((app.help_scroll as u16, 0)), |
|
| 651 | + | popup, |
|
| 652 | + | ); |
|
| 653 | + | } |
|
| 654 | + | ||
| 655 | + | fn draw_env(f: &mut Frame, app: &App, area: Rect) { |
|
| 656 | + | let popup = centered(area, 60, 50); |
|
| 657 | + | f.render_widget(Clear, popup); |
|
| 658 | + | let items: Vec<ListItem> = app |
|
| 659 | + | .collection |
|
| 660 | + | .servers |
|
| 661 | + | .iter() |
|
| 662 | + | .enumerate() |
|
| 663 | + | .map(|(i, s)| { |
|
| 664 | + | let marker = if i == app.collection.active_server { |
|
| 665 | + | "● " |
|
| 666 | + | } else { |
|
| 667 | + | " " |
|
| 668 | + | }; |
|
| 669 | + | ListItem::new(format!("{marker}{s}")) |
|
| 670 | + | }) |
|
| 671 | + | .collect(); |
|
| 672 | + | let block = Block::default() |
|
| 673 | + | .title(" Servers — Enter: use · a: add · d: delete · Esc: close ") |
|
| 674 | + | .borders(Borders::ALL) |
|
| 675 | + | .border_style(Style::default().fg(Color::Cyan)); |
|
| 676 | + | let list = List::new(items) |
|
| 677 | + | .block(block) |
|
| 678 | + | .highlight_style(Style::default().bg(Color::DarkGray)) |
|
| 679 | + | .highlight_symbol(">"); |
|
| 680 | + | let mut state = ListState::default().with_selected(Some(app.env_sel)); |
|
| 681 | + | f.render_stateful_widget(list, popup, &mut state); |
|
| 682 | + | } |
|
| 683 | + | ||
| 684 | + | fn draw_auth(f: &mut Frame, app: &App, area: Rect) { |
|
| 685 | + | let popup = centered(area, 70, 45); |
|
| 686 | + | f.render_widget(Clear, popup); |
|
| 687 | + | let block = Block::default() |
|
| 688 | + | .title(" OAuth client credentials — j/k: field · i/Enter: edit · space: toggle style · Esc: save & close ") |
|
| 689 | + | .borders(Borders::ALL) |
|
| 690 | + | .border_style(Style::default().fg(Color::Cyan)); |
|
| 691 | + | let inner = block.inner(popup); |
|
| 692 | + | f.render_widget(block, popup); |
|
| 693 | + | ||
| 694 | + | let mut lines = Vec::new(); |
|
| 695 | + | for (i, label) in App::AUTH_FIELDS.iter().enumerate() { |
|
| 696 | + | let value = if i == 2 && !app.auth_form.client_secret.is_empty() { |
|
| 697 | + | "••••••••".to_string() |
|
| 698 | + | } else if i == 4 { |
|
| 699 | + | match app.auth_form.auth_style { |
|
| 700 | + | crate::model::AuthStyle::Basic => "[basic] post".to_string(), |
|
| 701 | + | crate::model::AuthStyle::Post => " basic [post]".to_string(), |
|
| 702 | + | } |
|
| 703 | + | } else { |
|
| 704 | + | app.auth_field_value(i) |
|
| 705 | + | }; |
|
| 706 | + | let style = if i == app.auth_field { |
|
| 707 | + | Style::default() |
|
| 708 | + | .bg(Color::DarkGray) |
|
| 709 | + | .add_modifier(Modifier::BOLD) |
|
| 710 | + | } else { |
|
| 711 | + | Style::default() |
|
| 712 | + | }; |
|
| 713 | + | lines.push( |
|
| 714 | + | Line::from(vec![ |
|
| 715 | + | Span::styled(format!(" {label:<28}"), Style::default().fg(Color::Gray)), |
|
| 716 | + | Span::raw(value), |
|
| 717 | + | ]) |
|
| 718 | + | .style(style), |
|
| 719 | + | ); |
|
| 720 | + | } |
|
| 721 | + | f.render_widget(Paragraph::new(lines), inner); |
|
| 722 | + | } |
| 1 | + | //! End-to-end: TUI action → async send task → response/token state update. |
|
| 2 | + | ||
| 3 | + | use std::path::PathBuf; |
|
| 4 | + | ||
| 5 | + | use cielago::app::App; |
|
| 6 | + | use cielago::model::{Collection, KeyValueRow, Method, OAuthConfig, SavedRequest}; |
|
| 7 | + | use cielago::store::AppConfig; |
|
| 8 | + | use wiremock::matchers::{method, path}; |
|
| 9 | + | use wiremock::{Mock, MockServer, ResponseTemplate}; |
|
| 10 | + | ||
| 11 | + | fn app_with(base_url: String) -> App { |
|
| 12 | + | let mut c = Collection::new("test"); |
|
| 13 | + | c.servers = vec![base_url]; |
|
| 14 | + | let mut req = SavedRequest::blank("get thing"); |
|
| 15 | + | req.method = Method::Get; |
|
| 16 | + | req.path = "/things/1".into(); |
|
| 17 | + | req.headers |
|
| 18 | + | .push(KeyValueRow::new("X-Request-Id", "{{uuid}}", true)); |
|
| 19 | + | c.requests = vec![req]; |
|
| 20 | + | App::new( |
|
| 21 | + | c, |
|
| 22 | + | PathBuf::from("/tmp/cielago-test.json"), |
|
| 23 | + | AppConfig::default(), |
|
| 24 | + | ) |
|
| 25 | + | } |
|
| 26 | + | ||
| 27 | + | #[tokio::test] |
|
| 28 | + | async fn send_from_app_updates_response_pane() { |
|
| 29 | + | let server = MockServer::start().await; |
|
| 30 | + | Mock::given(method("GET")) |
|
| 31 | + | .and(path("/things/1")) |
|
| 32 | + | .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1}))) |
|
| 33 | + | .mount(&server) |
|
| 34 | + | .await; |
|
| 35 | + | ||
| 36 | + | let mut app = app_with(server.uri()); |
|
| 37 | + | assert!(app.response.is_none()); |
|
| 38 | + | ||
| 39 | + | app.send_selected(); |
|
| 40 | + | assert!(app.sending); |
|
| 41 | + | ||
| 42 | + | let outcome = app.rx.recv().await.expect("send outcome"); |
|
| 43 | + | app.handle_outcome(outcome); |
|
| 44 | + | ||
| 45 | + | assert!(!app.sending); |
|
| 46 | + | let resp = app.response.as_ref().expect("response recorded"); |
|
| 47 | + | assert_eq!(resp.status, 200); |
|
| 48 | + | assert!(resp.body.contains("\"id\": 1")); |
|
| 49 | + | assert!(app.status.contains("200")); |
|
| 50 | + | ||
| 51 | + | // {{uuid}} was substituted in the outgoing header. |
|
| 52 | + | let received = server.received_requests().await.unwrap(); |
|
| 53 | + | let id = received[0] |
|
| 54 | + | .headers |
|
| 55 | + | .get("x-request-id") |
|
| 56 | + | .unwrap() |
|
| 57 | + | .to_str() |
|
| 58 | + | .unwrap(); |
|
| 59 | + | assert!(uuid::Uuid::parse_str(id).is_ok(), "got {id}"); |
|
| 60 | + | } |
|
| 61 | + | ||
| 62 | + | #[tokio::test] |
|
| 63 | + | async fn send_with_oauth_fetches_and_caches_token() { |
|
| 64 | + | let server = MockServer::start().await; |
|
| 65 | + | Mock::given(method("POST")) |
|
| 66 | + | .and(path("/token")) |
|
| 67 | + | .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ |
|
| 68 | + | "access_token": "cached-tok", |
|
| 69 | + | "expires_in": 3600 |
|
| 70 | + | }))) |
|
| 71 | + | .expect(1) // fetched once, then cached |
|
| 72 | + | .mount(&server) |
|
| 73 | + | .await; |
|
| 74 | + | Mock::given(method("GET")) |
|
| 75 | + | .and(path("/things/1")) |
|
| 76 | + | .respond_with(ResponseTemplate::new(200)) |
|
| 77 | + | .expect(2) |
|
| 78 | + | .mount(&server) |
|
| 79 | + | .await; |
|
| 80 | + | ||
| 81 | + | let mut app = app_with(server.uri()); |
|
| 82 | + | app.collection.auth = Some(OAuthConfig { |
|
| 83 | + | token_url: format!("{}/token", server.uri()), |
|
| 84 | + | client_id: "id".into(), |
|
| 85 | + | client_secret: "secret".into(), |
|
| 86 | + | scopes: vec![], |
|
| 87 | + | auth_style: cielago::model::AuthStyle::Basic, |
|
| 88 | + | }); |
|
| 89 | + | ||
| 90 | + | // First send: fetches a token. |
|
| 91 | + | app.send_selected(); |
|
| 92 | + | let outcome = app.rx.recv().await.unwrap(); |
|
| 93 | + | app.handle_outcome(outcome); |
|
| 94 | + | assert_eq!(app.response.as_ref().unwrap().status, 200); |
|
| 95 | + | assert!(app.token.is_some()); |
|
| 96 | + | ||
| 97 | + | // Second send: reuses the cached token (token endpoint expect(1)). |
|
| 98 | + | app.send_selected(); |
|
| 99 | + | let outcome = app.rx.recv().await.unwrap(); |
|
| 100 | + | app.handle_outcome(outcome); |
|
| 101 | + | assert_eq!(app.response.as_ref().unwrap().status, 200); |
|
| 102 | + | ||
| 103 | + | // Both API calls carried the bearer token. |
|
| 104 | + | let received = server.received_requests().await.unwrap(); |
|
| 105 | + | let api_calls: Vec<_> = received |
|
| 106 | + | .iter() |
|
| 107 | + | .filter(|r| r.url.path() == "/things/1") |
|
| 108 | + | .collect(); |
|
| 109 | + | assert_eq!(api_calls.len(), 2); |
|
| 110 | + | for r in api_calls { |
|
| 111 | + | assert_eq!( |
|
| 112 | + | r.headers.get("authorization").unwrap().to_str().unwrap(), |
|
| 113 | + | "Bearer cached-tok" |
|
| 114 | + | ); |
|
| 115 | + | } |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | #[tokio::test] |
|
| 119 | + | async fn send_without_server_shows_status_error() { |
|
| 120 | + | let mut app = app_with("".into()); |
|
| 121 | + | app.collection.servers.clear(); |
|
| 122 | + | app.send_selected(); |
|
| 123 | + | assert!(!app.sending); |
|
| 124 | + | assert!(app.status.contains("No server configured")); |
|
| 125 | + | } |
| 1 | + | { |
|
| 2 | + | "openapi": "3.1.0", |
|
| 3 | + | "info": { "title": "Things", "version": "0.1" }, |
|
| 4 | + | "servers": [{ "url": "https://things.example.com" }], |
|
| 5 | + | "paths": { |
|
| 6 | + | "/things": { |
|
| 7 | + | "post": { |
|
| 8 | + | "operationId": "makeThing", |
|
| 9 | + | "requestBody": { |
|
| 10 | + | "content": { |
|
| 11 | + | "application/json": { |
|
| 12 | + | "schema": { |
|
| 13 | + | "type": "object", |
|
| 14 | + | "properties": { |
|
| 15 | + | "label": { "type": ["string", "null"], "examples": ["widget"] }, |
|
| 16 | + | "count": { "type": "integer" } |
|
| 17 | + | } |
|
| 18 | + | } |
|
| 19 | + | } |
|
| 20 | + | } |
|
| 21 | + | }, |
|
| 22 | + | "responses": { "201": { "description": "created" } } |
|
| 23 | + | } |
|
| 24 | + | } |
|
| 25 | + | } |
|
| 26 | + | } |
| 1 | + | openapi: 3.0.3 |
|
| 2 | + | info: |
|
| 3 | + | title: Pet Store |
|
| 4 | + | version: 1.0.0 |
|
| 5 | + | servers: |
|
| 6 | + | - url: https://api.pets.example.com/v1 |
|
| 7 | + | - url: https://staging.pets.example.com/v1 |
|
| 8 | + | components: |
|
| 9 | + | securitySchemes: |
|
| 10 | + | petstore_auth: |
|
| 11 | + | type: oauth2 |
|
| 12 | + | flows: |
|
| 13 | + | clientCredentials: |
|
| 14 | + | tokenUrl: https://auth.pets.example.com/oauth/token |
|
| 15 | + | scopes: |
|
| 16 | + | read:pets: Read pets |
|
| 17 | + | write:pets: Modify pets |
|
| 18 | + | schemas: |
|
| 19 | + | Pet: |
|
| 20 | + | type: object |
|
| 21 | + | required: [name] |
|
| 22 | + | properties: |
|
| 23 | + | id: |
|
| 24 | + | type: integer |
|
| 25 | + | format: int64 |
|
| 26 | + | name: |
|
| 27 | + | type: string |
|
| 28 | + | tag: |
|
| 29 | + | type: string |
|
| 30 | + | default: friendly |
|
| 31 | + | parameters: |
|
| 32 | + | PetId: |
|
| 33 | + | name: petId |
|
| 34 | + | in: path |
|
| 35 | + | required: true |
|
| 36 | + | example: 123 |
|
| 37 | + | schema: |
|
| 38 | + | type: integer |
|
| 39 | + | format: int64 |
|
| 40 | + | paths: |
|
| 41 | + | /pets: |
|
| 42 | + | get: |
|
| 43 | + | operationId: listPets |
|
| 44 | + | tags: [pets] |
|
| 45 | + | description: Lists pets, newest first. |
|
| 46 | + | parameters: |
|
| 47 | + | - name: limit |
|
| 48 | + | in: query |
|
| 49 | + | required: false |
|
| 50 | + | description: How many pets to return. |
|
| 51 | + | schema: |
|
| 52 | + | type: integer |
|
| 53 | + | default: 20 |
|
| 54 | + | - name: status |
|
| 55 | + | in: query |
|
| 56 | + | required: false |
|
| 57 | + | schema: |
|
| 58 | + | type: string |
|
| 59 | + | enum: [available, pending, sold] |
|
| 60 | + | default: available |
|
| 61 | + | - name: filter |
|
| 62 | + | in: query |
|
| 63 | + | required: false |
|
| 64 | + | schema: |
|
| 65 | + | type: string |
|
| 66 | + | - name: X-Tenant-Id |
|
| 67 | + | in: header |
|
| 68 | + | required: true |
|
| 69 | + | schema: |
|
| 70 | + | type: string |
|
| 71 | + | example: acme |
|
| 72 | + | responses: |
|
| 73 | + | '200': |
|
| 74 | + | description: ok |
|
| 75 | + | post: |
|
| 76 | + | operationId: createPet |
|
| 77 | + | tags: [pets] |
|
| 78 | + | requestBody: |
|
| 79 | + | content: |
|
| 80 | + | application/json: |
|
| 81 | + | schema: |
|
| 82 | + | $ref: '#/components/schemas/Pet' |
|
| 83 | + | example: |
|
| 84 | + | id: 1 |
|
| 85 | + | name: Fido |
|
| 86 | + | responses: |
|
| 87 | + | '201': |
|
| 88 | + | description: created |
|
| 89 | + | /pets/{petId}: |
|
| 90 | + | get: |
|
| 91 | + | operationId: getPet |
|
| 92 | + | tags: [pets] |
|
| 93 | + | parameters: |
|
| 94 | + | - $ref: '#/components/parameters/PetId' |
|
| 95 | + | responses: |
|
| 96 | + | '200': |
|
| 97 | + | description: ok |
|
| 98 | + | /store/orders: |
|
| 99 | + | post: |
|
| 100 | + | summary: Place order |
|
| 101 | + | tags: [store] |
|
| 102 | + | requestBody: |
|
| 103 | + | content: |
|
| 104 | + | application/json: |
|
| 105 | + | schema: |
|
| 106 | + | type: object |
|
| 107 | + | properties: |
|
| 108 | + | petId: |
|
| 109 | + | type: integer |
|
| 110 | + | requestId: |
|
| 111 | + | type: string |
|
| 112 | + | format: uuid |
|
| 113 | + | responses: |
|
| 114 | + | '200': |
|
| 115 | + | description: ok |
| 1 | + | use std::collections::HashMap; |
|
| 2 | + | ||
| 3 | + | use cielago::http::{fetch_token, send_request}; |
|
| 4 | + | use cielago::model::{AuthStyle, KeyValueRow, Method, OAuthConfig, SavedRequest}; |
|
| 5 | + | use wiremock::matchers::{method, path, query_param}; |
|
| 6 | + | use wiremock::{Mock, MockServer, ResponseTemplate}; |
|
| 7 | + | ||
| 8 | + | #[tokio::test] |
|
| 9 | + | async fn sends_request_with_params_and_uuid_header() { |
|
| 10 | + | let server = MockServer::start().await; |
|
| 11 | + | Mock::given(method("GET")) |
|
| 12 | + | .and(path("/pets/123")) |
|
| 13 | + | .and(query_param("limit", "10")) |
|
| 14 | + | .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true}))) |
|
| 15 | + | .mount(&server) |
|
| 16 | + | .await; |
|
| 17 | + | ||
| 18 | + | let mut req = SavedRequest::blank("get pet"); |
|
| 19 | + | req.method = Method::Get; |
|
| 20 | + | req.path = "/pets/{petId}".into(); |
|
| 21 | + | req.path_params.push(KeyValueRow::new("petId", "123", true)); |
|
| 22 | + | req.query.push(KeyValueRow::new("limit", "10", true)); |
|
| 23 | + | req.query.push(KeyValueRow::new("disabled", "x", false)); |
|
| 24 | + | req.headers |
|
| 25 | + | .push(KeyValueRow::new("X-Request-Id", "{{uuid}}", true)); |
|
| 26 | + | req.headers |
|
| 27 | + | .push(KeyValueRow::new("X-Tenant", "{{tenant}}", true)); |
|
| 28 | + | ||
| 29 | + | let vars = HashMap::from([("tenant".to_string(), "acme".to_string())]); |
|
| 30 | + | let client = reqwest::Client::new(); |
|
| 31 | + | let resp = send_request(&client, &server.uri(), &req, &vars, None) |
|
| 32 | + | .await |
|
| 33 | + | .unwrap(); |
|
| 34 | + | ||
| 35 | + | assert_eq!(resp.status, 200); |
|
| 36 | + | assert!(resp.body.contains("\"ok\": true")); |
|
| 37 | + | ||
| 38 | + | // Inspect the recorded request. |
|
| 39 | + | let received = server.received_requests().await.unwrap(); |
|
| 40 | + | assert_eq!(received.len(), 1); |
|
| 41 | + | let r = &received[0]; |
|
| 42 | + | // {{uuid}} became a real UUID. |
|
| 43 | + | let id = r.headers.get("x-request-id").unwrap().to_str().unwrap(); |
|
| 44 | + | assert!(uuid::Uuid::parse_str(id).is_ok(), "got {id}"); |
|
| 45 | + | // {{tenant}} became acme. |
|
| 46 | + | assert_eq!(r.headers.get("x-tenant").unwrap().to_str().unwrap(), "acme"); |
|
| 47 | + | // disabled query param was not sent. |
|
| 48 | + | assert!(!r.url.query().unwrap_or_default().contains("disabled")); |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | #[tokio::test] |
|
| 52 | + | async fn oauth_client_credentials_basic_flow() { |
|
| 53 | + | let server = MockServer::start().await; |
|
| 54 | + | Mock::given(method("POST")) |
|
| 55 | + | .and(path("/token")) |
|
| 56 | + | .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ |
|
| 57 | + | "access_token": "tok-abc", |
|
| 58 | + | "expires_in": 3600 |
|
| 59 | + | }))) |
|
| 60 | + | .mount(&server) |
|
| 61 | + | .await; |
|
| 62 | + | ||
| 63 | + | let cfg = OAuthConfig { |
|
| 64 | + | token_url: format!("{}/token", server.uri()), |
|
| 65 | + | client_id: "my-id".into(), |
|
| 66 | + | client_secret: "my-secret".into(), |
|
| 67 | + | scopes: vec!["read".into(), "write".into()], |
|
| 68 | + | auth_style: AuthStyle::Basic, |
|
| 69 | + | }; |
|
| 70 | + | let client = reqwest::Client::new(); |
|
| 71 | + | let token = fetch_token(&client, &cfg).await.unwrap(); |
|
| 72 | + | assert_eq!(token.access_token, "tok-abc"); |
|
| 73 | + | assert!(cielago::http::token_valid(&token)); |
|
| 74 | + | ||
| 75 | + | let received = server.received_requests().await.unwrap(); |
|
| 76 | + | assert_eq!(received.len(), 1); |
|
| 77 | + | let r = &received[0]; |
|
| 78 | + | let auth = r |
|
| 79 | + | .headers |
|
| 80 | + | .get("authorization") |
|
| 81 | + | .unwrap() |
|
| 82 | + | .to_str() |
|
| 83 | + | .unwrap() |
|
| 84 | + | .to_string(); |
|
| 85 | + | assert!(auth.starts_with("Basic "), "got {auth}"); |
|
| 86 | + | let body = String::from_utf8_lossy(&r.body).into_owned(); |
|
| 87 | + | assert!( |
|
| 88 | + | body.contains("grant_type=client_credentials"), |
|
| 89 | + | "body: {body}" |
|
| 90 | + | ); |
|
| 91 | + | assert!(body.contains("scope="), "body: {body}"); |
|
| 92 | + | } |
|
| 93 | + | ||
| 94 | + | #[tokio::test] |
|
| 95 | + | async fn oauth_post_style_sends_creds_in_body() { |
|
| 96 | + | let server = MockServer::start().await; |
|
| 97 | + | Mock::given(method("POST")) |
|
| 98 | + | .and(path("/token")) |
|
| 99 | + | .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ |
|
| 100 | + | "access_token": "tok" |
|
| 101 | + | }))) |
|
| 102 | + | .mount(&server) |
|
| 103 | + | .await; |
|
| 104 | + | ||
| 105 | + | let cfg = OAuthConfig { |
|
| 106 | + | token_url: format!("{}/token", server.uri()), |
|
| 107 | + | client_id: "id2".into(), |
|
| 108 | + | client_secret: "secret2".into(), |
|
| 109 | + | scopes: vec![], |
|
| 110 | + | auth_style: AuthStyle::Post, |
|
| 111 | + | }; |
|
| 112 | + | let client = reqwest::Client::new(); |
|
| 113 | + | fetch_token(&client, &cfg).await.unwrap(); |
|
| 114 | + | ||
| 115 | + | let received = server.received_requests().await.unwrap(); |
|
| 116 | + | let r = &received[0]; |
|
| 117 | + | let body = String::from_utf8_lossy(&r.body).into_owned(); |
|
| 118 | + | assert!(body.contains("client_id=id2"), "body: {body}"); |
|
| 119 | + | assert!(body.contains("client_secret=secret2"), "body: {body}"); |
|
| 120 | + | assert!(r.headers.get("authorization").is_none()); |
|
| 121 | + | } |
|
| 122 | + | ||
| 123 | + | #[tokio::test] |
|
| 124 | + | async fn bearer_token_injected_unless_header_present() { |
|
| 125 | + | let server = MockServer::start().await; |
|
| 126 | + | Mock::given(method("GET")) |
|
| 127 | + | .and(path("/x")) |
|
| 128 | + | .respond_with(ResponseTemplate::new(200)) |
|
| 129 | + | .mount(&server) |
|
| 130 | + | .await; |
|
| 131 | + | ||
| 132 | + | let client = reqwest::Client::new(); |
|
| 133 | + | let req = SavedRequest::blank("x"); |
|
| 134 | + | send_request(&client, &server.uri(), &req, &HashMap::new(), Some("tok-1")) |
|
| 135 | + | .await |
|
| 136 | + | .unwrap(); |
|
| 137 | + | let received = server.received_requests().await.unwrap(); |
|
| 138 | + | assert_eq!( |
|
| 139 | + | received[0] |
|
| 140 | + | .headers |
|
| 141 | + | .get("authorization") |
|
| 142 | + | .unwrap() |
|
| 143 | + | .to_str() |
|
| 144 | + | .unwrap(), |
|
| 145 | + | "Bearer tok-1" |
|
| 146 | + | ); |
|
| 147 | + | ||
| 148 | + | // Explicit Authorization header wins over the injected bearer. |
|
| 149 | + | let mut req2 = SavedRequest::blank("x2"); |
|
| 150 | + | req2.headers |
|
| 151 | + | .push(KeyValueRow::new("Authorization", "Bearer manual", true)); |
|
| 152 | + | send_request( |
|
| 153 | + | &client, |
|
| 154 | + | &server.uri(), |
|
| 155 | + | &req2, |
|
| 156 | + | &HashMap::new(), |
|
| 157 | + | Some("tok-2"), |
|
| 158 | + | ) |
|
| 159 | + | .await |
|
| 160 | + | .unwrap(); |
|
| 161 | + | let received = server.received_requests().await.unwrap(); |
|
| 162 | + | assert_eq!( |
|
| 163 | + | received[1] |
|
| 164 | + | .headers |
|
| 165 | + | .get("authorization") |
|
| 166 | + | .unwrap() |
|
| 167 | + | .to_str() |
|
| 168 | + | .unwrap(), |
|
| 169 | + | "Bearer manual" |
|
| 170 | + | ); |
|
| 171 | + | } |
|
| 172 | + | ||
| 173 | + | /// The compose/decompose contract: what `split_url_input` pulls apart, |
|
| 174 | + | /// `build_url` must put back together. |
|
| 175 | + | #[test] |
|
| 176 | + | fn pasted_url_round_trips_through_build_url() { |
|
| 177 | + | let pasted = "https://api.example.com/orgs/{orgId}/pets?limit=10&sort=name"; |
|
| 178 | + | let parts = cielago::http::split_url_input(pasted); |
|
| 179 | + | ||
| 180 | + | let mut req = SavedRequest::blank("round trip"); |
|
| 181 | + | req.path = parts.path; |
|
| 182 | + | req.query = parts.query.unwrap(); |
|
| 183 | + | req.sync_path_params(); |
|
| 184 | + | // Path params come out of the paste blank; fill the one placeholder. |
|
| 185 | + | assert_eq!(req.path_params.len(), 1); |
|
| 186 | + | req.path_params[0].value = "acme".into(); |
|
| 187 | + | ||
| 188 | + | let base = parts.origin.unwrap(); |
|
| 189 | + | let url = cielago::http::client::build_url(&base, &req, &HashMap::new()); |
|
| 190 | + | assert_eq!(url, "https://api.example.com/orgs/acme/pets"); |
|
| 191 | + | // The query is applied by reqwest rather than `build_url`, so check the rows. |
|
| 192 | + | let query: Vec<(&str, &str)> = req |
|
| 193 | + | .query |
|
| 194 | + | .iter() |
|
| 195 | + | .map(|r| (r.key.as_str(), r.value.as_str())) |
|
| 196 | + | .collect(); |
|
| 197 | + | assert_eq!(query, vec![("limit", "10"), ("sort", "name")]); |
|
| 198 | + | } |
|
| 199 | + | ||
| 200 | + | /// End to end: a pasted URL becomes a request that actually reaches the server |
|
| 201 | + | /// it named, with the query it carried. |
|
| 202 | + | #[tokio::test] |
|
| 203 | + | async fn a_pasted_url_sends_to_the_pasted_server() { |
|
| 204 | + | let server = MockServer::start().await; |
|
| 205 | + | Mock::given(method("GET")) |
|
| 206 | + | .and(path("/v1/pets")) |
|
| 207 | + | .and(query_param("limit", "5")) |
|
| 208 | + | .respond_with(ResponseTemplate::new(200).set_body_string("ok")) |
|
| 209 | + | .mount(&server) |
|
| 210 | + | .await; |
|
| 211 | + | ||
| 212 | + | let parts = cielago::http::split_url_input(&format!("{}/v1/pets?limit=5", server.uri())); |
|
| 213 | + | let mut req = SavedRequest::blank("pasted"); |
|
| 214 | + | req.path = parts.path; |
|
| 215 | + | req.query = parts.query.unwrap(); |
|
| 216 | + | req.sync_path_params(); |
|
| 217 | + | ||
| 218 | + | let client = reqwest::Client::new(); |
|
| 219 | + | let resp = send_request(&client, &parts.origin.unwrap(), &req, &HashMap::new(), None) |
|
| 220 | + | .await |
|
| 221 | + | .unwrap(); |
|
| 222 | + | assert_eq!(resp.status, 200); |
|
| 223 | + | } |
| 1 | + | use cielago::model::{Method, variables_map}; |
|
| 2 | + | use cielago::openapi::{import_spec, load_spec}; |
|
| 3 | + | use cielago::store; |
|
| 4 | + | ||
| 5 | + | fn fixture_path(name: &str) -> String { |
|
| 6 | + | format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR")) |
|
| 7 | + | } |
|
| 8 | + | ||
| 9 | + | async fn import_fixture(name: &str, coll_name: &str) -> cielago::model::Collection { |
|
| 10 | + | let doc = load_spec(&fixture_path(name)).await.unwrap(); |
|
| 11 | + | import_spec(&doc, coll_name, Some(fixture_path(name))) |
|
| 12 | + | } |
|
| 13 | + | ||
| 14 | + | #[tokio::test] |
|
| 15 | + | async fn imports_petstore_30() { |
|
| 16 | + | let c = import_fixture("petstore30.yaml", "pets").await; |
|
| 17 | + | ||
| 18 | + | assert_eq!( |
|
| 19 | + | c.servers, |
|
| 20 | + | vec![ |
|
| 21 | + | "https://api.pets.example.com/v1".to_string(), |
|
| 22 | + | "https://staging.pets.example.com/v1".to_string() |
|
| 23 | + | ] |
|
| 24 | + | ); |
|
| 25 | + | ||
| 26 | + | // OAuth clientCredentials flow is detected and prefilled. |
|
| 27 | + | let auth = c.auth.as_ref().expect("auth should be prefilled"); |
|
| 28 | + | assert_eq!(auth.token_url, "https://auth.pets.example.com/oauth/token"); |
|
| 29 | + | assert_eq!(auth.scopes, vec!["read:pets", "write:pets"]); |
|
| 30 | + | assert!(auth.client_id.is_empty()); |
|
| 31 | + | ||
| 32 | + | assert_eq!(c.requests.len(), 4); |
|
| 33 | + | ||
| 34 | + | let list = c.requests.iter().find(|r| r.name == "listPets").unwrap(); |
|
| 35 | + | assert_eq!(list.method, Method::Get); |
|
| 36 | + | assert_eq!(list.path, "/pets"); |
|
| 37 | + | assert_eq!(list.tags, vec!["pets"]); |
|
| 38 | + | // Optional query params are populated but disabled; defaults prefilled. |
|
| 39 | + | let limit = list.query.iter().find(|q| q.key == "limit").unwrap(); |
|
| 40 | + | assert!(!limit.enabled); |
|
| 41 | + | assert_eq!(limit.value, "20"); |
|
| 42 | + | let filter = list.query.iter().find(|q| q.key == "filter").unwrap(); |
|
| 43 | + | assert!(!filter.enabled); |
|
| 44 | + | assert_eq!(filter.value, ""); |
|
| 45 | + | // Required header param is enabled with its example. |
|
| 46 | + | let tenant = list |
|
| 47 | + | .headers |
|
| 48 | + | .iter() |
|
| 49 | + | .find(|h| h.key == "X-Tenant-Id") |
|
| 50 | + | .unwrap(); |
|
| 51 | + | assert!(tenant.enabled); |
|
| 52 | + | assert_eq!(tenant.value, "acme"); |
|
| 53 | + | ||
| 54 | + | // Authored media-type example wins for the body. |
|
| 55 | + | let create = c.requests.iter().find(|r| r.name == "createPet").unwrap(); |
|
| 56 | + | assert_eq!(create.method, Method::Post); |
|
| 57 | + | let body = create.body.as_deref().unwrap(); |
|
| 58 | + | assert!(body.contains("\"name\": \"Fido\""), "body was: {body}"); |
|
| 59 | + | ||
| 60 | + | // $ref'd path parameter is resolved and its example prefilled. |
|
| 61 | + | let get_pet = c.requests.iter().find(|r| r.name == "getPet").unwrap(); |
|
| 62 | + | assert_eq!(get_pet.path, "/pets/{petId}"); |
|
| 63 | + | let pet_id = get_pet |
|
| 64 | + | .path_params |
|
| 65 | + | .iter() |
|
| 66 | + | .find(|p| p.key == "petId") |
|
| 67 | + | .unwrap(); |
|
| 68 | + | assert!(pet_id.enabled); |
|
| 69 | + | assert_eq!(pet_id.value, "123"); |
|
| 70 | + | ||
| 71 | + | // Summary used as name when operationId is absent; body generated from |
|
| 72 | + | // schema, uuid format becomes the {{uuid}} variable. |
|
| 73 | + | let order = c.requests.iter().find(|r| r.name == "Place order").unwrap(); |
|
| 74 | + | assert_eq!(order.tags, vec!["store"]); |
|
| 75 | + | let body = order.body.as_deref().unwrap(); |
|
| 76 | + | assert!(body.contains("\"petId\": 1"), "body was: {body}"); |
|
| 77 | + | assert!( |
|
| 78 | + | body.contains("\"requestId\": \"{{uuid}}\""), |
|
| 79 | + | "body was: {body}" |
|
| 80 | + | ); |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | #[tokio::test] |
|
| 84 | + | async fn import_captures_docs_for_the_docs_tab() { |
|
| 85 | + | let c = import_fixture("petstore30.yaml", "pets docs").await; |
|
| 86 | + | ||
| 87 | + | let list = c.requests.iter().find(|r| r.name == "listPets").unwrap(); |
|
| 88 | + | assert_eq!( |
|
| 89 | + | list.description.as_deref(), |
|
| 90 | + | Some("Lists pets, newest first.") |
|
| 91 | + | ); |
|
| 92 | + | ||
| 93 | + | let limit = list.docs.iter().find(|d| d.name == "limit").unwrap(); |
|
| 94 | + | assert_eq!(limit.location, "query"); |
|
| 95 | + | assert_eq!(limit.ty, "integer"); |
|
| 96 | + | assert!(!limit.required); |
|
| 97 | + | assert_eq!(limit.default.as_deref(), Some("20")); |
|
| 98 | + | assert_eq!( |
|
| 99 | + | limit.description.as_deref(), |
|
| 100 | + | Some("How many pets to return.") |
|
| 101 | + | ); |
|
| 102 | + | ||
| 103 | + | // The options a field accepts are what the tab is for. |
|
| 104 | + | let status = list.docs.iter().find(|d| d.name == "status").unwrap(); |
|
| 105 | + | assert_eq!(status.options, ["available", "pending", "sold"]); |
|
| 106 | + | ||
| 107 | + | let tenant = list.docs.iter().find(|d| d.name == "X-Tenant-Id").unwrap(); |
|
| 108 | + | assert_eq!(tenant.location, "header"); |
|
| 109 | + | assert!(tenant.required); |
|
| 110 | + | ||
| 111 | + | // $ref'd path parameter, documented through the reference. |
|
| 112 | + | let get_pet = c.requests.iter().find(|r| r.name == "getPet").unwrap(); |
|
| 113 | + | let pet_id = get_pet.docs.iter().find(|d| d.name == "petId").unwrap(); |
|
| 114 | + | assert_eq!(pet_id.location, "path"); |
|
| 115 | + | assert_eq!(pet_id.ty, "integer(int64)"); |
|
| 116 | + | assert!(pet_id.required); |
|
| 117 | + | ||
| 118 | + | // Body fields come from the request body schema, `required` included. |
|
| 119 | + | let create = c.requests.iter().find(|r| r.name == "createPet").unwrap(); |
|
| 120 | + | let body: Vec<(&str, &str, bool)> = create |
|
| 121 | + | .docs |
|
| 122 | + | .iter() |
|
| 123 | + | .filter(|d| d.location == "body") |
|
| 124 | + | .map(|d| (d.name.as_str(), d.ty.as_str(), d.required)) |
|
| 125 | + | .collect(); |
|
| 126 | + | assert_eq!( |
|
| 127 | + | body, |
|
| 128 | + | [ |
|
| 129 | + | ("id", "integer(int64)", false), |
|
| 130 | + | ("name", "string", true), |
|
| 131 | + | ("tag", "string", false) |
|
| 132 | + | ] |
|
| 133 | + | ); |
|
| 134 | + | assert_eq!( |
|
| 135 | + | create |
|
| 136 | + | .docs |
|
| 137 | + | .iter() |
|
| 138 | + | .find(|d| d.name == "tag") |
|
| 139 | + | .unwrap() |
|
| 140 | + | .default |
|
| 141 | + | .as_deref(), |
|
| 142 | + | Some("friendly") |
|
| 143 | + | ); |
|
| 144 | + | ||
| 145 | + | // Hand-made requests simply have none. |
|
| 146 | + | assert!(cielago::model::SavedRequest::blank("x").docs.is_empty()); |
|
| 147 | + | } |
|
| 148 | + | ||
| 149 | + | #[tokio::test] |
|
| 150 | + | async fn imports_31_json() { |
|
| 151 | + | let c = import_fixture("api31.json", "things").await; |
|
| 152 | + | assert_eq!(c.servers, vec!["https://things.example.com".to_string()]); |
|
| 153 | + | assert_eq!(c.requests.len(), 1); |
|
| 154 | + | let make = &c.requests[0]; |
|
| 155 | + | assert_eq!(make.name, "makeThing"); |
|
| 156 | + | let body = make.body.as_deref().unwrap(); |
|
| 157 | + | assert!(body.contains("\"label\": \"widget\""), "body was: {body}"); |
|
| 158 | + | assert!(body.contains("\"count\": 1"), "body was: {body}"); |
|
| 159 | + | } |
|
| 160 | + | ||
| 161 | + | #[test] |
|
| 162 | + | fn summary_wins_over_operation_id_for_naming() { |
|
| 163 | + | let doc = serde_json::json!({ |
|
| 164 | + | "paths": { |
|
| 165 | + | "/v1/customers/{id}": { |
|
| 166 | + | "get": { |
|
| 167 | + | "operationId": "CustomerControllerV1_retrieveCustomerById", |
|
| 168 | + | "summary": "Get customer", |
|
| 169 | + | "tags": ["customers"] |
|
| 170 | + | } |
|
| 171 | + | }, |
|
| 172 | + | "/v1/health": { "get": { "operationId": "healthCheck" } }, |
|
| 173 | + | "/v1/ping": { "get": {} } |
|
| 174 | + | } |
|
| 175 | + | }); |
|
| 176 | + | let c = import_spec(&doc, "svc", None); |
|
| 177 | + | ||
| 178 | + | let cust = c |
|
| 179 | + | .requests |
|
| 180 | + | .iter() |
|
| 181 | + | .find(|r| r.path.contains("customers")) |
|
| 182 | + | .unwrap(); |
|
| 183 | + | assert_eq!(cust.name, "Get customer"); |
|
| 184 | + | assert_eq!(cust.summary.as_deref(), Some("Get customer")); |
|
| 185 | + | assert_eq!( |
|
| 186 | + | cust.operation_id.as_deref(), |
|
| 187 | + | Some("CustomerControllerV1_retrieveCustomerById") |
|
| 188 | + | ); |
|
| 189 | + | ||
| 190 | + | // operationId is the fallback when there's no summary. |
|
| 191 | + | let health = c.requests.iter().find(|r| r.path == "/v1/health").unwrap(); |
|
| 192 | + | assert_eq!(health.name, "healthCheck"); |
|
| 193 | + | assert_eq!(health.summary, None); |
|
| 194 | + | ||
| 195 | + | // Neither present: METHOD + path. |
|
| 196 | + | let ping = c.requests.iter().find(|r| r.path == "/v1/ping").unwrap(); |
|
| 197 | + | assert_eq!(ping.name, "GET /v1/ping"); |
|
| 198 | + | } |
|
| 199 | + | ||
| 200 | + | #[test] |
|
| 201 | + | fn label_mode_selects_the_displayed_text() { |
|
| 202 | + | use cielago::model::LabelMode; |
|
| 203 | + | ||
| 204 | + | let doc = serde_json::json!({ |
|
| 205 | + | "paths": { |
|
| 206 | + | "/v1/customers/{id}": { |
|
| 207 | + | "get": { "operationId": "CustomerControllerV1_get", "summary": "Get customer" } |
|
| 208 | + | } |
|
| 209 | + | } |
|
| 210 | + | }); |
|
| 211 | + | let c = import_spec(&doc, "svc", None); |
|
| 212 | + | let r = &c.requests[0]; |
|
| 213 | + | assert_eq!(r.label(LabelMode::Name), "Get customer"); |
|
| 214 | + | assert_eq!(r.label(LabelMode::Summary), "Get customer"); |
|
| 215 | + | assert_eq!(r.label(LabelMode::Path), "/v1/customers/{id}"); |
|
| 216 | + | ||
| 217 | + | // No summary: Summary mode falls back to the name rather than blanking. |
|
| 218 | + | let mut bare = cielago::model::SavedRequest::blank("hand made"); |
|
| 219 | + | bare.path = "/thing".into(); |
|
| 220 | + | assert_eq!(bare.label(LabelMode::Summary), "hand made"); |
|
| 221 | + | assert_eq!(bare.label(LabelMode::Path), "/thing"); |
|
| 222 | + | } |
|
| 223 | + | ||
| 224 | + | #[tokio::test] |
|
| 225 | + | async fn collection_survives_save_load_roundtrip() { |
|
| 226 | + | let c = import_fixture("petstore30.yaml", "pets roundtrip").await; |
|
| 227 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 228 | + | let path = dir.path().join("coll.json"); |
|
| 229 | + | std::fs::write(&path, serde_json::to_string_pretty(&c).unwrap()).unwrap(); |
|
| 230 | + | let back = store::load_collection_path(&path.to_path_buf()).unwrap(); |
|
| 231 | + | assert_eq!(back.requests.len(), 4); |
|
| 232 | + | assert_eq!( |
|
| 233 | + | back.auth.unwrap().token_url, |
|
| 234 | + | "https://auth.pets.example.com/oauth/token" |
|
| 235 | + | ); |
|
| 236 | + | } |
|
| 237 | + | ||
| 238 | + | #[test] |
|
| 239 | + | fn variables_map_respects_enabled() { |
|
| 240 | + | let vars = vec![ |
|
| 241 | + | cielago::model::KeyValueRow::new("a", "1", true), |
|
| 242 | + | cielago::model::KeyValueRow::new("b", "2", false), |
|
| 243 | + | cielago::model::KeyValueRow::new("", "3", true), |
|
| 244 | + | ]; |
|
| 245 | + | let map = variables_map(&vars); |
|
| 246 | + | assert_eq!(map.get("a").unwrap(), "1"); |
|
| 247 | + | assert!(!map.contains_key("b")); |
|
| 248 | + | assert!(!map.contains_key("")); |
|
| 249 | + | } |
| 1 | + | //! State-machine tests for the TUI: synthetic key events drive `input::handle_key` |
|
| 2 | + | //! directly (no terminal needed). |
|
| 3 | + | ||
| 4 | + | use std::path::PathBuf; |
|
| 5 | + | ||
| 6 | + | use cielago::app::{App, EditTarget, EditorTab, Focus, Mode, Popup, SidebarRow}; |
|
| 7 | + | use cielago::input::handle_key; |
|
| 8 | + | use cielago::model::{Collection, KeyValueRow, LabelMode, Method, SavedRequest}; |
|
| 9 | + | use cielago::store::AppConfig; |
|
| 10 | + | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; |
|
| 11 | + | ||
| 12 | + | fn key(code: KeyCode) -> KeyEvent { |
|
| 13 | + | KeyEvent::new(code, KeyModifiers::NONE) |
|
| 14 | + | } |
|
| 15 | + | ||
| 16 | + | fn char_key(c: char) -> KeyEvent { |
|
| 17 | + | key(KeyCode::Char(c)) |
|
| 18 | + | } |
|
| 19 | + | ||
| 20 | + | fn type_str(app: &mut App, s: &str) { |
|
| 21 | + | for c in s.chars() { |
|
| 22 | + | handle_key(app, char_key(c)); |
|
| 23 | + | } |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | fn test_collection() -> Collection { |
|
| 27 | + | let mut c = Collection::new("test"); |
|
| 28 | + | c.servers = vec![ |
|
| 29 | + | "https://one.example.com".into(), |
|
| 30 | + | "https://two.example.com".into(), |
|
| 31 | + | ]; |
|
| 32 | + | ||
| 33 | + | let mut list = SavedRequest::blank("listPets"); |
|
| 34 | + | list.method = Method::Get; |
|
| 35 | + | list.path = "/pets".into(); |
|
| 36 | + | list.tags = vec!["pets".into()]; |
|
| 37 | + | list.query.push(KeyValueRow::new("limit", "20", false)); |
|
| 38 | + | list.query.push(KeyValueRow::new("filter", "", false)); |
|
| 39 | + | ||
| 40 | + | let mut create = SavedRequest::blank("createPet"); |
|
| 41 | + | create.method = Method::Post; |
|
| 42 | + | create.path = "/pets".into(); |
|
| 43 | + | create.tags = vec!["pets".into()]; |
|
| 44 | + | create.body = Some("{\n \"name\": \"Fido\"\n}".into()); |
|
| 45 | + | ||
| 46 | + | let mut order = SavedRequest::blank("placeOrder"); |
|
| 47 | + | order.method = Method::Post; |
|
| 48 | + | order.path = "/orders".into(); |
|
| 49 | + | order.tags = vec!["store".into()]; |
|
| 50 | + | ||
| 51 | + | c.requests = vec![list, create, order]; |
|
| 52 | + | c |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | fn test_app() -> App { |
|
| 56 | + | App::new( |
|
| 57 | + | test_collection(), |
|
| 58 | + | PathBuf::from("/tmp/cielago-test.json"), |
|
| 59 | + | AppConfig::default(), |
|
| 60 | + | ) |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | #[test] |
|
| 64 | + | fn startup_state() { |
|
| 65 | + | let app = test_app(); |
|
| 66 | + | assert_eq!(app.mode, Mode::Normal); |
|
| 67 | + | // sidebar: group header + 2 pets requests + group + 1 store request |
|
| 68 | + | assert_eq!(app.sidebar_rows.len(), 5); |
|
| 69 | + | // first request auto-selected, but focus starts on the sidebar |
|
| 70 | + | assert_eq!(app.selected, Some(0)); |
|
| 71 | + | assert_eq!(app.focus, Focus::Sidebar); |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | #[test] |
|
| 75 | + | fn startup_restores_the_last_open_request() { |
|
| 76 | + | let mut c = test_collection(); |
|
| 77 | + | c.last_request = Some(c.requests[2].id); |
|
| 78 | + | let app = App::new( |
|
| 79 | + | c, |
|
| 80 | + | PathBuf::from("/tmp/cielago-test.json"), |
|
| 81 | + | AppConfig::default(), |
|
| 82 | + | ); |
|
| 83 | + | assert_eq!(app.selected, Some(2)); |
|
| 84 | + | // the sidebar cursor lands on it too, not back at the top |
|
| 85 | + | assert_eq!(app.sidebar_rows[app.sidebar_sel], SidebarRow::Request(2)); |
|
| 86 | + | assert_eq!(app.focus, Focus::Sidebar); |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | #[test] |
|
| 90 | + | fn startup_expands_the_restored_request_group() { |
|
| 91 | + | let mut c = test_collection(); |
|
| 92 | + | c.groups_collapsed = true; |
|
| 93 | + | c.last_request = Some(c.requests[2].id); |
|
| 94 | + | let app = App::new( |
|
| 95 | + | c, |
|
| 96 | + | PathBuf::from("/tmp/cielago-test.json"), |
|
| 97 | + | AppConfig::default(), |
|
| 98 | + | ); |
|
| 99 | + | // "store" is expanded so the restored row is visible; "pets" stays collapsed |
|
| 100 | + | assert_eq!(app.selected, Some(2)); |
|
| 101 | + | assert_eq!(app.sidebar_rows[app.sidebar_sel], SidebarRow::Request(2)); |
|
| 102 | + | assert_eq!(app.sidebar_rows.len(), 3); |
|
| 103 | + | } |
|
| 104 | + | ||
| 105 | + | #[test] |
|
| 106 | + | fn startup_restores_the_focused_pane_and_tab() { |
|
| 107 | + | let mut c = test_collection(); |
|
| 108 | + | c.last_request = Some(c.requests[1].id); |
|
| 109 | + | c.last_focus = Some(Focus::Editor); |
|
| 110 | + | c.last_tab = Some(EditorTab::Body); |
|
| 111 | + | let app = App::new( |
|
| 112 | + | c, |
|
| 113 | + | PathBuf::from("/tmp/cielago-test.json"), |
|
| 114 | + | AppConfig::default(), |
|
| 115 | + | ); |
|
| 116 | + | assert_eq!(app.selected, Some(1)); |
|
| 117 | + | assert_eq!(app.focus, Focus::Editor); |
|
| 118 | + | assert_eq!(app.tab, EditorTab::Body); |
|
| 119 | + | } |
|
| 120 | + | ||
| 121 | + | #[test] |
|
| 122 | + | fn startup_skips_a_saved_response_pane_with_no_response() { |
|
| 123 | + | let mut c = test_collection(); |
|
| 124 | + | c.last_focus = Some(Focus::Response); |
|
| 125 | + | c.last_tab = Some(EditorTab::Docs); |
|
| 126 | + | let app = App::new( |
|
| 127 | + | c, |
|
| 128 | + | PathBuf::from("/tmp/cielago-test.json"), |
|
| 129 | + | AppConfig::default(), |
|
| 130 | + | ); |
|
| 131 | + | // responses aren't persisted, so pane 3 would be empty |
|
| 132 | + | assert_eq!(app.focus, Focus::Editor); |
|
| 133 | + | assert_eq!(app.tab, EditorTab::Docs); |
|
| 134 | + | } |
|
| 135 | + | ||
| 136 | + | // `record_view` rather than `save`: saving writes into the real |
|
| 137 | + | // `~/.config/cielago/collections`, which a test has no business touching. |
|
| 138 | + | #[test] |
|
| 139 | + | fn record_view_captures_request_pane_and_tab() { |
|
| 140 | + | let mut app = test_app(); |
|
| 141 | + | handle_key(&mut app, char_key('j')); // onto the first request row |
|
| 142 | + | handle_key(&mut app, char_key('j')); // onto the second |
|
| 143 | + | handle_key(&mut app, key(KeyCode::Enter)); // open it |
|
| 144 | + | handle_key(&mut app, char_key(']')); // Params -> Headers |
|
| 145 | + | handle_key(&mut app, char_key(']')); // Headers -> Body |
|
| 146 | + | handle_key(&mut app, char_key('3')); // response pane |
|
| 147 | + | app.record_view(); |
|
| 148 | + | ||
| 149 | + | let id = app.collection.requests[1].id; |
|
| 150 | + | assert_eq!(app.collection.last_request, Some(id)); |
|
| 151 | + | assert_eq!(app.collection.last_focus, Some(Focus::Response)); |
|
| 152 | + | assert_eq!(app.collection.last_tab, Some(EditorTab::Body)); |
|
| 153 | + | } |
|
| 154 | + | ||
| 155 | + | #[test] |
|
| 156 | + | fn startup_ignores_a_stale_last_request() { |
|
| 157 | + | let mut c = test_collection(); |
|
| 158 | + | c.last_request = Some(uuid::Uuid::new_v4()); |
|
| 159 | + | let app = App::new( |
|
| 160 | + | c, |
|
| 161 | + | PathBuf::from("/tmp/cielago-test.json"), |
|
| 162 | + | AppConfig::default(), |
|
| 163 | + | ); |
|
| 164 | + | // request is gone (re-imported spec, deleted operation): fall back to first |
|
| 165 | + | assert_eq!(app.selected, Some(0)); |
|
| 166 | + | } |
|
| 167 | + | ||
| 168 | + | #[test] |
|
| 169 | + | fn help_popup_opens_and_closes() { |
|
| 170 | + | let mut app = test_app(); |
|
| 171 | + | handle_key(&mut app, char_key('?')); |
|
| 172 | + | assert_eq!(app.popup, Popup::Help); |
|
| 173 | + | handle_key(&mut app, key(KeyCode::Esc)); |
|
| 174 | + | assert_eq!(app.popup, Popup::None); |
|
| 175 | + | } |
|
| 176 | + | ||
| 177 | + | #[test] |
|
| 178 | + | fn z_toggles_pane_zoom_without_touching_focus() { |
|
| 179 | + | let mut app = test_app(); |
|
| 180 | + | handle_key(&mut app, char_key('2')); |
|
| 181 | + | handle_key(&mut app, char_key('z')); |
|
| 182 | + | assert!(app.zoom); |
|
| 183 | + | assert_eq!(app.focus, Focus::Editor); |
|
| 184 | + | // Focus still moves while zoomed — it just picks the maximized pane. |
|
| 185 | + | handle_key(&mut app, key(KeyCode::Tab)); |
|
| 186 | + | assert!(app.zoom); |
|
| 187 | + | assert_eq!(app.focus, Focus::Response); |
|
| 188 | + | handle_key(&mut app, char_key('z')); |
|
| 189 | + | assert!(!app.zoom); |
|
| 190 | + | } |
|
| 191 | + | ||
| 192 | + | #[test] |
|
| 193 | + | fn sidebar_navigation_and_selection() { |
|
| 194 | + | let mut app = test_app(); |
|
| 195 | + | handle_key(&mut app, char_key('1')); |
|
| 196 | + | assert_eq!(app.focus, Focus::Sidebar); |
|
| 197 | + | handle_key(&mut app, char_key('j')); |
|
| 198 | + | assert_eq!(app.sidebar_sel, 1); |
|
| 199 | + | handle_key(&mut app, char_key('j')); |
|
| 200 | + | handle_key(&mut app, key(KeyCode::Enter)); |
|
| 201 | + | assert_eq!(app.selected, Some(1)); // createPet |
|
| 202 | + | assert_eq!(app.focus, Focus::Editor); |
|
| 203 | + | // body loaded into textarea |
|
| 204 | + | assert!(app.textarea.lines().join("\n").contains("Fido")); |
|
| 205 | + | } |
|
| 206 | + | ||
| 207 | + | #[test] |
|
| 208 | + | fn sidebar_group_collapse() { |
|
| 209 | + | let mut app = test_app(); |
|
| 210 | + | handle_key(&mut app, char_key('1')); |
|
| 211 | + | // row 0 is the "pets" group |
|
| 212 | + | handle_key(&mut app, key(KeyCode::Enter)); |
|
| 213 | + | assert!(app.collapsed.contains("pets")); |
|
| 214 | + | assert_eq!(app.sidebar_rows.len(), 3); // pets group collapsed |
|
| 215 | + | handle_key(&mut app, key(KeyCode::Enter)); |
|
| 216 | + | assert!(!app.collapsed.contains("pets")); |
|
| 217 | + | assert_eq!(app.sidebar_rows.len(), 5); |
|
| 218 | + | } |
|
| 219 | + | ||
| 220 | + | #[test] |
|
| 221 | + | fn quit_guards_unsaved_changes() { |
|
| 222 | + | let mut app = test_app(); |
|
| 223 | + | assert!(!app.dirty); |
|
| 224 | + | handle_key(&mut app, char_key('q')); |
|
| 225 | + | assert!(app.should_quit); |
|
| 226 | + | ||
| 227 | + | let mut app = test_app(); |
|
| 228 | + | app.dirty = true; |
|
| 229 | + | handle_key(&mut app, char_key('q')); |
|
| 230 | + | assert!(!app.should_quit); |
|
| 231 | + | assert!(app.status.contains("Unsaved")); |
|
| 232 | + | // :q! forces |
|
| 233 | + | handle_key(&mut app, char_key(':')); |
|
| 234 | + | type_str(&mut app, "q!"); |
|
| 235 | + | handle_key(&mut app, key(KeyCode::Enter)); |
|
| 236 | + | assert!(app.should_quit); |
|
| 237 | + | } |
|
| 238 | + | ||
| 239 | + | #[test] |
|
| 240 | + | fn tab_cycling() { |
|
| 241 | + | let mut app = test_app(); |
|
| 242 | + | assert_eq!(app.tab, EditorTab::Params); |
|
| 243 | + | handle_key(&mut app, char_key(']')); |
|
| 244 | + | assert_eq!(app.tab, EditorTab::Headers); |
|
| 245 | + | handle_key(&mut app, char_key(']')); |
|
| 246 | + | assert_eq!(app.tab, EditorTab::Body); |
|
| 247 | + | handle_key(&mut app, char_key('[')); |
|
| 248 | + | assert_eq!(app.tab, EditorTab::Headers); |
|
| 249 | + | } |
|
| 250 | + | ||
| 251 | + | #[test] |
|
| 252 | + | fn tab_cycling_letter_aliases() { |
|
| 253 | + | let mut app = test_app(); |
|
| 254 | + | assert_eq!(app.tab, EditorTab::Params); |
|
| 255 | + | handle_key(&mut app, char_key('L')); |
|
| 256 | + | assert_eq!(app.tab, EditorTab::Headers); |
|
| 257 | + | handle_key(&mut app, char_key('L')); |
|
| 258 | + | assert_eq!(app.tab, EditorTab::Body); |
|
| 259 | + | handle_key(&mut app, char_key('H')); |
|
| 260 | + | assert_eq!(app.tab, EditorTab::Headers); |
|
| 261 | + | // wraps backwards past Params into Variables |
|
| 262 | + | handle_key(&mut app, char_key('H')); |
|
| 263 | + | handle_key(&mut app, char_key('H')); |
|
| 264 | + | assert_eq!(app.tab, EditorTab::Variables); |
|
| 265 | + | } |
|
| 266 | + | ||
| 267 | + | #[test] |
|
| 268 | + | fn slash_filters_the_sidebar() { |
|
| 269 | + | let mut app = test_app(); |
|
| 270 | + | handle_key(&mut app, char_key('/')); |
|
| 271 | + | assert_eq!(app.mode, Mode::Search); |
|
| 272 | + | assert_eq!(app.focus, Focus::Sidebar); |
|
| 273 | + | ||
| 274 | + | type_str(&mut app, "order"); |
|
| 275 | + | // "store" group header + placeOrder only |
|
| 276 | + | assert_eq!(app.sidebar_rows.len(), 2); |
|
| 277 | + | // cursor parked on the match, not the group header |
|
| 278 | + | assert_eq!(app.sidebar_rows[app.sidebar_sel], SidebarRow::Request(2)); |
|
| 279 | + | ||
| 280 | + | // Enter keeps the filter and returns to Normal. |
|
| 281 | + | handle_key(&mut app, key(KeyCode::Enter)); |
|
| 282 | + | assert_eq!(app.mode, Mode::Normal); |
|
| 283 | + | assert_eq!(app.filter, "order"); |
|
| 284 | + | handle_key(&mut app, key(KeyCode::Enter)); // open the match |
|
| 285 | + | assert_eq!(app.selected, Some(2)); |
|
| 286 | + | ||
| 287 | + | // Esc in the sidebar clears the filter. |
|
| 288 | + | handle_key(&mut app, char_key('1')); |
|
| 289 | + | handle_key(&mut app, key(KeyCode::Esc)); |
|
| 290 | + | assert!(app.filter.is_empty()); |
|
| 291 | + | assert_eq!(app.sidebar_rows.len(), 5); |
|
| 292 | + | } |
|
| 293 | + | ||
| 294 | + | #[test] |
|
| 295 | + | fn search_matches_path_method_and_tag() { |
|
| 296 | + | let mut app = test_app(); |
|
| 297 | + | ||
| 298 | + | handle_key(&mut app, char_key('/')); |
|
| 299 | + | type_str(&mut app, "/pets"); |
|
| 300 | + | assert_eq!(app.sidebar_rows.len(), 3); // pets group + 2 requests |
|
| 301 | + | handle_key(&mut app, key(KeyCode::Esc)); |
|
| 302 | + | assert!(app.filter.is_empty()); |
|
| 303 | + | ||
| 304 | + | handle_key(&mut app, char_key('/')); |
|
| 305 | + | type_str(&mut app, "post"); |
|
| 306 | + | assert_eq!(app.sidebar_rows.len(), 4); // createPet + placeOrder, 2 groups |
|
| 307 | + | handle_key(&mut app, key(KeyCode::Esc)); |
|
| 308 | + | ||
| 309 | + | handle_key(&mut app, char_key('/')); |
|
| 310 | + | type_str(&mut app, "store"); |
|
| 311 | + | assert_eq!(app.sidebar_rows.len(), 2); |
|
| 312 | + | ||
| 313 | + | // backspacing widens the match set again |
|
| 314 | + | handle_key(&mut app, key(KeyCode::Backspace)); |
|
| 315 | + | handle_key(&mut app, key(KeyCode::Backspace)); |
|
| 316 | + | handle_key(&mut app, key(KeyCode::Backspace)); |
|
| 317 | + | handle_key(&mut app, key(KeyCode::Backspace)); |
|
| 318 | + | handle_key(&mut app, key(KeyCode::Backspace)); |
|
| 319 | + | assert_eq!(app.sidebar_rows.len(), 5); |
|
| 320 | + | } |
|
| 321 | + | ||
| 322 | + | #[test] |
|
| 323 | + | fn search_shows_matches_inside_collapsed_groups() { |
|
| 324 | + | let mut app = test_app(); |
|
| 325 | + | app.collapsed.insert("pets".into()); |
|
| 326 | + | app.rebuild_sidebar(); |
|
| 327 | + | assert_eq!(app.sidebar_rows.len(), 3); |
|
| 328 | + | ||
| 329 | + | handle_key(&mut app, char_key('/')); |
|
| 330 | + | type_str(&mut app, "createPet"); |
|
| 331 | + | assert_eq!(app.sidebar_rows.len(), 2); |
|
| 332 | + | } |
|
| 333 | + | ||
| 334 | + | #[test] |
|
| 335 | + | fn label_mode_cycles_and_persists_on_the_collection() { |
|
| 336 | + | let mut app = test_app(); |
|
| 337 | + | handle_key(&mut app, char_key('1')); |
|
| 338 | + | assert_eq!(app.collection.label_mode, LabelMode::Name); |
|
| 339 | + | handle_key(&mut app, char_key('t')); |
|
| 340 | + | assert_eq!(app.collection.label_mode, LabelMode::Summary); |
|
| 341 | + | handle_key(&mut app, char_key('t')); |
|
| 342 | + | assert_eq!(app.collection.label_mode, LabelMode::Path); |
|
| 343 | + | handle_key(&mut app, char_key('t')); |
|
| 344 | + | assert_eq!(app.collection.label_mode, LabelMode::Name); |
|
| 345 | + | assert!(app.dirty); |
|
| 346 | + | } |
|
| 347 | + | ||
| 348 | + | #[test] |
|
| 349 | + | fn label_command_sets_mode() { |
|
| 350 | + | let mut app = test_app(); |
|
| 351 | + | handle_key(&mut app, char_key(':')); |
|
| 352 | + | type_str(&mut app, "label path"); |
|
| 353 | + | handle_key(&mut app, key(KeyCode::Enter)); |
|
| 354 | + | assert_eq!(app.collection.label_mode, LabelMode::Path); |
|
| 355 | + | ||
| 356 | + | handle_key(&mut app, char_key(':')); |
|
| 357 | + | type_str(&mut app, "label nonsense"); |
|
| 358 | + | handle_key(&mut app, key(KeyCode::Enter)); |
|
| 359 | + | assert_eq!(app.collection.label_mode, LabelMode::Path); |
|
| 360 | + | assert!(app.status.contains("Usage")); |
|
| 361 | + | } |
|
| 362 | + | ||
| 363 | + | #[test] |
|
| 364 | + | fn rename_all_rewrites_names_from_paths() { |
|
| 365 | + | let mut app = test_app(); |
|
| 366 | + | handle_key(&mut app, char_key(':')); |
|
| 367 | + | type_str(&mut app, "rename-all method-path"); |
|
| 368 | + | handle_key(&mut app, key(KeyCode::Enter)); |
|
| 369 | + | assert_eq!(app.collection.requests[0].name, "GET /pets"); |
|
| 370 | + | assert_eq!(app.collection.requests[1].name, "POST /pets"); |
|
| 371 | + | assert_eq!(app.collection.requests[2].name, "POST /orders"); |
|
| 372 | + | assert!(app.dirty); |
|
| 373 | + | } |
|
| 374 | + | ||
| 375 | + | #[test] |
|
| 376 | + | fn edit_query_value_inline() { |
|
| 377 | + | let mut app = test_app(); |
|
| 378 | + | handle_key(&mut app, char_key('2')); |
|
| 379 | + | assert_eq!(app.tab, EditorTab::Params); |
|
| 380 | + | // row 0 = limit (value "20") |
|
| 381 | + | handle_key(&mut app, char_key('i')); |
|
| 382 | + | assert_eq!(app.mode, Mode::Insert); |
|
| 383 | + | assert!(matches!(app.editing, Some(EditTarget::Cell { .. }))); |
|
| 384 | + | handle_key(&mut app, char_key('5')); |
|
| 385 | + | handle_key(&mut app, key(KeyCode::Enter)); |
|
| 386 | + | assert_eq!(app.mode, Mode::Normal); |
|
| 387 | + | assert_eq!(app.collection.requests[0].query[0].value, "205"); |
|
| 388 | + | assert!(app.dirty); |
|
| 389 | + | } |
|
| 390 | + | ||
| 391 | + | #[test] |
|
| 392 | + | fn space_toggles_row() { |
|
| 393 | + | let mut app = test_app(); |
|
| 394 | + | handle_key(&mut app, char_key('2')); |
|
| 395 | + | assert!(!app.collection.requests[0].query[0].enabled); |
|
| 396 | + | handle_key(&mut app, char_key(' ')); |
|
| 397 | + | assert!(app.collection.requests[0].query[0].enabled); |
|
| 398 | + | } |
|
| 399 | + | ||
| 400 | + | #[test] |
|
| 401 | + | fn add_header_row_with_uuid_variable() { |
|
| 402 | + | let mut app = test_app(); |
|
| 403 | + | handle_key(&mut app, char_key('2')); |
|
| 404 | + | handle_key(&mut app, char_key(']')); // Headers tab |
|
| 405 | + | assert_eq!(app.tab, EditorTab::Headers); |
|
| 406 | + | handle_key(&mut app, char_key('a')); |
|
| 407 | + | type_str(&mut app, "X-Request-Id"); |
|
| 408 | + | handle_key(&mut app, key(KeyCode::Enter)); // chains to value edit |
|
| 409 | + | type_str(&mut app, "{{uuid}}"); |
|
| 410 | + | handle_key(&mut app, key(KeyCode::Enter)); |
|
| 411 | + | assert_eq!(app.mode, Mode::Normal); |
|
| 412 | + | let headers = &app.collection.requests[0].headers; |
|
| 413 | + | assert_eq!(headers.len(), 1); |
|
| 414 | + | assert_eq!(headers[0].key, "X-Request-Id"); |
|
| 415 | + | assert_eq!(headers[0].value, "{{uuid}}"); |
|
| 416 | + | assert!(headers[0].enabled); |
|
| 417 | + | } |
|
| 418 | + | ||
| 419 | + | #[test] |
|
| 420 | + | fn body_textarea_editing() { |
|
| 421 | + | let mut app = test_app(); |
|
| 422 | + | // select createPet (has a body) |
|
| 423 | + | app.select_request(1); |
|
| 424 | + | handle_key(&mut app, char_key(']')); |
|
| 425 | + | handle_key(&mut app, char_key(']')); // Body tab |
|
| 426 | + | assert_eq!(app.tab, EditorTab::Body); |
|
| 427 | + | handle_key(&mut app, char_key('i')); |
|
| 428 | + | assert_eq!(app.mode, Mode::Insert); |
|
| 429 | + | handle_key(&mut app, key(KeyCode::Esc)); |
|
| 430 | + | assert_eq!(app.mode, Mode::Normal); |
|
| 431 | + | assert!( |
|
| 432 | + | app.collection.requests[1] |
|
| 433 | + | .body |
|
| 434 | + | .as_ref() |
|
| 435 | + | .unwrap() |
|
| 436 | + | .contains("Fido") |
|
| 437 | + | ); |
|
| 438 | + | } |
|
| 439 | + | ||
| 440 | + | #[test] |
|
| 441 | + | fn body_tab_scrolls_the_read_only_view() { |
|
| 442 | + | let mut app = test_app(); |
|
| 443 | + | app.select_request(1); |
|
| 444 | + | app.set_textarea_text(&(1..=40).map(|i| format!("line {i}\n")).collect::<String>()); |
|
| 445 | + | app.tab = EditorTab::Body; |
|
| 446 | + | ||
| 447 | + | // The highlighted body view follows the textarea cursor. |
|
| 448 | + | handle_key(&mut app, char_key('j')); |
|
| 449 | + | handle_key(&mut app, char_key('j')); |
|
| 450 | + | assert_eq!(app.textarea.cursor().0, 2); |
|
| 451 | + | handle_key(&mut app, char_key('k')); |
|
| 452 | + | assert_eq!(app.textarea.cursor().0, 1); |
|
| 453 | + | handle_key(&mut app, char_key('d')); |
|
| 454 | + | assert_eq!(app.textarea.cursor().0, 16); |
|
| 455 | + | handle_key(&mut app, char_key('u')); |
|
| 456 | + | assert_eq!(app.textarea.cursor().0, 1); |
|
| 457 | + | handle_key(&mut app, char_key('G')); |
|
| 458 | + | assert!(app.textarea.cursor().0 >= 39); |
|
| 459 | + | handle_key(&mut app, char_key('g')); |
|
| 460 | + | assert_eq!(app.textarea.cursor().0, 0); |
|
| 461 | + | ||
| 462 | + | // `d` scrolls here rather than deleting a row, but the other editor keys |
|
| 463 | + | // still reach their handlers. |
|
| 464 | + | assert_eq!(app.collection.requests[1].method, Method::Post); |
|
| 465 | + | handle_key(&mut app, char_key('m')); |
|
| 466 | + | assert_eq!(app.collection.requests[1].method, Method::Put); |
|
| 467 | + | handle_key(&mut app, char_key('i')); |
|
| 468 | + | assert_eq!(app.mode, Mode::Insert); |
|
| 469 | + | } |
|
| 470 | + | ||
| 471 | + | #[test] |
|
| 472 | + | fn docs_tab_scrolls_and_stays_read_only() { |
|
| 473 | + | let mut app = test_app(); |
|
| 474 | + | app.select_request(0); // moves focus to the editor |
|
| 475 | + | app.tab = EditorTab::Docs; |
|
| 476 | + | let params_before = app.collection.requests[0].query.len(); |
|
| 477 | + | ||
| 478 | + | handle_key(&mut app, char_key('j')); |
|
| 479 | + | handle_key(&mut app, char_key('j')); |
|
| 480 | + | assert_eq!(app.docs_scroll, 2); |
|
| 481 | + | handle_key(&mut app, char_key('k')); |
|
| 482 | + | assert_eq!(app.docs_scroll, 1); |
|
| 483 | + | handle_key(&mut app, char_key('d')); |
|
| 484 | + | assert_eq!(app.docs_scroll, 16); |
|
| 485 | + | handle_key(&mut app, char_key('u')); |
|
| 486 | + | assert_eq!(app.docs_scroll, 1); |
|
| 487 | + | handle_key(&mut app, char_key('g')); |
|
| 488 | + | assert_eq!(app.docs_scroll, 0); |
|
| 489 | + | ||
| 490 | + | // `d` scrolled instead of deleting, and `i` doesn't open an editor here. |
|
| 491 | + | assert_eq!(app.collection.requests[0].query.len(), params_before); |
|
| 492 | + | handle_key(&mut app, char_key('i')); |
|
| 493 | + | assert_eq!(app.mode, Mode::Normal); |
|
| 494 | + | assert!(!app.dirty); |
|
| 495 | + | ||
| 496 | + | // Opening another request resets the scroll. |
|
| 497 | + | app.docs_scroll = 5; |
|
| 498 | + | app.select_request(2); |
|
| 499 | + | assert_eq!(app.docs_scroll, 0); |
|
| 500 | + | } |
|
| 501 | + | ||
| 502 | + | #[test] |
|
| 503 | + | fn help_popup_scrolls() { |
|
| 504 | + | let mut app = test_app(); |
|
| 505 | + | handle_key(&mut app, char_key('?')); |
|
| 506 | + | assert_eq!(app.help_scroll, 0); |
|
| 507 | + | handle_key(&mut app, char_key('j')); |
|
| 508 | + | handle_key(&mut app, char_key('j')); |
|
| 509 | + | assert_eq!(app.help_scroll, 2); |
|
| 510 | + | handle_key(&mut app, char_key('k')); |
|
| 511 | + | assert_eq!(app.help_scroll, 1); |
|
| 512 | + | handle_key(&mut app, char_key('g')); |
|
| 513 | + | assert_eq!(app.help_scroll, 0); |
|
| 514 | + | // Reopening starts back at the top. |
|
| 515 | + | handle_key(&mut app, char_key('d')); |
|
| 516 | + | assert!(app.help_scroll > 0); |
|
| 517 | + | handle_key(&mut app, key(KeyCode::Esc)); |
|
| 518 | + | handle_key(&mut app, char_key('?')); |
|
| 519 | + | assert_eq!(app.help_scroll, 0); |
|
| 520 | + | } |
|
| 521 | + | ||
| 522 | + | #[test] |
|
| 523 | + | fn env_popup_add_and_select_server() { |
|
| 524 | + | let mut app = test_app(); |
|
| 525 | + | handle_key(&mut app, char_key('E')); |
|
| 526 | + | assert_eq!(app.popup, Popup::Env); |
|
| 527 | + | handle_key(&mut app, char_key('a')); |
|
| 528 | + | type_str(&mut app, "http://localhost:8080"); |
|
| 529 | + | handle_key(&mut app, key(KeyCode::Enter)); |
|
| 530 | + | assert_eq!(app.collection.servers.len(), 3); |
|
| 531 | + | assert_eq!(app.collection.active_server, 2); |
|
| 532 | + | handle_key(&mut app, key(KeyCode::Esc)); |
|
| 533 | + | assert_eq!(app.popup, Popup::None); |
|
| 534 | + | } |
|
| 535 | + | ||
| 536 | + | #[test] |
|
| 537 | + | fn auth_popup_edits_and_applies() { |
|
| 538 | + | let mut app = test_app(); |
|
| 539 | + | handle_key(&mut app, char_key('A')); |
|
| 540 | + | assert_eq!(app.popup, Popup::Auth); |
|
| 541 | + | // field 0 = token url |
|
| 542 | + | handle_key(&mut app, char_key('i')); |
|
| 543 | + | type_str(&mut app, "https://auth.example.com/token"); |
|
| 544 | + | handle_key(&mut app, key(KeyCode::Enter)); |
|
| 545 | + | // move to client id, edit |
|
| 546 | + | handle_key(&mut app, char_key('j')); |
|
| 547 | + | handle_key(&mut app, char_key('i')); |
|
| 548 | + | type_str(&mut app, "my-client"); |
|
| 549 | + | handle_key(&mut app, key(KeyCode::Enter)); |
|
| 550 | + | // style toggle: field 4 |
|
| 551 | + | for _ in 0..3 { |
|
| 552 | + | handle_key(&mut app, char_key('j')); |
|
| 553 | + | } |
|
| 554 | + | assert_eq!(app.auth_field, 4); |
|
| 555 | + | handle_key(&mut app, char_key(' ')); |
|
| 556 | + | // close + apply |
|
| 557 | + | handle_key(&mut app, key(KeyCode::Esc)); |
|
| 558 | + | assert_eq!(app.popup, Popup::None); |
|
| 559 | + | let auth = app.collection.auth.as_ref().unwrap(); |
|
| 560 | + | assert_eq!(auth.token_url, "https://auth.example.com/token"); |
|
| 561 | + | assert_eq!(auth.client_id, "my-client"); |
|
| 562 | + | assert_eq!(auth.auth_style, cielago::model::AuthStyle::Post); |
|
| 563 | + | assert!(app.dirty); |
|
| 564 | + | } |
|
| 565 | + | ||
| 566 | + | #[test] |
|
| 567 | + | fn new_request_flow() { |
|
| 568 | + | let mut app = test_app(); |
|
| 569 | + | handle_key(&mut app, char_key('1')); |
|
| 570 | + | handle_key(&mut app, char_key('n')); |
|
| 571 | + | type_str(&mut app, "my custom request"); |
|
| 572 | + | handle_key(&mut app, key(KeyCode::Enter)); |
|
| 573 | + | assert_eq!(app.collection.requests.len(), 4); |
|
| 574 | + | assert_eq!(app.selected, Some(3)); |
|
| 575 | + | assert_eq!(app.collection.requests[3].name, "my custom request"); |
|
| 576 | + | } |
|
| 577 | + | ||
| 578 | + | #[test] |
|
| 579 | + | fn rename_and_delete_request() { |
|
| 580 | + | let mut app = test_app(); |
|
| 581 | + | handle_key(&mut app, char_key('1')); |
|
| 582 | + | handle_key(&mut app, char_key('j')); // first request row |
|
| 583 | + | handle_key(&mut app, char_key('r')); |
|
| 584 | + | // rename input prefilled with current name; replace |
|
| 585 | + | handle_key(&mut app, key(KeyCode::Home)); |
|
| 586 | + | for _ in 0..20 { |
|
| 587 | + | handle_key(&mut app, key(KeyCode::Delete)); |
|
| 588 | + | } |
|
| 589 | + | type_str(&mut app, "renamed"); |
|
| 590 | + | handle_key(&mut app, key(KeyCode::Enter)); |
|
| 591 | + | assert_eq!(app.collection.requests[0].name, "renamed"); |
|
| 592 | + | ||
| 593 | + | // focus stays in the sidebar on the renamed row; delete it |
|
| 594 | + | assert_eq!(app.focus, Focus::Sidebar); |
|
| 595 | + | handle_key(&mut app, char_key('d')); |
|
| 596 | + | assert_eq!(app.collection.requests.len(), 2); |
|
| 597 | + | assert!(!app.collection.requests.iter().any(|r| r.name == "renamed")); |
|
| 598 | + | } |
|
| 599 | + | ||
| 600 | + | #[test] |
|
| 601 | + | fn variables_tab_roundtrip() { |
|
| 602 | + | let mut app = test_app(); |
|
| 603 | + | handle_key(&mut app, char_key('2')); |
|
| 604 | + | handle_key(&mut app, char_key('[')); // Variables (prev of Params) |
|
| 605 | + | assert_eq!(app.tab, EditorTab::Variables); |
|
| 606 | + | handle_key(&mut app, char_key('a')); |
|
| 607 | + | type_str(&mut app, "tenant"); |
|
| 608 | + | handle_key(&mut app, key(KeyCode::Enter)); |
|
| 609 | + | type_str(&mut app, "acme"); |
|
| 610 | + | handle_key(&mut app, key(KeyCode::Enter)); |
|
| 611 | + | assert_eq!(app.collection.variables.len(), 1); |
|
| 612 | + | let map = cielago::model::variables_map(&app.collection.variables); |
|
| 613 | + | assert_eq!(map.get("tenant").unwrap(), "acme"); |
|
| 614 | + | } |
|
| 615 | + | ||
| 616 | + | // ----- ad-hoc requests and collections ----- |
|
| 617 | + | ||
| 618 | + | /// Open the URL prompt on the selected request with a cleared buffer. |
|
| 619 | + | fn start_url_edit(app: &mut App) { |
|
| 620 | + | handle_key(app, char_key('2')); |
|
| 621 | + | handle_key(app, char_key('p')); |
|
| 622 | + | assert_eq!(app.editing, Some(EditTarget::Url)); |
|
| 623 | + | handle_key(app, key(KeyCode::Home)); |
|
| 624 | + | for _ in 0..60 { |
|
| 625 | + | handle_key(app, key(KeyCode::Delete)); |
|
| 626 | + | } |
|
| 627 | + | } |
|
| 628 | + | ||
| 629 | + | fn type_url(app: &mut App, url: &str) { |
|
| 630 | + | start_url_edit(app); |
|
| 631 | + | type_str(app, url); |
|
| 632 | + | handle_key(app, key(KeyCode::Enter)); |
|
| 633 | + | } |
|
| 634 | + | ||
| 635 | + | #[test] |
|
| 636 | + | fn edit_url_sets_path_and_syncs_path_params() { |
|
| 637 | + | let mut app = test_app(); |
|
| 638 | + | type_url(&mut app, "/pets/{petId}/photos"); |
|
| 639 | + | ||
| 640 | + | let req = &app.collection.requests[0]; |
|
| 641 | + | assert_eq!(req.path, "/pets/{petId}/photos"); |
|
| 642 | + | assert_eq!(req.path_params.len(), 1); |
|
| 643 | + | assert_eq!(req.path_params[0].key, "petId"); |
|
| 644 | + | assert!(app.dirty); |
|
| 645 | + | // No `?` in the input, so the existing query rows are untouched. |
|
| 646 | + | assert_eq!(req.query.len(), 2); |
|
| 647 | + | ||
| 648 | + | // Removing the placeholder prunes the row again. |
|
| 649 | + | type_url(&mut app, "/pets"); |
|
| 650 | + | assert!(app.collection.requests[0].path_params.is_empty()); |
|
| 651 | + | } |
|
| 652 | + | ||
| 653 | + | #[test] |
|
| 654 | + | fn pasting_a_full_url_adds_and_activates_the_server() { |
|
| 655 | + | let mut app = test_app(); |
|
| 656 | + | type_url( |
|
| 657 | + | &mut app, |
|
| 658 | + | "https://three.example.com/v2/pets?limit=5&sort=name", |
|
| 659 | + | ); |
|
| 660 | + | ||
| 661 | + | assert_eq!(app.collection.servers.len(), 3); |
|
| 662 | + | assert_eq!(app.collection.active_server, 2); |
|
| 663 | + | assert_eq!(app.collection.base_url(), Some("https://three.example.com")); |
|
| 664 | + | let req = &app.collection.requests[0]; |
|
| 665 | + | assert_eq!(req.path, "/v2/pets"); |
|
| 666 | + | let query: Vec<(&str, &str)> = req |
|
| 667 | + | .query |
|
| 668 | + | .iter() |
|
| 669 | + | .map(|r| (r.key.as_str(), r.value.as_str())) |
|
| 670 | + | .collect(); |
|
| 671 | + | assert_eq!(query, vec![("limit", "5"), ("sort", "name")]); |
|
| 672 | + | assert!(req.query.iter().all(|r| r.enabled)); |
|
| 673 | + | } |
|
| 674 | + | ||
| 675 | + | #[test] |
|
| 676 | + | fn pasting_a_known_origin_switches_to_it_without_duplicating() { |
|
| 677 | + | let mut app = test_app(); |
|
| 678 | + | assert_eq!(app.collection.active_server, 0); |
|
| 679 | + | type_url(&mut app, "https://two.example.com/pets"); |
|
| 680 | + | ||
| 681 | + | assert_eq!(app.collection.servers.len(), 2); |
|
| 682 | + | assert_eq!(app.collection.active_server, 1); |
|
| 683 | + | assert_eq!(app.collection.requests[0].path, "/pets"); |
|
| 684 | + | } |
|
| 685 | + | ||
| 686 | + | #[test] |
|
| 687 | + | fn pasting_without_a_query_keeps_existing_params() { |
|
| 688 | + | let mut app = test_app(); |
|
| 689 | + | type_url(&mut app, "https://one.example.com/pets/all"); |
|
| 690 | + | ||
| 691 | + | let req = &app.collection.requests[0]; |
|
| 692 | + | assert_eq!(req.path, "/pets/all"); |
|
| 693 | + | assert_eq!(req.query.len(), 2); |
|
| 694 | + | // Disabled optional params from the fixture survive untouched. |
|
| 695 | + | assert!(req.query.iter().all(|r| !r.enabled)); |
|
| 696 | + | assert_eq!(req.query[0].key, "limit"); |
|
| 697 | + | } |
|
| 698 | + | ||
| 699 | + | #[test] |
|
| 700 | + | fn non_http_scheme_is_rejected() { |
|
| 701 | + | let mut app = test_app(); |
|
| 702 | + | type_url(&mut app, "ftp://files.example.com/pets"); |
|
| 703 | + | ||
| 704 | + | assert_eq!(app.collection.requests[0].path, "/pets"); |
|
| 705 | + | assert_eq!(app.collection.servers.len(), 2); |
|
| 706 | + | assert!(app.status.contains("http(s)")); |
|
| 707 | + | } |
|
| 708 | + | ||
| 709 | + | #[test] |
|
| 710 | + | fn duplicate_request_clones_directly_after_the_original() { |
|
| 711 | + | let mut app = test_app(); |
|
| 712 | + | handle_key(&mut app, char_key('1')); |
|
| 713 | + | handle_key(&mut app, char_key('j')); // first request row (listPets) |
|
| 714 | + | handle_key(&mut app, char_key('y')); |
|
| 715 | + | ||
| 716 | + | assert_eq!(app.collection.requests.len(), 4); |
|
| 717 | + | assert_eq!(app.collection.requests[1].name, "listPets copy"); |
|
| 718 | + | assert_ne!(app.collection.requests[0].id, app.collection.requests[1].id); |
|
| 719 | + | assert_eq!(app.collection.requests[1].path, "/pets"); |
|
| 720 | + | assert_eq!(app.collection.requests[1].query.len(), 2); |
|
| 721 | + | // The original is still in place, and the clone is what's open. |
|
| 722 | + | assert_eq!(app.collection.requests[0].name, "listPets"); |
|
| 723 | + | assert_eq!(app.selected, Some(1)); |
|
| 724 | + | assert_eq!(app.focus, Focus::Sidebar); |
|
| 725 | + | assert_eq!(app.sidebar_rows[app.sidebar_sel], SidebarRow::Request(1)); |
|
| 726 | + | assert!(app.dirty); |
|
| 727 | + | } |
|
| 728 | + | ||
| 729 | + | #[test] |
|
| 730 | + | fn duplicate_twice_numbers_the_copies() { |
|
| 731 | + | let mut app = test_app(); |
|
| 732 | + | handle_key(&mut app, char_key('1')); |
|
| 733 | + | handle_key(&mut app, char_key('j')); |
|
| 734 | + | handle_key(&mut app, char_key('y')); |
|
| 735 | + | handle_key(&mut app, char_key('y')); |
|
| 736 | + | ||
| 737 | + | assert_eq!(app.collection.requests.len(), 5); |
|
| 738 | + | let names: Vec<&str> = app |
|
| 739 | + | .collection |
|
| 740 | + | .requests |
|
| 741 | + | .iter() |
|
| 742 | + | .map(|r| r.name.as_str()) |
|
| 743 | + | .collect(); |
|
| 744 | + | // The cursor followed the first clone, so the second `y` duplicates *it* — |
|
| 745 | + | // and the ` copy` suffix is stripped first, giving `copy 2` not `copy copy`. |
|
| 746 | + | assert_eq!(names[1], "listPets copy"); |
|
| 747 | + | assert_eq!(names[2], "listPets copy 2"); |
|
| 748 | + | } |
|
| 749 | + | ||
| 750 | + | #[test] |
|
| 751 | + | fn new_request_chains_into_the_url_prompt() { |
|
| 752 | + | let mut app = test_app(); |
|
| 753 | + | handle_key(&mut app, char_key('1')); |
|
| 754 | + | handle_key(&mut app, char_key('n')); |
|
| 755 | + | type_str(&mut app, "adhoc"); |
|
| 756 | + | handle_key(&mut app, key(KeyCode::Enter)); |
|
| 757 | + | ||
| 758 | + | // The name commit leaves you in the URL prompt rather than on `GET /`. |
|
| 759 | + | assert_eq!(app.editing, Some(EditTarget::Url)); |
|
| 760 | + | assert_eq!(app.mode, Mode::Insert); |
|
| 761 | + | type_str(&mut app, "https://four.example.com/ip"); |
|
| 762 | + | handle_key(&mut app, key(KeyCode::Enter)); |
|
| 763 | + | ||
| 764 | + | let req = app.collection.requests.last().unwrap(); |
|
| 765 | + | assert_eq!(req.name, "adhoc"); |
|
| 766 | + | assert_eq!(req.path, "/ip"); |
|
| 767 | + | assert_eq!(app.collection.base_url(), Some("https://four.example.com")); |
|
| 768 | + | } |
|
| 769 | + | ||
| 770 | + | #[test] |
|
| 771 | + | fn switch_collection_replaces_state() { |
|
| 772 | + | let mut app = test_app(); |
|
| 773 | + | handle_key(&mut app, char_key('/')); |
|
| 774 | + | type_str(&mut app, "orders"); |
|
| 775 | + | handle_key(&mut app, key(KeyCode::Enter)); |
|
| 776 | + | handle_key(&mut app, char_key('2')); |
|
| 777 | + | handle_key(&mut app, char_key('m')); // dirty it |
|
| 778 | + | ||
| 779 | + | let mut other = Collection::new("other"); |
|
| 780 | + | other.requests = vec![SavedRequest::blank("only")]; |
|
| 781 | + | app.switch_collection(other, PathBuf::from("/tmp/cielago-other.json")); |
|
| 782 | + | ||
| 783 | + | assert_eq!(app.collection.name, "other"); |
|
| 784 | + | assert_eq!(app.collection.requests.len(), 1); |
|
| 785 | + | assert_eq!(app.selected, Some(0)); |
|
| 786 | + | assert!(!app.dirty); |
|
| 787 | + | assert!(app.filter.is_empty()); |
|
| 788 | + | assert!(app.response.is_none()); |
|
| 789 | + | assert!(app.status.contains("other")); |
|
| 790 | + | // Tracked in memory only — `switch_collection` must not write to the real |
|
| 791 | + | // `~/.config/cielago`, which is where `store::config_dir` always points. |
|
| 792 | + | assert_eq!(app.config.last_collection.as_deref(), Some("other")); |
|
| 793 | + | } |
|
| 794 | + | ||
| 795 | + | #[test] |
|
| 796 | + | fn new_collection_command_refuses_when_dirty() { |
|
| 797 | + | let mut app = test_app(); |
|
| 798 | + | handle_key(&mut app, char_key('2')); |
|
| 799 | + | handle_key(&mut app, char_key('m')); // cycle method → dirty |
|
| 800 | + | assert!(app.dirty); |
|
| 801 | + | ||
| 802 | + | // The dirty guard runs before any filesystem access, so this touches nothing. |
|
| 803 | + | handle_key(&mut app, char_key(':')); |
|
| 804 | + | type_str(&mut app, "new Scratch"); |
|
| 805 | + | handle_key(&mut app, key(KeyCode::Enter)); |
|
| 806 | + | ||
| 807 | + | assert_eq!(app.collection.name, "test"); |
|
| 808 | + | assert!(app.status.contains("Unsaved changes")); |
|
| 809 | + | } |
|
| 810 | + | ||
| 811 | + | #[test] |
|
| 812 | + | fn new_collection_command_rejects_a_missing_name() { |
|
| 813 | + | let mut app = test_app(); |
|
| 814 | + | handle_key(&mut app, char_key(':')); |
|
| 815 | + | type_str(&mut app, "new "); |
|
| 816 | + | handle_key(&mut app, key(KeyCode::Enter)); |
|
| 817 | + | ||
| 818 | + | assert_eq!(app.collection.name, "test"); |
|
| 819 | + | assert!(app.status.starts_with("Usage: :new")); |
|
| 820 | + | } |
| 1 | + | //! Render tests: draw the whole UI into an in-memory terminal and inspect the |
|
| 2 | + | //! cells. These cover the syntax highlighting, which the keymap tests can't see. |
|
| 3 | + | ||
| 4 | + | use std::path::PathBuf; |
|
| 5 | + | use std::time::Duration; |
|
| 6 | + | ||
| 7 | + | use cielago::app::{App, EditorTab, Mode}; |
|
| 8 | + | use cielago::http::HttpResponse; |
|
| 9 | + | use cielago::model::{Collection, FieldDoc, Method, SavedRequest}; |
|
| 10 | + | use cielago::store::AppConfig; |
|
| 11 | + | use cielago::ui; |
|
| 12 | + | use ratatui::Terminal; |
|
| 13 | + | use ratatui::backend::TestBackend; |
|
| 14 | + | use ratatui::buffer::Buffer; |
|
| 15 | + | use ratatui::style::Color; |
|
| 16 | + | ||
| 17 | + | fn test_app() -> App { |
|
| 18 | + | let mut c = Collection::new("test"); |
|
| 19 | + | c.servers = vec!["https://one.example.com".into()]; |
|
| 20 | + | let mut create = SavedRequest::blank("createPet"); |
|
| 21 | + | create.method = Method::Post; |
|
| 22 | + | create.path = "/pets".into(); |
|
| 23 | + | create.body = |
|
| 24 | + | Some("{\n \"name\": \"{{petName}}\",\n \"legs\": 4,\n \"good\": true\n}".into()); |
|
| 25 | + | c.requests = vec![create]; |
|
| 26 | + | App::new( |
|
| 27 | + | c, |
|
| 28 | + | PathBuf::from("/tmp/cielago-ui-test.json"), |
|
| 29 | + | AppConfig::default(), |
|
| 30 | + | ) |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | fn render(app: &mut App, w: u16, h: u16) -> Buffer { |
|
| 34 | + | let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap(); |
|
| 35 | + | terminal.draw(|f| ui::draw(f, app)).unwrap(); |
|
| 36 | + | terminal.backend().buffer().clone() |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | fn row_text(buf: &Buffer, y: u16) -> String { |
|
| 40 | + | (0..buf.area.width) |
|
| 41 | + | .map(|x| buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" ")) |
|
| 42 | + | .collect() |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | /// Foreground colour of the first cell of `needle` on screen. Rows contain |
|
| 46 | + | /// multi-byte box-drawing characters, so the byte offset is converted to a |
|
| 47 | + | /// column (one character per cell). |
|
| 48 | + | fn fg_of(buf: &Buffer, needle: &str) -> Option<Color> { |
|
| 49 | + | for y in 0..buf.area.height { |
|
| 50 | + | let row = row_text(buf, y); |
|
| 51 | + | if let Some(byte) = row.find(needle) { |
|
| 52 | + | let x = row[..byte].chars().count() as u16; |
|
| 53 | + | return buf.cell((x, y)).map(|c| c.fg); |
|
| 54 | + | } |
|
| 55 | + | } |
|
| 56 | + | panic!("{needle:?} not on screen"); |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | fn screen(buf: &Buffer) -> String { |
|
| 60 | + | (0..buf.area.height) |
|
| 61 | + | .map(|y| row_text(buf, y)) |
|
| 62 | + | .collect::<Vec<_>>() |
|
| 63 | + | .join("\n") |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | #[test] |
|
| 67 | + | fn body_view_is_syntax_highlighted() { |
|
| 68 | + | let mut app = test_app(); |
|
| 69 | + | app.tab = EditorTab::Body; |
|
| 70 | + | let buf = render(&mut app, 100, 40); |
|
| 71 | + | ||
| 72 | + | assert_eq!(fg_of(&buf, "\"name\""), Some(Color::Cyan), "object key"); |
|
| 73 | + | assert_eq!(fg_of(&buf, "4"), Some(Color::Yellow), "number"); |
|
| 74 | + | assert_eq!(fg_of(&buf, "true"), Some(Color::Magenta), "literal"); |
|
| 75 | + | // Variables stand out from the string they sit in. |
|
| 76 | + | assert_eq!(fg_of(&buf, "{{petName}}"), Some(Color::Magenta)); |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | #[test] |
|
| 80 | + | fn body_falls_back_to_the_plain_textarea_while_editing() { |
|
| 81 | + | let mut app = test_app(); |
|
| 82 | + | app.tab = EditorTab::Body; |
|
| 83 | + | app.mode = Mode::Insert; |
|
| 84 | + | let buf = render(&mut app, 100, 40); |
|
| 85 | + | ||
| 86 | + | assert!(screen(&buf).contains("\"name\"")); |
|
| 87 | + | assert_eq!(fg_of(&buf, "\"name\""), Some(Color::Reset)); |
|
| 88 | + | } |
|
| 89 | + | ||
| 90 | + | #[test] |
|
| 91 | + | fn response_view_is_syntax_highlighted() { |
|
| 92 | + | let mut app = test_app(); |
|
| 93 | + | app.response = Some(HttpResponse { |
|
| 94 | + | status: 200, |
|
| 95 | + | reason: "OK".into(), |
|
| 96 | + | elapsed: Duration::from_millis(12), |
|
| 97 | + | headers: vec![("content-type".into(), "application/json".into())], |
|
| 98 | + | body: "{\n \"id\": \"{{notavar}}\",\n \"count\": 7\n}".into(), |
|
| 99 | + | size: 40, |
|
| 100 | + | }); |
|
| 101 | + | let buf = render(&mut app, 100, 40); |
|
| 102 | + | ||
| 103 | + | assert!(screen(&buf).contains("200 OK · 12ms")); |
|
| 104 | + | assert_eq!(fg_of(&buf, "\"id\""), Some(Color::Cyan)); |
|
| 105 | + | assert_eq!(fg_of(&buf, "7"), Some(Color::Yellow)); |
|
| 106 | + | // Braces in a response are the server's bytes, not template syntax. |
|
| 107 | + | assert_eq!(fg_of(&buf, "\"{{notavar}}\""), Some(Color::Green)); |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | #[test] |
|
| 111 | + | fn xml_response_is_syntax_highlighted() { |
|
| 112 | + | let mut app = test_app(); |
|
| 113 | + | app.response = Some(HttpResponse { |
|
| 114 | + | status: 500, |
|
| 115 | + | reason: "Internal Server Error".into(), |
|
| 116 | + | elapsed: Duration::from_millis(3), |
|
| 117 | + | headers: Vec::new(), |
|
| 118 | + | body: "<error code=\"500\">boom</error>".into(), |
|
| 119 | + | size: 30, |
|
| 120 | + | }); |
|
| 121 | + | let buf = render(&mut app, 100, 40); |
|
| 122 | + | ||
| 123 | + | assert_eq!(fg_of(&buf, "<error"), Some(Color::Blue)); |
|
| 124 | + | assert_eq!(fg_of(&buf, "code"), Some(Color::Cyan)); |
|
| 125 | + | assert_eq!(fg_of(&buf, "\"500\""), Some(Color::Green)); |
|
| 126 | + | assert_eq!(fg_of(&buf, "boom"), Some(Color::Reset)); |
|
| 127 | + | } |
|
| 128 | + | ||
| 129 | + | #[test] |
|
| 130 | + | fn docs_tab_shows_types_options_and_defaults() { |
|
| 131 | + | let mut app = test_app(); |
|
| 132 | + | let req = &mut app.collection.requests[0]; |
|
| 133 | + | req.description = Some("Adds a pet to the store.".into()); |
|
| 134 | + | req.docs = vec![ |
|
| 135 | + | FieldDoc { |
|
| 136 | + | name: "status".into(), |
|
| 137 | + | location: "query".into(), |
|
| 138 | + | ty: "string".into(), |
|
| 139 | + | required: true, |
|
| 140 | + | options: vec!["available".into(), "pending".into(), "sold".into()], |
|
| 141 | + | description: Some("Which pets to return.".into()), |
|
| 142 | + | default: Some("available".into()), |
|
| 143 | + | }, |
|
| 144 | + | FieldDoc { |
|
| 145 | + | name: "pets[].tag".into(), |
|
| 146 | + | location: "body".into(), |
|
| 147 | + | ty: "array<string>".into(), |
|
| 148 | + | ..FieldDoc::default() |
|
| 149 | + | }, |
|
| 150 | + | ]; |
|
| 151 | + | app.tab = EditorTab::Docs; |
|
| 152 | + | let buf = render(&mut app, 100, 40); |
|
| 153 | + | let text = screen(&buf); |
|
| 154 | + | ||
| 155 | + | assert!(text.contains("Adds a pet to the store."), "{text}"); |
|
| 156 | + | assert!(text.contains("Query params"), "{text}"); |
|
| 157 | + | assert!(text.contains("status*"), "required marker: {text}"); |
|
| 158 | + | assert!( |
|
| 159 | + | text.contains("one of: available | pending | sold"), |
|
| 160 | + | "enum options: {text}" |
|
| 161 | + | ); |
|
| 162 | + | assert!(text.contains("= available"), "default: {text}"); |
|
| 163 | + | assert!(text.contains("Which pets to return."), "{text}"); |
|
| 164 | + | assert!(text.contains("Body"), "{text}"); |
|
| 165 | + | assert!(text.contains("pets[].tag"), "{text}"); |
|
| 166 | + | assert_eq!(fg_of(&buf, "array<string>"), Some(Color::Yellow)); |
|
| 167 | + | } |
|
| 168 | + | ||
| 169 | + | #[test] |
|
| 170 | + | fn docs_tab_explains_itself_when_there_is_nothing_to_show() { |
|
| 171 | + | let mut app = test_app(); |
|
| 172 | + | app.tab = EditorTab::Docs; |
|
| 173 | + | let text = screen(&render(&mut app, 100, 40)); |
|
| 174 | + | assert!(text.contains("No spec docs for this request"), "{text}"); |
|
| 175 | + | } |
|
| 176 | + | ||
| 177 | + | #[test] |
|
| 178 | + | fn renders_without_panicking_in_edge_cases() { |
|
| 179 | + | // Tiny terminal, empty body, long body scrolled to the bottom, help popup. |
|
| 180 | + | let mut app = test_app(); |
|
| 181 | + | app.tab = EditorTab::Body; |
|
| 182 | + | render(&mut app, 20, 8); |
|
| 183 | + | ||
| 184 | + | app.set_textarea_text(""); |
|
| 185 | + | render(&mut app, 100, 40); |
|
| 186 | + | ||
| 187 | + | app.set_textarea_text(&(1..=200).map(|i| format!("[{i}]\n")).collect::<String>()); |
|
| 188 | + | app.textarea.move_cursor(tui_textarea::CursorMove::Bottom); |
|
| 189 | + | render(&mut app, 100, 40); |
|
| 190 | + | ||
| 191 | + | app.tab = EditorTab::Docs; |
|
| 192 | + | app.docs_scroll = usize::MAX / 2; |
|
| 193 | + | render(&mut app, 100, 40); |
|
| 194 | + | render(&mut app, 20, 8); |
|
| 195 | + | ||
| 196 | + | app.popup = cielago::app::Popup::Help; |
|
| 197 | + | app.help_scroll = usize::MAX / 2; |
|
| 198 | + | render(&mut app, 100, 40); |
|
| 199 | + | render(&mut app, 30, 10); |
|
| 200 | + | ||
| 201 | + | // A collection with no requests at all: the sidebar hands ratatui a |
|
| 202 | + | // selected index on a zero-row list, and there is nothing to draw in the |
|
| 203 | + | // URL bar or editor. |
|
| 204 | + | let mut empty = App::new( |
|
| 205 | + | Collection::new("empty"), |
|
| 206 | + | PathBuf::from("/tmp/cielago-ui-empty.json"), |
|
| 207 | + | AppConfig::default(), |
|
| 208 | + | ); |
|
| 209 | + | render(&mut empty, 100, 40); |
|
| 210 | + | render(&mut empty, 20, 8); |
|
| 211 | + | } |
|
| 212 | + | ||
| 213 | + | #[test] |
|
| 214 | + | fn url_edit_prompt_shows_in_the_status_bar() { |
|
| 215 | + | let mut app = test_app(); |
|
| 216 | + | app.start_edit(cielago::app::EditTarget::Url); |
|
| 217 | + | let buf = render(&mut app, 100, 40); |
|
| 218 | + | assert!(screen(&buf).contains("url> /pets")); |
|
| 219 | + | } |
|
| 220 | + | ||
| 221 | + | #[test] |
|
| 222 | + | fn help_lists_duplicate_and_new_collection() { |
|
| 223 | + | let mut app = test_app(); |
|
| 224 | + | app.popup = cielago::app::Popup::Help; |
|
| 225 | + | let top = screen(&render(&mut app, 100, 60)); |
|
| 226 | + | assert!(top.contains("duplicate")); |
|
| 227 | + | assert!(top.contains("edit URL")); |
|
| 228 | + | ||
| 229 | + | // The commands live past the fold, so scroll to the bottom for those. |
|
| 230 | + | app.help_scroll = usize::MAX / 2; |
|
| 231 | + | let bottom = screen(&render(&mut app, 100, 60)); |
|
| 232 | + | assert!(bottom.contains(":new")); |
|
| 233 | + | assert!(bottom.contains(":open")); |
|
| 234 | + | } |