ichigo 0.3.0

A CLI HTTP client — store named request configs in your project or globally, then run them by name or through the TUI
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Use available skills (via the Skill tool) when they are relevant — for example, `/code-review` when reviewing changes, `/verify` when confirming a fix works, `/run` when launching the app, and `/security-review` for security-sensitive changes.

## Commands

```sh
cargo build                   # debug build
cargo build --release         # release build
cargo run -- <subcommand>     # run with args, e.g. `cargo run -- list`
cargo test                    # run tests
cargo clippy                  # lint
```

The binary is `target/debug/ichigo` (or `target/release/ichigo`).

Releases are built with [cargo-dist](https://github.com/axodotdev/cargo-dist) via `dist-workspace.toml` and the GitHub Actions workflow at `.github/workflows/release.yml`.

## Architecture

The project is a single-binary Rust CLI. Four top-level source files plus the `src/tui/` module:

**`src/main.rs`** — CLI entry point. Defines the `Cli` / `Commands` enum (via clap derive), dispatches to command functions (`cmd_new`, `cmd_run`, etc.), and embeds shell completion scripts as `const` strings. Chain detection is done by a simple `content.contains("steps:")` string check — there is no separate file format; a config is a chain if and only if it has a `steps:` key.

**`src/config.rs`** — All config types and file I/O. Two top-level config shapes: `RequestConfig` (single request) and `ChainConfig` (contains `Vec<RequestConfig>` as steps). Also defines `Body`, `Profile`, and `RequestEntry`. Key path logic:
- Local: `.ichigo/<name>.yaml`
- Global: `~/.config/ichigo/<name>.yaml`
- `resolve_config_path` checks local first, then global — local always wins.
- Names support `/`-delimited subfolders (e.g. `folder/request`), stored as subdirectories inside `.ichigo/`.

The same module's **user config** section is a different thing from a request config: `~/.config/ichigo/config.toml`, read once at launch by `Keys::load`. It is **TOML while requests are YAML, and that is load-bearing** — the two share a directory, and the differing extension is the only thing keeping them apart. `collect_yaml_entries` filters on `.yaml`, so it skips `config.toml` without being told to, and a request legitimately named `config` writes `config.yaml` beside it rather than over it. Teaching the request loader to read `.toml` would break both at once, which is why `USER_CONFIG_FILE` says so. An earlier `settings.yaml` needed a skip in the loader *and* a reserved-name refusal in `save_new_request` to get the same safety; the extension replaced both. Two structs on purpose: `UserConfigFile` is the file as written (every field optional so adding one never invalidates an existing file, `deny_unknown_fields` so a misspelled key is refused rather than ignored forever), and `Keys` is the validated form the TUI holds. `Keys` is `Copy` because the form handlers need it while holding a `&mut` borrow of `App::mode` — copying it out first is what makes that split borrow legal. There is no project-local override: a keymap is per-user, not per-project.

**YAML parsing goes through `serde_yaml_ng`**, a maintained fork of `serde_yaml`, which was archived upstream and last published as `0.9.34+deprecated`. It is API-compatible, so the swap was the dependency and the module paths and nothing else. Do not "simplify" back to `serde_yaml`.

**`src/utils.rs`** — `send_request()` builds a blocking `reqwest` client and fires the HTTP request. `interpolate()` does `{{VAR}}` substitution: it checks the provided `vars` map first, then falls back to environment variables, leaving unresolved placeholders as-is (`{{VAR}}`).

**`src/curl.rs`** — Converts between a `RequestConfig` and a cURL command in both directions. Pure string work, no IO, unit tested.

`to_curl()` renders a config + resolved vars as a paste-ready command. It takes the same two inputs as `send_request` and **must stay in step with it** — a command that disagrees with what ichigo sends is worse than none. Substitution is shared via `utils::interpolate`; the duplicated rules (and the real drift risk) are query folding into the URL and the body's `Content-Type` header. Values are single-quoted unconditionally, with `'` escaped as `'\''`.

`from_curl()` is its inverse: it tokenizes with shell quoting rules (`'…'`, `"…"`, `$'…'`, `\`-continuations) and maps flags onto config fields. Two rules make the pair agree, and both are normalizations rather than transcription: a `Content-Type` header on a command with a body is stored as `body.content_type` and *never* in `headers` (`to_curl` re-derives it, so a config holding both emits the header twice), and a query string is lifted off the URL into `query` unless a key repeats or the URL carries `{{VAR}}`. The flag table is **deny-by-default** — an unrecognized flag is an error naming it, because a parser that skips what it does not understand turns `curl -F file=@x URL` into a bodyless GET and the user finds out against a real server. The round-trip tests at the bottom of the file are what keep the two directions honest; that is why both live in one module.

**`src/runner.rs`** — Executes a single `RequestConfig` (or one step of a chain). Handles profile variable injection, prints the response (status + body), and extracts values from the JSON response using dot-notation paths (e.g. `$.token` → `token`). Returns extracted variables so the caller can pass them to the next chain step.

**`src/tester.rs`** — Runs a request N times sequentially (blocking), collects per-iteration timings and status code counts, then renders an ASCII bar chart (status distribution) and ASCII line graph (latency over time).

**`src/tui/`** — The ratatui TUI, split across five files:
- `mod.rs` — `App` state, the `Mode` enum, the action paths (`try_run_selected`, `execute_request`, …), and the event loop.
- `tree.rs` — reading configs off disk (`load_entry` / `load_entries`) and the folder-tree model.
- `handlers.rs` — one key handler per mode.
- `render.rs` — all drawing.
- `edit.rs` — the caret and vim motions inside a single form field.

`App` holds the list of entries and a `Mode` enum that drives all rendering and input. Modes are:
- `Browse` → main list + detail pane
- `ProfileSelect` → pick a profile before running
- `VarInput` → fill in `{{VAR}}` placeholders before running
- `TestInput` → fill vars + iteration count before a load test
- `Response` / `TestResponse` → show results; `f` filters lines, `V`/`y` copy a line range
- `NewRequest` / `NewProfile` → create/edit requests and their profiles in-TUI
- `ProfileList` → the draft's profiles; pick one to edit, add, or delete
- `EditHeaders` → the draft's headers as name/value pairs
- `ImportCurl` → paste buffer for importing a cURL command
- `ConfirmDelete` → confirm before deleting

**Text fields.** `edit::apply` is the only place a form field's characters are typed, deleted, or moved through, and `edit::Edit` (a byte caret plus an `insert` flag) is the only record of where the caret is. Fields stay plain `String`s: each *mode* carries one `Edit` beside its focus index, because only the focused field can be reached by a keystroke. That is also the invariant most easily broken — **every** place that changes `focused` must re-anchor the caret (`RequestDraft::focus`, `caret_for`, `Edit::at_end`), or Tab carries an offset from a long field into a shorter one. `apply` clamps the caret against the value on the way in rather than trusting it, so the cases nothing re-anchors — a draft that toured `EditHeaders` and came back, a header row deleted at the focused index — cost a cosmetic keystroke instead of panicking on the next slice. `Mode::var_input` / `test_input` / `edit_headers` / `new_profile` exist for the same reason: a form can open on a value prefilled from a profile, the environment, or disk, and building the variant by hand left the caret at 0 wherever the anchoring was forgotten.

Fields open in **insert** mode; `Esc` drops to **normal** mode, and only a second `Esc` leaves the pane. That is what makes `h`/`l`/`w`/`b` possible at all — in insert mode those are letters someone is typing into a URL — and it is why `Esc` is no longer a match arm in the form handlers: it reaches `edit::apply`, which reports `Applied::Exit` only from normal mode. Arrows and Home/End work in both modes, and Backspace deletes in both (vim moves left there; backspace-deletes is the one habit every text field shares, and normal mode already has `x`). There is deliberately **no hint** for any of this: the mode shows in the caret's shape, a thin bar for insert and a block over the character for normal, exactly as a terminal cursor would. `draw_response`'s own `j`/`k`/`V`/`y` bindings are a separate thing — a response pane is not a text field — as are the two incremental filters, where every keystroke re-filters and a motion key would have nothing to move.

`edit::apply` also owns the refusal of `Ctrl+<letter>`, which is what keeps a handler's `_` arm from typing the letter of an unbound chord.

**Undo.** `u` in normal mode pops `Edit::history`, a stack of `Snapshot { value, caret }`. The unit is a *change*, not a keystroke: `Edit::record` is called at each mutation site, and in insert mode it records only once per session (`session_saved`), so everything typed between `i` and `Esc` collapses into one `u`. `s`/`C`/`S` both edit and open a session, so they record their edit and then set `session_saved` to fold the typing that follows into it. Every `record` sits *inside* the guard that checks the mutation will actually happen — a Backspace at position 0 or an `x` past the end must not push an entry that restores identical text, which would read as a broken `u`. This is also why the history is **not** seeded at construction: `Edit::at_end`'s argument would have to be the field's true current value at every call site, and `Edit::default()` (used for a freshly added header or param row) has none to give — a wrong seed would make `u` blank the field rather than merely misbehave. Recording at the point of mutation is the only place the current text is known for certain.

Two consequences worth keeping: `Edit` is **no longer `Copy`**, because the history is a `Vec` — the renderer takes `&Edit` (it only ever reads `caret` and `insert`), and every other holder already worked through `&mut`. And the history dies with the `Edit`, so undo is per-field and a focus change clears it; `MAX_UNDO` only bounds a long session inside one field. There is no redo stack.

**The insert-escape sequence.** `keys.insert_escape` in `config.toml` is vim's `inoremap jk <Esc>`: a two-key sequence that leaves insert mode. Exactly two, because the pending first key is a single `char` in `Edit` — a longer sequence would need a `String` there and `Edit` would stop being `Copy`, which is what lets it live inside a `Mode` and be handed around by value. The first key is inserted into the field like any other character and *un-typed* when the second completes the sequence; `completes_escape` re-checks the character before the caret against the pending one rather than trusting the flag, so a mismatch degrades into a plain insert instead of deleting whatever is there. `apply` does `edit.pending.take()` unconditionally on the way in, which is what makes every other key disarm the sequence — miss that and a `j`, three cursor moves, and a `k` would escape. `insert_str` clears it too. The one-second `SEQUENCE_TIMEOUT` is vim's `timeoutlen` and is checked against an `Instant` when the second key arrives, so it needs no tick in the event loop; it rescues a pause between the keys, not a fast literal `jk`.

**Headers.** `h` from Browse and `Ctrl+e` from the request form open `EditHeaders` (`Ctrl+h` is kept as an alias but must never be the only binding — many terminals and tmux configs bind Ctrl+H to backward-delete-char and send `0x7F`, which arrives as a plain Backspace and silently deletes a character; a plain key in Browse is the one binding nothing can intercept). `apply_headers` folds the rows back into the draft and is where the two rules live. Duplicate names are refused **case-insensitively**, because HTTP does not distinguish case and a `HashMap` would silently keep whichever row landed last. A `Content-Type` row is moved into `body.content_type` when the draft has a body and never left in `headers` — the same normalization `from_curl` performs, for the same reason: `to_curl` re-derives that header from the body, so a config holding both emits it twice. Note that `Ctrl+<letter>` reaches a handler as `KeyCode::Char` **plus** a CONTROL modifier, so text-entry arms must exclude it — before `Ctrl+h` existed, pressing it typed a literal `h` into the focused field. Form fields are safe because `edit::apply` refuses modified keys centrally; the two filter handlers still match bare `Char(c)` and still have that bug.

**Response lines.** `visible_response_lines` is the single definition of which lines the filter leaves showing, and `draw_response`, `G`, cursor movement, and both copy paths all read it. It exists because those had already drifted: the predicate was spelled out separately at each site and the copy path never got one, so filtering to two lines and pressing `c` handed over the whole body. `cursor` and `anchor` index *that* list, not `body.lines()`, which is why every edit to the filter resets them — carrying them over leaves the cursor on an unrelated line and a selection spanning lines the user never saw. The pane scrolls to follow the cursor rather than the reverse, so a selection cannot be extended past the edge of the view. `Esc` drops a selection before it leaves the pane, and the selection highlight is a base `Line::style` so `colorize_json_line`'s colours survive it.

**Profiles.** `ProfileList` is the only door into `NewProfile` — `p` from Browse and `Ctrl+p` from the request form both land there, so add/edit/delete all start from one screen. Its rows are `0..profiles.len()` plus a trailing "new" row, which is why an empty list opens with "new" already selected. `NewProfile` carries `editing: Option<usize>`, and `upsert_profile` uses it to replace rather than push; without it, editing a profile and keeping its name appended a second profile under that name and the picker could only ever reach the first. That index also makes a name clash decidable — a profile keeping its own name is not a clash, a profile taking a sibling's is, and the second is refused because a duplicate is unreachable from both the picker and `--profile`. Edits mutate only the draft; `save_new_request` remains the single path that writes a config, so Esc out of the form discards profile changes too.

**The help overlay.** `App::show_help` is a `bool`, not a `Mode` variant, because the `?` keymap draws *over* the current pane and dismisses back to it — as a mode it would have to record which of the ten it interrupted. `draw` renders it last so it lands on top of both panes and the hint line, and `handle_key` checks it first, swallowing the dismissing key: letting it through would make `d` close the overlay *and* open the delete confirmation behind it. `?` only opens from Browse, since every other mode is either a text field where `?` is a literal character or a pane whose own hints already fit. The Browse hint line is deliberately short (6 entries) — it once listed all fifteen bindings at 155 columns, which an 80-column terminal truncated silently, hiding the last five. Keep new bindings out of it and in `HELP_COLUMNS`.

Variable placeholder names are extracted by `extract_var_names` (scans url, headers, query, body for `{{...}}`) to build the `VarInput` field list. The TUI clipboard copy (`c` key) uses `pbcopy` and is macOS-only.

**`RequestDraft`.** `NewRequest` and `NewProfile` both hold a `RequestDraft` — the four editable fields plus the headers, query, body, and extract the form does *not* edit. Those are carried in the draft rather than re-read from disk at save time, because an imported or cloned request has no file to re-read; recovering them from disk silently dropped them (which is why `c` used to clone only a request's method and URL). Build a draft through `RequestDraft::blank()` or `from_config()`; `edit_selected` / `clone_selected` / `confirm_import_curl` are the three entry points.

**Pasting.** The event loop enables bracketed paste (`EnableBracketedPaste` on entry, `DisableBracketedPaste` on exit — skip the latter and the terminal keeps emitting paste markers) and handles `Event::Paste`. `handle_paste` has to serve every text field, not just the import buffer — miss one and pasting into it is a silent no-op, which is exactly what `EditHeaders` did until it got an arm. It lands text at the caret via `edit::insert_str`, so a paste behaves like typing rather than always appending. Enter in that pane inserts a newline and `Ctrl+s` confirms, precisely because a terminal *without* bracketed paste delivers a multi-line paste as characters with Enters between the lines; binding Enter to confirm would parse only the first line.

**Pending actions.** `ProfileSelect` and `VarInput` are shared by every action that needs a profile or variables, so each carries a `PendingAction` (`Run` / `Test` / `Curl`) naming its destination. Both `confirm_profile_select` and `handle_key_var_input`'s Enter dispatch on it. A new action must set it at *every* construction site of both modes — miss the `VarInput` one and the action silently falls through to running the request, because that is where the pipeline used to be hardcoded. `Mode::Response` likewise carries a `ResponseKind` (`Http(u16)` / `Error` / `Curl`) instead of encoding "not a response" as status `0`; build it through `App::show_message` / `show_error` rather than spelling out the variant.

**Config freshness.** The TUI is meant to stay open for long sessions, so no action may rely on the entry snapshot taken at startup. `App::entries` is a display cache only. Every action path (`try_run_selected`, `try_test_selected`, `confirm_profile_select`, `start_chain`) re-reads the config through `tree::load_entry` before deriving profiles or `{{VAR}}` names — those feed the vars map, and `interpolate` prefers that map over everything else, so a stale value there silently wins over a correct one in the file. On a load failure the action aborts via `App::show_error`; it must never fall back to the cached entry. `R` in Browse mode calls `reload_entries` for a full resync (picks up files added/renamed/deleted on disk); `r` is run. Environment-sourced `{{VAR}}` values cannot be refreshed — the process env is fixed at launch.