# Architecture

`fj` is a thin Rust CLI over Forgejo's `/api/v1`. The whole binary is
~10k LOC and shapes itself like `gh`: clap-parsed subcommands, one
typed wrapper per API resource, one HTTP client funnel for everything.

## Module graph

```
              ┌─────────────┐
              │   main.rs   │  alias expansion + fj-<x> plugin dispatch
              └──────┬──────┘
                     │
              ┌──────▼──────┐
              │  cli::run   │  Ctrl+C race + pager guard
              └──────┬──────┘
                     │
       ┌─────────────┼────────────────────┐
       │             │                    │
  ┌────▼─────┐ ┌─────▼─────┐       ┌──────▼──────┐
  │ cli::auth│ │ cli::repo │  ...  │  cli::api   │  raw escape hatch
  └────┬─────┘ └─────┬─────┘       └──────┬──────┘
       │             │                    │
       │       ┌─────▼─────┐              │
       │       │ context:: │  -R or git-remote autodetect
       │       │resolve_repo│
       │       └─────┬─────┘              │
       │             │                    │
       │       ┌─────▼─────┐              │
       └──────►│  api::*   │◄─────────────┘
               │  typed    │
               │ wrappers  │
               └─────┬─────┘
                     │
               ┌─────▼─────┐
               │  client   │  one funnel: auth headers, retry, pagination,
               │           │  debug logging, custom headers
               └─────┬─────┘
                     │
               ┌─────▼─────┐
               │  reqwest  │  rustls-tls, JSON, multipart
               └───────────┘
```

## Layering rules

- `cli::*` handles flags, argument validation, output formatting. It
  never makes HTTP calls directly: it calls `api::*`.
- `api::*` is one file per Forgejo resource. Each function takes a
  `&Client` and returns typed `Result<T>`.
- `client::*` is the only place that touches `reqwest`. All HTTP goes
  through `Client::request_with_headers`, which handles auth headers,
  retries, debug logging, and custom header merging. There is no
  parallel HTTP path (other than the multipart asset upload in
  `api::release::upload_asset`, which goes through `client.http()` but
  still uses `client.token()` for auth).
- `config::*` is the on-disk shape of `hosts.toml`. Tokens are NOT
  stored here. They live in the OS keychain via `auth::*`.
- `git::*` shells out to the `git` binary. The only place we call git.

## Repo resolution

Every command that operates on a specific repo calls
`cli::context::resolve_repo(explicit_repo, explicit_host)`. The
algorithm:

1. If `-R/--repo` was given, parse it as `owner/name`.
2. Otherwise call `git remote -v` and pick the first remote that parses
   as a Forgejo URL. Preference order is `upstream`, then `origin`,
   then any other.
3. The host comes from `--host`, then the detected remote's host, then
   the configured default host in `hosts.toml`.

This is the magic that lets `fj pr list` work without any flags inside
a clone.

## HTTP retry

In `client::request_with_headers`:

- The request is built once and stored as `reqwest::Request`. Each retry
  uses `Request::try_clone`.
- Idempotent methods (GET, HEAD, OPTIONS, PUT, DELETE) get 3 attempts.
- POST and PATCH get 1 attempt; never retried.
- Backoff is `200ms * 2^attempt` between retries.
- On 5xx or transport error, we retry.
- On 429 we honor the `Retry-After` header (capped at 30 s) and fall
  back to exponential backoff if the header is absent or non-numeric.

## Auto-pagination

`Client::get_all(path, query, total_limit)` follows `Link: rel=next`
until either `total_limit` items have accumulated or there are no more
pages. List commands switch to this path automatically when the user
asks for `--limit > 50` (Forgejo's per-page cap).

## Pager

When `cli::run` dispatches a command whose output can run long (list,
view, diff, search, status, api), it calls
`output::pager::maybe_start(force_disabled)`. That function:

1. Bails if stdout isn't a TTY or `--no-pager` / `FJ_NO_PAGER` is set.
2. Spawns `$FJ_PAGER` / `$PAGER` / `less -FRX`.
3. `dup`s our stdout fd (saving it for restore) then `dup2`s the pager
   child's stdin fd onto stdout (fd 1).
4. Returns a `PagerGuard` whose `Drop` impl flushes stdout, restores
   the saved fd, closes the saved fd, and waits on the child.

This means the rest of `fj` keeps using `println!`/`print!` unmodified
even though it's now writing to the pager.

## SIGINT

`cli::run` `tokio::select!`s the command future against
`tokio::signal::ctrl_c()`. On SIGINT the command future is dropped,
which propagates to the `_pager` binding's `Drop` and tears down the
pager cleanly.

## Aliases and extensions

`main.rs` runs two passes before clap:

1. **Alias expansion** via `cli::alias::expand_argv`. Reads
   `aliases.toml`. If `argv[1]` matches a defined alias, replaces it
   with the tokenized expansion.
2. **Plugin dispatch** via `main::detect_plugin`. If `argv[1]` isn't a
   known subcommand AND `fj-<name>` is on PATH, exec it.

Only if neither pass triggers do we hand off to clap.

## JSON projection

A global `--json-fields foo,bar` flag works on top of any `--json`
output. `cli::run` reads it once and stores the parsed list in a
`OnceLock` in `output::mod.rs`. Every `output::print_json` call routes
through `json_filter::project` before printing, so individual commands
need no awareness of the filter.

Supports dotted paths (`owner.login`). Missing fields project to `null`
rather than failing, matching gh.

## Test strategy

- **Unit tests** stay inline next to the code they cover. Pure
  functions only (no I/O), with two exceptions marked `#[ignore]`:
  `editor_command_uses_visual_first` and
  `edit_text_with_true_returns_initial` mutate `$EDITOR` / `$VISUAL`,
  which races with the cargo test harness on macOS. Run them with
  `cargo test -- --ignored --test-threads=1`.
- **Integration tests** for HTTP behavior live in
  `src/client/integration_tests.rs` and use a wiremock server.
  Construct test clients via `Client::for_base_url`.
- **Live E2E** lives in `scripts/e2e-smoke.sh`, gated on `FJ_E2E=1`
  in the pre-push hook. It hits the real Forgejo at `rasterhub.com`
  against `stephen/fj-cli-test`.
- **Remote CI** at `.forgejo/workflows/ci.yml` runs the same gate
  (fmt, clippy `-D warnings`, test, release build) on every push and
  PR. The local hook is no longer the only gate.
