feat: init 1afa378f
Steve Simkins · 2026-08-07 18:36 35 file(s) · +10821 −0
.github/workflows/release.yml (added) +343 −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
.gitignore (added) +1 −0
1 +
/target
AGENTS.md (added) +151 −0
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.
Cargo.lock (added) +2441 −0
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"
Cargo.toml (added) +38 −0
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"
LICENSE (added) +22 −0
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 +
README.md (added) +83 −0
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)
dist-workspace.toml (added) +21 −0
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
src/app.rs (added) +1364 −0
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 +
}
src/highlight.rs (added) +342 −0
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 &amp; 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 +
}
src/http/client.rs (added) +172 −0
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 +
}
src/http/mod.rs (added) +11 −0
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};
src/http/oauth.rs (added) +69 −0
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 +
}
src/http/send.rs (added) +65 −0
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 +
}
src/http/url_input.rs (added) +218 −0
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 +
}
src/http/vars.rs (added) +356 −0
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 +
}
src/input.rs (added) +464 −0
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 +
}
src/lib.rs (added) +8 −0
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;
src/main.rs (added) +405 −0
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 +
}
src/model.rs (added) +439 −0
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 +
}
src/openapi/docs.rs (added) +377 −0
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 +
}
src/openapi/examples.rs (added) +180 −0
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 +
}
src/openapi/import.rs (added) +369 −0
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 +
}
src/openapi/loader.rs (added) +53 −0
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 +
}
src/openapi/mod.rs (added) +13 −0
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};
src/openapi/resolve.rs (added) +67 −0
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 +
}
src/store.rs (added) +235 −0
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 +
}
src/ui.rs (added) +722 −0
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 +
}
tests/app_send_tests.rs (added) +125 −0
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 +
}
tests/fixtures/api31.json (added) +26 −0
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 +
}
tests/fixtures/petstore30.yaml (added) +115 −0
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
tests/http_tests.rs (added) +223 −0
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 +
}
tests/import_tests.rs (added) +249 −0
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 +
}
tests/input_tests.rs (added) +820 −0
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 +
}
tests/ui_tests.rs (added) +234 −0
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 +
}