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