fj/docs/architecture.md
Stephen Way e60a810c69
Some checks are pending
ci / check (push) Waiting to run
docs: sync README + CLAUDE.md + architecture.md with recent additions
* README: new commands (milestone, search code, watch/star, edit-comment,
  request-review) and the `--json-fields` / `--no-pager` globals.
* CLAUDE.md: layout includes json_filter and .forgejo/workflows; test
  count updated to 75; HTTP retry, JSON projection, pager-on-Unix
  conventions documented; setup-git hostname-validation invariant
  called out; auth token TTY guard noted.
* docs/architecture.md: HTTP retry section now covers 429 / Retry-After
  and Request::try_clone; new JSON projection section explains the
  global --json-fields flow; test strategy mentions remote CI and
  ignored env-mutating tests.
* docs/README.md: link to SECURITY.md and CHANGELOG.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:57:07 -07:00

6.9 KiB

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. dups our stdout fd (saving it for restore) then dup2s 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.