Some checks are pending
ci / check (push) Waiting to run
* 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>
160 lines
6.9 KiB
Markdown
160 lines
6.9 KiB
Markdown
# 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.
|