Skip to content

Architecture

jenkin edited this page May 28, 2026 · 3 revisions

Architecture

For curious users and would-be contributors. The full module breakdown is in CLAUDE.md; this page covers the shape of the data flow and the trade-offs behind the design choices.

Data flow

                       ┌───────────────────────────┐
                       │   cargo install --list    │
                       └─────────────┬─────────────┘
                                     │ parsed once, cached
                                     ▼
                       ┌───────────────────────────┐
                       │ PackageInfo[]             │
                       │  name, current_version,   │
                       │  PackageSource            │
                       └─────────────┬─────────────┘
                                     │ filter / exclude (globset)
                                     ▼
                       ┌───────────────────────────┐
                       │ filtered PackageInfo[]    │
                       └─────────────┬─────────────┘
                                     │ for each: tokio::spawn
                                     │ (Semaphore(16))
                                     ▼
              ┌──────────────────────┴──────────────────────┐
              ▼                                             ▼
   ┌─────────────────────┐                       ┌─────────────────────┐
   │ sparse index GET    │                       │  cargo search       │
   │ index.crates.io/... │  ── fallback ──→      │  (subprocess)       │
   │ 1 retry on 5xx/net  │                       │  slower path        │
   └──────────┬──────────┘                       └──────────┬──────────┘
              │                                             │
              └──────────────────┬──────────────────────────┘
                                 ▼
                       ┌───────────────────────────┐
                       │ choose_latest(stable,     │
                       │   prerelease, current,    │
                       │   include_prerelease)     │
                       └─────────────┬─────────────┘
                                     │ select_updates
                                     ▼
                       ┌───────────────────────────┐
                       │ dialoguer multi-select    │
                       │ (or --batch / --no-int)   │
                       └─────────────┬─────────────┘
                                     │ for each selected (up to -j N
                                     │ concurrent, default 4):
                                     ▼
                       ┌───────────────────────────────────┐
                       │ updater::update_package           │
                       │  - in-process downloader path:    │
                       │     · GitHub Releases API tag     │
                       │       lookup (HEAD-probe fallback)│
                       │     · stream + sha256 + extract   │
                       │     · atomic rename to ~/.cargo   │
                       │  - cargo install fallback for     │
                       │    non-GitHub / git / path        │
                       │  - PbGuard + SlowGuard            │
                       │  - retry 3× backoff 2s            │
                       │  - verify via cache               │
                       │    (invalidate single key)        │
                       └─────────────┬─────────────────────┘
                                     ▼
                       ┌───────────────────────────┐
                       │ summary + exit code       │
                       │ (or JsonReport on stdout) │
                       └───────────────────────────┘

Key trade-offs

sparse index vs cargo search

The dominant cost in "check 20 packages for updates" is how many cargo subprocesses you spawn. cargo-update and earlier versions of cargo-fresh ran cargo search <name> per package — that's 20 cargo startups, each loading the registry index from scratch.

cargo-fresh's primary path is one shared reqwest::Client making 20 concurrent HTTPS requests to https://index.crates.io/{shard}/{name}. The TLS connection pool reuses the same TCP connection across all of them. Total wallclock: ~3s for 20 packages, of which most is TLS handshake + DNS.

cargo search is kept as fallback for environments where sparse index is blocked (corporate firewalls, broken mirrors). The fallback can be disabled with --no-cargo-search-fallback for diagnostic clarity.

Why 16-way concurrent? Semaphore(16) keeps fd usage bounded and avoids overwhelming crates.io's rate limit when someone has 100+ packages installed. Empirically, going higher doesn't help — TLS handshake amortizes well at 16, and individual requests are I/O-bound on the server side.

OnceLock<Mutex<HashMap>> for installed version cache

Each successful update needs to re-check the installed version (to confirm the install actually advanced the version). Without caching, that's an extra cargo install --list per package — N+1 in the worst case.

cargo-fresh populates the version map once in get_installed_packages, then invalidate_installed_version(pkg) removes a single entry after a successful install. The next get_installed_version call for that package re-runs cargo install --list and refreshes the cache — but only for that one query.

The lock type is OnceLock<Mutex<HashMap>> rather than OnceCell because:

  1. Initialization happens in async code; OnceLock is Sync
  2. The map needs interior mutability for invalidate_*
  3. OnceLock::get_or_init runs the initializer at most once and is non-blocking after

Initialization was previously OnceLock::set, which silently returns Err if called twice — a foot-gun if get_installed_packages ever ran more than once in a process (which a future --watch mode will need). 0.10.1 changed it to get_or_init plus lock/clear/extend so re-scans correctly refresh.

PbGuard and SlowGuard RAII

cargo's terminal aesthetic depends on clean spinner cleanup. Earlier versions had ghost spinner frames left on screen after errors — every return path needs finish_and_clear(), and missing one is invisible until someone notices ghosts in their scrollback.

PbGuard wraps ProgressBar in an RAII struct; its Drop calls finish_and_clear(). So no matter how update_package exits (success, failure, retry exhausted, dry-run, panic), the spinner gets cleaned up.

SlowGuard does the opposite: it cancels a background "tell user this is taking >30s" watchdog when the update completes. Without SlowGuard, late-fired warnings would print after the main flow ended, confusing users.

JSON_MODE: AtomicBool

--format=json requires that no human-facing output goes to stdout/stderr — scripts piping stdout into jq would choke on color codes or spinner characters. Rather than threading a json_mode boolean through every function that might print, cargo-fresh sets a global atomic flag at startup. Every status* / pb_status* / print_* function short-circuits when it's true. The final JsonReport is the single output line.

This is the kind of global that's normally avoided. It's defensible here because:

  1. The flag is set exactly once at startup, never mutated again
  2. The alternative (a display context parameter on dozens of functions) added more cognitive load than the global removed
  3. The contract is simple: "if JSON_MODE, shut up"

Source-aware install via enum dispatch

PackageSource is enum { Crates, Git { url, rev }, Path { dir }, Unknown(String) }. updater::update_package dispatches on it to pick the install strategy:

  • Crates → try the in-process downloader (GitHub Releases API resolves the tag's asset list, picks the binary matching the current target triple, streams + verifies + atomically renames into ~/.cargo/bin). On any Unsupported outcome (non-GitHub repo, no matching asset, unsupported archive format) fall back to cargo install --force <name> [--version X]
  • Git { url, rev }cargo install --git URL [--rev REV] <name> (no downloader path — there's no notion of "release asset" for an arbitrary git ref)
  • Path { dir }cargo install --path DIR <name>
  • Unknown(raw) → skip with a Skip [unknown source] line

The Unknown variant exists because earlier code silently treated unknown prefixes as Crates, sending them to the sparse index — which would 404 and the user would see "version check failed" without understanding why. Now the skip is explicit and visible.

Why the GitHub Releases API instead of blind HEAD-probing

The 0.11.0 downloader resolved release assets by HEAD-probing a cross-product of candidate URLs (N names × 6 tag paths × 10 filename templates × 2 archive formats × M target aliases — up to 360 requests per "no prebuilt" verdict). 5s timeout + 16-way concurrency capped the wall-clock at ~5s worst case, but for users running 50+ packages this generated a lot of avoidable traffic against github.com.

0.12.0 switched to GET /repos/{owner}/{repo}/releases/tags/{tag}. One API call returns the full asset list; cargo-fresh applies the same filename-matching logic locally and streams the winning URL directly. Wall-clock for "no prebuilt": one API call (~200ms) instead of 16–360 HEADs.

The API quota is the cost: 60/hr anonymous, 5000/hr with a token (GITHUB_TOKEN / GH_TOKEN / gh auth token, in that lookup order). When the API call fails for any reason — 401 / 403 / 429 / network — the code automatically falls back to the older HEAD-probe path, so the worst case for an unauthenticated user under heavy load is "behaves like 0.11.0", not "fails outright".

Concurrent updater (-j N / --jobs N)

0.12.0 made the update loop concurrent via tokio::JoinSet. -j N caps the parallel task count (default 4, 0 means unlimited, 1 restores 0.11.x serial behavior). Default 4 balances throughput against GitHub-CDN friendliness — downloads are network-bound and the inner HEAD-probe pool already caps connection fan-out, so going higher rarely helps.

Two correctness details:

  1. MultiProgress rows are pre-registered in input order. Concurrent updates finish in arbitrary order, but UpdatePlan::new(names) lays out the rows up front, so the visual layout stays stable.
  2. .crates.toml / .crates2.json writes serialize through an in-process Mutex<()> (install::CRATES_FILES_LOCK). Two concurrent updates writing different binary entries to the same cargo metadata files would race; the lock makes the read-modify-write atomic.

cargo install fallbacks naturally serialize on cargo's own $CARGO_HOME lock regardless of -j, so there's no extra coordination needed for that path.

MSRV at 1.86 (not lower)

cargo-fresh ships a binary, not a library. MSRV exists so users on long-lived toolchains can cargo install cargo-fresh without hitting "this requires a newer rustc". Each MSRV bump is friction for users — we keep it as low as reasonable.

Current floor: 1.86. Constraints:

  • clap_derive 4.6.1 requires edition2024 (stable since 1.85)
  • icu_*@2.2.0 (transitive via reqwest → url → idna → icu) declares rust-version = 1.86

The MSRV CI job runs cargo check --locked --lib --binsnot --all-targets, because dev-dependencies (wiremock, assert_cmd, etc.) have their own MSRVs unrelated to what we promise users.

Exit code contract

0   no updates / all selected succeeded
1   updates available but not applied
2   at least one update failed
130 SIGINT

The "updates available but not applied" path matters for CI gating: a scheduled job runs cargo fresh --no-interactive and exits 1 → the job fails → maintainers get paged → updates get reviewed and applied manually. This is more useful than the boolean "did anything fail" you'd get from a 0/1-only contract.

130 is the POSIX convention for SIGINT (128 + 2). cargo-fresh handles it through Arc<AtomicBool> + tokio::signal::ctrl_c. The cancel is cooperative: a first Ctrl-C flips the flag and prints Aborting Ctrl-C again to force exit — in-flight downloads check the flag between byte-stream chunks and stop quickly; in-flight cargo install subprocesses run to natural completion. A second Ctrl-C calls exit(130) immediately. TempDir Drop + atomic rename guarantee no half-installed state is left behind in either case.

Bilingual via lookup table, not gettext

src/locale/texts.rs is two giant match statements (one per language) mapping string keys to translated strings. No .po files, no gettext runtime, no separate translation pipeline.

The trade-off: this scales poorly past ~3 languages. For a CLI with English + Chinese, the simplicity wins. If a third language joins, switching to fluent or gettext would be the right call — that's tracked informally (no ROADMAP entry yet).

detect_language() reads LANG / LC_ALL / LC_CTYPE; tests call detect_from_locale(&str) (pure function) to avoid env::set_var races.

Why no library API?

src/lib.rs exists, but only so tests/ can call internals (e.g., cargo_fresh::package::sparse_index::fetch_latest). It's not a downstream API — README's Stability Guarantees explicitly carve it out.

The reasoning: if cargo-fresh's modules became a public API, internal refactors would need semver coordination with downstream users. For a 1-maintainer project, that's a tax not worth paying when nobody has asked for the API. If demand emerges, we'd reshape the lib surface deliberately rather than freezing whatever happens to be in 1.0.

Where to read next

  • src/main.rs for orchestration and how exit codes flow
  • src/package/sparse_index.rs for the HTTPS path
  • src/updater/mod.rs for the install + retry loop with guards
  • src/errors.rs for the actionable-hint matcher
  • tests/cli.rs and tests/sparse_index_http.rs for the integration test patterns