fj/docs/architecture.md

160 lines
6.9 KiB
Markdown
Raw Normal View History

# 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.