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