* CLAUDE.md: project layout, key conventions, where to look first. Captures the non-obvious things a future session needs. * CONTRIBUTING.md: build/test workflow, how to add a subcommand (concrete walkthrough), code style. * CHANGELOG.md: history. 0.1.0 entry covers initial feature set; Unreleased captures stability + optimization batch. * docs/architecture.md: module graph, layering rules, the HTTP funnel, pager + SIGINT, repo resolution, test strategy. * docs/jq.md: --jq syntax cheatsheet (dot paths, brackets, negative indices, pipes, what's not supported). * docs/troubleshooting.md: keychain re-prompts, debug logging, pager opt-out, alias precedence, hook bypass, common 401s. * README.md: links into docs/ and updates binary size to 4 MB. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5.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 callsapi::*.api::*is one file per Forgejo resource. Each function takes a&Clientand returns typedResult<T>.client::*is the only place that touchesreqwest. All HTTP goes throughClient::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 inapi::release::upload_asset, which goes throughclient.http()but still usesclient.token()for auth).config::*is the on-disk shape ofhosts.toml. Tokens are NOT stored here. They live in the OS keychain viaauth::*.git::*shells out to thegitbinary. 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:
- If
-R/--repowas given, parse it asowner/name. - Otherwise call
git remote -vand pick the first remote that parses as a Forgejo URL. Preference order isupstream, thenorigin, then any other. - The host comes from
--host, then the detected remote's host, then the configured default host inhosts.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. - Idempotent methods (GET, HEAD, OPTIONS, PUT, DELETE) get 3 attempts.
- POST and PATCH get 1 attempt — they're never retried.
- Backoff is
200ms * 2^attemptbetween retries. - On 5xx or transport error, we retry. On any other status, we return.
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:
- Bails if stdout isn't a TTY or
--no-pager/FJ_NO_PAGERis set. - Spawns
$FJ_PAGER/$PAGER/less -FRX. dups our stdout fd (saving it for restore) thendup2s the pager child's stdin fd onto stdout (fd 1).- Returns a
PagerGuardwhoseDropimpl 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:
- Alias expansion via
cli::alias::expand_argv. Readsaliases.toml. Ifargv[1]matches a defined alias, replaces it with the tokenized expansion. - Plugin dispatch via
main::detect_plugin. Ifargv[1]isn't a known subcommand ANDfj-<name>is on PATH, exec it.
Only if neither pass triggers do we hand off to clap.
Test strategy
- Unit tests stay inline next to the code they cover. Pure functions only (no I/O).
- Integration tests for HTTP behavior live in
src/client/integration_tests.rsand use a wiremock server. Construct test clients viaClient::for_base_url. - Live E2E lives in
scripts/e2e-smoke.sh, gated onFJ_E2E=1in the pre-push hook. It hits the real Forgejo atrasterhub.comagainststephen/fj-cli-test.