| 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+AuthKind (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 | ├── secret.rs # $(cmd) secret resolution via `sh -c` |
| 27 | ├── send.rs # send_with_auth: pick scheme; oauth cache/refresh + 401 retry |
| 28 | ├── url_input.rs # pasted URL -> origin / path / query (inverse of build_url) |
| 29 | └── vars.rs # {{name}} + dynamic ({{uuid}}, {{randomInt}}…) substitution |
| 30 | ``` |
| 31 | |
| 32 | `tests/` has fixture-driven integration tests: `import_tests.rs` (spec → |
| 33 | collection), `http_tests.rs`/`app_send_tests.rs` (wiremock-backed HTTP + |
| 34 | OAuth), `input_tests.rs` (full keymap flows over an in-memory `App`), |
| 35 | `ui_tests.rs` (draws into a ratatui `TestBackend` and asserts on cell colours |
| 36 | — the only place rendering is covered). |
| 37 | |
| 38 | ## Design decisions worth knowing |
| 39 | |
| 40 | - **The project was renamed `getman` → `stableman` → `manpost` → `cielago`.** |
| 41 | `store::config_dir` moves a leftover `~/.config/{manpost,stableman,getman}` |
| 42 | onto `~/.config/cielago` on first use (see `LEGACY_DIR_NAMES`), so existing |
| 43 | collections survive. Drop those migrations once they've had time to run |
| 44 | everywhere. |
| 45 | - **No remote `$ref`s.** `openapi::resolve` only follows local |
| 46 | `#/components/...` JSON pointers. Specs that split across files aren't |
| 47 | supported — bundle them first if you hit this. |
| 48 | - **Auth is one struct, three schemes.** `OAuthConfig` (aliased `AuthConfig`) |
| 49 | carries every scheme's fields, discriminated by `AuthKind` (`bearer` / |
| 50 | `apikey` / `oauth2`). It stays a single flat JSON object so collections |
| 51 | written before bearer/api-key support — whose `auth` has no `kind` — still |
| 52 | deserialize (missing `kind` defaults to `oauth2`, which is what they were). |
| 53 | The popup (`A`) builds its rows from `App::auth_fields` per kind; the first |
| 54 | row is always the kind toggle. `send::send_with_auth` branches on the kind: |
| 55 | bearer/apikey resolve their secret and send (no token cache, no 401 retry), |
| 56 | oauth2 keeps the cache-and-retry path. |
| 57 | - **Secrets are plaintext, but can be indirected.** Secret fields |
| 58 | (`token`, `client_secret`) are saved as-is in the collection JSON (explicit |
| 59 | user choice). To avoid that, a field may hold a single `$(…)` command |
| 60 | substitution — `http::resolve_secret` runs it through `sh -c` at send time |
| 61 | and uses the trimmed stdout. Only a value that is *entirely* `$(…)` is |
| 62 | executed, never one embedded in a longer string. The in-memory `OAuthToken` |
| 63 | is never persisted. |
| 64 | - **Tags become sidebar groups**, first tag only; untagged requests land in |
| 65 | a `default` group. This wasn't asked for explicitly but was cheap and |
| 66 | matches how most specs are organized. |
| 67 | - **`Method::parse`, not `FromStr`** — deliberately not the trait, to dodge |
| 68 | a clippy lint; nothing else depends on `FromStr`. |
| 69 | - **Vim modes are `Normal` / `Insert` / `Command` / `Search`** — no Visual |
| 70 | mode. Insert mode is reused for both single-line field edits (`LineEdit`) |
| 71 | and the body `TextArea`; `app.editing` discriminates which. `Search` is the |
| 72 | `/` sidebar filter: it re-applies on every keystroke, and `app.filter` (the |
| 73 | committed query) is deliberately separate from `app.search` (the live |
| 74 | prompt buffer) so `Esc` can drop the prompt without touching the filter. |
| 75 | - **Sidebar labels are a view concern.** `SavedRequest` keeps `name` (user |
| 76 | editable), `summary` and `operation_id` (verbatim from the spec); |
| 77 | `Collection.label_mode` picks which one renders. `:rename-all` is the only |
| 78 | thing that overwrites `name`. Import prefers `summary` over `operationId` |
| 79 | for the initial name — most real specs put a generated controller method |
| 80 | name in `operationId`. |
| 81 | - **Switching collections reassigns the whole `App`** (`switch_collection` does |
| 82 | `*self = App::new(...)`). Everything view-related is derived from the |
| 83 | collection, so there's nothing to migrate by hand — and dropping the mpsc |
| 84 | channel and cached `OAuthToken` is a feature, not collateral: a response still |
| 85 | in flight for the previous collection can no longer land in the new one, and |
| 86 | the token belonged to the old `auth` config. It deliberately does *not* go |
| 87 | through a `pending_*` field like `run_external_edit` does; that indirection |
| 88 | only exists because the editor needs the `&mut Terminal` to suspend raw mode, |
| 89 | and routing a switch through the run loop would put it out of reach of |
| 90 | `input_tests.rs`. |
| 91 | - **Pasting a URL rewrites collection state.** `App::apply_url_input` + |
| 92 | `http::url_input` split an absolute URL into origin / path / query: the origin |
| 93 | is added to `servers` (deduped on `trim_end_matches('/')`, since `E`-added |
| 94 | servers may carry a trailing slash) **and made active**, because the URL bar |
| 95 | renders `base_url() + path` and would otherwise show something other than what |
| 96 | was just pasted. Query rows are replaced only when the input actually contained |
| 97 | a `?` — otherwise fixing a typo'd path would silently wipe the disabled |
| 98 | optional params an import set up. Two traps the module exists to handle: |
| 99 | `Url::parse("localhost:8080/x")` *succeeds* with scheme `localhost` (hence the |
| 100 | http/https + host check), and `{`/`}` are in the crate's path encode set, so |
| 101 | `/pets/{id}` comes back as `/pets/%7Bid%7D` and needs `restore_braces`. |
| 102 | - **`path_params` follows the path.** `SavedRequest::sync_path_params` derives |
| 103 | the rows from `{placeholders}` in `path`, pruning ones that no longer appear — |
| 104 | `build_url` ignores those anyway, so a stale row only makes the Params tab |
| 105 | lie. `{{variables}}` are skipped by the scanner. Import does *not* call it: |
| 106 | spec-declared rows are authoritative there. |
| 107 | - **`$EDITOR` integration** shells out synchronously, suspending raw mode |
| 108 | around it (`app::run_external_edit`). It writes/reads a temp file rather |
| 109 | than piping, so it works with any editor. |
| 110 | - **Highlighting is hand-rolled** (`highlight.rs`), line-oriented, and never |
| 111 | drops input: every character comes back out in some span (there's a test). |
| 112 | A `syntect`-class dependency would be larger than the rest of the binary, |
| 113 | and JSON/XML/plain is all a request client shows. |
| 114 | - **The body has two renderers.** `tui-textarea` styles whole lines only, so |
| 115 | the Body tab renders a highlighted `Paragraph` in Normal mode and the raw |
| 116 | `TextArea` in Insert mode. The textarea stays the source of truth either |
| 117 | way; the read-only view scrolls by moving *its* cursor, which is why `j`/`k` |
| 118 | on the Body tab drive `CursorMove`. |
| 119 | - **Dynamic variables live in the `{{…}}` namespace**, not a second syntax: |
| 120 | `{{uuid}}`, `{{randomInt(1,10)}}`, `{{isoTimestamp}}`. A collection variable |
| 121 | shadows a dynamic one of the same name (`{{$name}}` forces the dynamic one), |
| 122 | so a fixed `uuid` can be pinned for debugging. Randomness comes from UUID v4 |
| 123 | bytes and the RFC 3339 formatter is hand-written — both to avoid adding |
| 124 | `rand`/`chrono` for a handful of values. |
| 125 | - **Collections carry a saved view.** `last_request` (request id), |
| 126 | `last_focus` (pane `1`/`2`/`3`) and `last_tab` are written by |
| 127 | `App::record_view` when `:w` runs, and replayed by `App::new` — it reopens |
| 128 | the request (expanding its group if `groups_collapsed` would hide it), then |
| 129 | restores the pane and tab. Two deliberate wrinkles: the view is recorded |
| 130 | *during* `save` rather than by `select_request`, because marking the |
| 131 | collection dirty just for moving the sidebar cursor would make `:q` nag |
| 132 | after a read-only browse (the trade: navigate away, quit clean, and the old |
| 133 | position stays); and a saved `Response` focus falls back to the editor, |
| 134 | since responses aren't persisted and pane 3 is empty on open. |
| 135 | `Focus`/`EditorTab` stay in `app.rs` and gained serde derives, so `model.rs` |
| 136 | reaches back into `app` for those two types. |
| 137 | - **CLI names resolve by slug** (`store::match_name`): the file is named after |
| 138 | `slugify(name)` anyway, so `delete "some api"` and `delete some-api` both hit |
| 139 | `Some API`. Exact name wins first. `store::resolve_collection` is the entry |
| 140 | point and produces the "Available: …" error every name-taking command shares. |
| 141 | - **`cielago edit` edits a temp copy, not the file.** It only writes back after |
| 142 | the edited text parses as a `Collection`; on a parse error the temp file is |
| 143 | left in place and its path printed, so a botched edit is recoverable. A `name` |
| 144 | changed in the editor is a rename (file moves, `config.last_collection` |
| 145 | follows), and it bails rather than overwriting a different collection whose |
| 146 | name slugifies the same. |
| 147 | - **`FieldDoc`s are stored on the request**, not read from the spec on demand: |
| 148 | a collection's `spec_source` is often a URL that has moved or needs auth by |
| 149 | the time the collection is opened. Cost is a re-import to refresh them, and |
| 150 | older collections having none. |
| 151 | |
| 152 | ## Before committing |
| 153 | |
| 154 | ```sh |
| 155 | cargo fmt |
| 156 | cargo clippy --all-targets # keep this clean, no warnings |
| 157 | cargo test |
| 158 | ``` |
| 159 | |
| 160 | ## Known gaps (intentionally out of scope for v1) |
| 161 | |
| 162 | Swagger 2.0, interactive OAuth flows (auth-code / device / implicit — only |
| 163 | client-credentials is automated; bearer and API-key are static), |
| 164 | collection folders beyond tag grouping, request history/response diffing. |
| 165 | The Docs tab covers request inputs only — response schemas and status codes |
| 166 | aren't imported. |