Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Conventions

The rules inside a crate. cargo xtask tidy enforces the ones marked (tidy); the allowlists it uses are data at the top of xtask/src/tidy.rs. Keep this file current: a rule that changes changes here first. The dependency rule between crates is in ARCHITECTURE.md.

These rules are the content of CONVENTIONS.md. cargo xtask tidy enforces the ones marked (tidy).

Module layout

  • src/lib.rs is a table of contents: a //! crate doc of at most one screen stating purpose, allowed dependencies and what does not belong here; mod declarations; one pub use block of the crate's public surface. No logic in lib.rs.
  • Children of a module live in a directory named after it: foo.rs declares mod bar; and foo/bar.rs holds it (the 2018 layout; no mod.rs files, tidy). codex and rust-analyzer use the same layout.
  • Unit tests live in foo/tests.rs under #[cfg(test)] mod tests; at the end of foo.rs, never inline and never as a sibling foo_tests.rs (tidy rejects *_tests.rs and inline mod tests {).
  • One type per file for anything with more than one impl block or more than one screen of methods; small value types may share a file named for the concept (ids.rs, error.rs).
  • A pure module inside a crate that is not pure (efr_tools::patch, listed in PURE_MODULES) names no tokio, no async, no IO of std (std::fs, std::io, std::net, std::process, std::env, std::thread), no workspace crate and no crate:: path outside itself (tidy). It is what a forbidden edge to tokio is for a crate.
  • Binaries keep main.rs to argument parsing, tracing setup and a call into run.rs. Everything in a bin crate is pub(crate).
  • A crate's README states: purpose, the tier, allowed dependencies, the invariant it protects, how to run its tests without the rest of the workspace.

Tests

  • One integration test binary per crate: tests/it/main.rs declares one module per area (tests/it/hello.rs, tests/it/smoke.rs) and holds the #![cfg(test)] line; insta snapshots of those modules live in tests/it/snapshots/ (tidy). Cargo makes a binary of every tests/*.rs, and each one links the crate and all its dependencies again: eleven files in efr-daemon were eleven binaries of about 320 MB that each held the whole daemon. One binary links once, and nextest still runs every test in a process of its own. A test is named <crate>::it <module>::<test>; cargo nextest run -p efr-daemon -E 'test(/^hello::/)' runs one module.
  • A bench is an ignored test named bench_perf_<name> (the search benches keep bench_search_<name>), in the crate's tests/it binary or, for a module that only the crate can reach, in its tests.rs. It uses efr_test_support::bench: it times a ladder of input sizes, reports the slope when the cost depends on a size, and ends Report::finish, which prints the lines and writes them as JSON lines to EFR_BENCH_OUT. A bench never uses a real model or the user's daemon, and it ends on today's code: a size that takes more than 10 s stops its ladder. just bench-perf runs every bench in release, one at a time, and just bench-compare <old> <new> prints the change between two runs. just bench-size checks the release binaries against xtask/size-budget.json. A fix that makes a path faster gives its bench a budget, so the gain cannot go away unseen.
  • Debug info is line-tables-only for the workspace's crates and off for dependencies in dev and test builds (the root Cargo.toml): a backtrace keeps the file and the line of each frame, and target/ stays a fraction of its size. CARGO_PROFILE_DEV_DEBUG=full turns full debug info back on for a debugger session.

Naming

ThingRuleExample
Crateefr-<context>, kebab-case, a nounefr-permissions
Libraryefr_<context>efr_permissions
Binaryshort, no prefix repetitionefrd, efr
Modulesnake_case noun; verbs only for process modulesrun.rs, reconcile.rs
Wire methodnoun.verb on the wire, noun_verb.rs as the file and NounVerb as the params typeconversation.subscribe, methods/conversation_subscribe.rs, ConversationSubscribe
EventEvent::<Noun><PastParticiple>TurnStarted, ApprovalRequested, CwdChanged
Error<Crate>Error enum per crate, variants named for what failed, not whereStoreError::Busy, ProviderError::RateLimited
Actor pairXActor (owns state, runs the loop) and XHandle (Clone + Send)ScreenActor, ScreenHandle
Trait for a boundarythe role, not I or Trait suffixScreen, PtyHolder, Provider, Tool, SecretStore
Featurekebab-case, one adjective or nounlocal-pty, screen-ghostty, keyring, conformance
Env varEFR_ prefix, screaming snakeEFR_LOG, EFR_SCREEN, EFR_DATA_DIR
MigrationNNNN_<noun>.sql, four digits, never renumbered0001_events.sql
Fixture<case>.ndjson or <case>.json, lowercase, words joined by _cwd_move_between_turns.ndjson

Visibility

  • unreachable_pub = "warn" (as in rust-analyzer): an item is pub only if it is re-exported from lib.rs; otherwise pub(crate).
  • No glob re-exports (pub use foo::*) and no prelude modules; a reader must find a name by following lib.rs.
  • #[non_exhaustive] on every wire enum in efr-protocol and on every error enum; matches outside the owning crate use a wildcard arm. The one deliberate exception is the Method enum, matched exhaustively in efr-daemon/src/methods.rs so a new method cannot ship without a scope.
  • Constructors over public fields for anything with an invariant; plain pub fields for serde wire structs.

Docs

  • Every pub item in tier 0 and tier 1 crates has a doc comment (missing_docs = "warn" for those crates, set in each manifest's [lints] override; the rest of the workspace is exempt until it stabilises).
  • rustdoc::broken_intra_doc_links = "deny"; CI runs cargo doc --no-deps with -D warnings.
  • Doc tests are off (doctest = false) in every crate except efr-stdx and efr-protocol; examples elsewhere go in examples/ or tests.
  • ARCHITECTURE.md follows the rust-analyzer shape: a map of the crates, the invariants, where to find things, and the threading model. docs/protocol.md is edited by hand for now: cargo xtask protocol-docs is a stub, and later generates it from efr-protocol with the schema feature (schemars 1.2.2). Each protocol change adds a line to its changelog. docs/protocol.schema.json, the protocol for the phone app's code generator, is generated by cargo xtask protocol-schema and never edited by hand; just tidy and CI fail when it is not current. The phone app's types, app/src/lib/protocol/generated.ts, are generated from that file by pnpm gen:protocol in app/ and never edited by hand; just app-check and the CI app job fail when they are not current.
  • Comments explain why, never what; a // NOTE: or // SAFETY: prefix marks the two kinds a reviewer must read.

Error types

  • Library crates use thiserror 2.0.21 enums; anyhow 1.0.104 is allowed only in efr-daemon/src/main.rs, efr-cli, xtask and tests. A tidy rule rejects anyhow in any other Cargo.toml.
  • Each crate has exactly one public error enum in error.rs. A public module that was a crate of its own (ADR 0014) keeps its own enum in its own error.rs: efr_tools::patch::PatchError, efr_providers::openai::OpenAiError, efr_providers::anthropic::AnthropicError and efr_transport::client::ClientError. Variants carry structured data (path: PathBuf, seq: Seq), never preformatted strings. #[source] chains are preserved; a Display message says what failed in one sentence without the source's text.
  • Wire errors: efr_protocol::ErrorFrame { id, error: { code: ErrorCode, message, data } } with ErrorCode as a closed set (ProtocolMismatch, Unauthorized, Forbidden, NotFound, Conflict, Busy, Overflow, Cancelled, Invalid, Internal). The only From<DaemonError> for ErrorFrame lives in efr-daemon/src/error.rs; no other crate knows the mapping.
  • unwrap_used, expect_used and panic are deny outside tests (allow-unwrap-in-tests, allow-expect-in-tests, allow-panic-in-tests in clippy.toml, the codex and nushell setting). The escape hatch is #[expect(clippy::unwrap_used, reason = "...")] on the smallest item. Infallible-by-construction cases use match with an unreachable!() arm that states the invariant.
  • Errors are logged once, at the boundary that drops them: a method handler, an actor loop or main. Library crates return them.

Logging and tracing

  • tracing 0.1.44 everywhere; the log crate is forbidden by deny.toml bans.
  • Spans: one per connection (conn_id, surface, uid), one per turn (conversation_id, turn_id), one per tool call (tool, call_id), one per provider request (provider, model, request_id). Field names are snake_case and stable because the optional JSON log file is searched with grep.
  • #[tracing::instrument(skip_all, fields(...))] on actor entry points and method handlers; never on hot paths (feed, vt_write, the PTY reader).
  • Levels: error for a failed user-visible operation, warn for degraded but continuing (a refresh retry, a dropped slow subscriber), info for lifecycle (start, ready, shell spawned, login completed), debug for per-request detail, trace for bytes (never on by default).
  • Secrets never reach a field: tokens are secrecy::SecretString (0.10.3) whose Debug is redacted, request headers go through efr_http::redact, and the zsh plugin's ShellContext is logged without last_command.
  • Subscriber setup is only in efr-daemon/src/telemetry.rs and efr-cli/src/main.rs: EnvFilter from EFR_LOG (default info,efr_=debug under cargo run, info as a service), tracing-journald 0.3.2 when JOURNAL_STREAM is set, otherwise a compact stderr layer; an optional JSON file layer through tracing-appender 0.2.5 under $XDG_STATE_HOME/efr/logs/ (whether it is on by default is undecided).

Config

  • One file, $XDG_CONFIG_HOME/efr/config.toml, owned by efr-config: one schema for efrd and efr, parsed into Settings with #[serde(deny_unknown_fields)] on every table (toml 1.1.6, serde 1.0.229), then checked. A new key is a field of its table there, with its doc comment, its default and, when it needs one, its check; the JSON schema, the key list and the effective dump follow from the types. Precedence: built-in defaults, then the file, then EFR_* environment variables, then flags (efr-daemon/src/config.rs). efrd --print-config and efr config show print the effective result with the source of each value.
  • The daemon keeps its Settings in a watch of Arc<Settings>; a reader takes the latest value when its unit of work starts (a turn, a prompt, a tool call) and keeps it. Other crates receive plain config structs (ShellConfig, ProviderConfig, StoreConfig) by value, or read through a trait the daemon implements (efr_conversation::ConfigSource). No global config, no once_cell config, no crate reads the environment except efr-stdx::env and the daemon.
  • Paths come from efr_stdx::paths (etcetera 0.11.0 for XDG): config $XDG_CONFIG_HOME/efr/, data $XDG_DATA_HOME/efr/ (database, recordings, scratch, secrets, memory later), state $XDG_STATE_HOME/efr/ (logs), runtime $XDG_RUNTIME_DIR/efr/ (socket, daemon.json), else /run/user/<uid>/efr. EFR_DATA_DIR and friends override each root for tests; EFR_HOME places every root without its own variable below one directory.
  • The daemon reloads the file live (efr-daemon/src/reload.rs): a file with an error changes nothing, and a key of efr_config::RESTART_KEYS keeps its running value until a restart. docs/config.md and docs/config.schema.json are generated by cargo xtask config-docs and never edited by hand.

Async and actor rules

  • One tokio 1.53.3 runtime per binary, started in efr-daemon/src/main.rs and efr-cli/src/main.rs: efrd builds a multi-thread runtime of at most four workers (efr-daemon/src/runtime.rs), efr a current-thread one (efr-cli/src/run.rs). Library crates never create runtimes and never call block_on; functions that need a runtime are async fn.
  • State is owned by actors, not shared: one actor per conversation, one StoreWriter, one ShellSessions, one ScreenActor per screen. Communication is tokio::sync::mpsc with bounded capacity (default 64) and oneshot replies. A handle type is the only public API of an actor.
  • No lock is held across an .await (await_holding_lock = "deny", await_holding_invalid_type for tokio guards, as in codex). std::sync::Mutex is fine for short critical sections; tokio::sync::Mutex only when the guard must live across an await, which should be rare and commented.
  • Time is injected: anything that schedules, retries or expires takes a Clock (efr_stdx::time::Clock, SystemClock in production, TestClock in tests). tokio::time::sleep appears only inside SystemClock. This is what lets provider refresh timing and lease expiry be tested without waiting. The one exception is the sandbox launcher: efr-sbx may not reach efr-stdx or tokio, so efr-sbx/src/os.rs is the one place where it reads its environment and real time and starts processes, and its grace loops take that time through a small trait of their own (exit_child::Processes).
  • Randomness is injected the same way (Rng trait, rand 0.10.3 behind SystemRng, a seeded TestRng).
  • Blocking work (SQLite reads, file hashing) runs in spawn_blocking; the only code that is allowed to block a tokio worker is the PTY pre_exec closure.
  • Backpressure is explicit: every fanout (subscriptions, PTY output) has a bounded queue per consumer, and overflow closes that consumer with ErrorCode::Overflow carrying last_seq, never slows the producer. A lossy item (a draft of a running turn) has a small room of its own in the same queue; a full room drops it and never closes the consumer.
  • cfg(feature = ...) may appear only in named files: efr-daemon/src/screens.rs (screen-ghostty), efr-daemon/src/shells.rs (local-pty), efr-daemon/src/sandbox/seams.rs (test-sandbox-fake), efr-credentials/src/lib.rs (keyring, one mod line), efr-screen/src/lib.rs (conformance, one mod line), efr-protocol/src/lib.rs (schema, one mod line) (tidy).

Unsafe policy

  • unsafe_code = "deny" in [workspace.lints.rust]. The one module per crate that needs it opts out with #[allow(unsafe_code)] on the module item in lib.rs (main.rs for a binary) plus a module doc explaining why, and cargo xtask tidy holds the allowlist of files in which the token unsafe may appear: crates/efr-pty/src/local_holder.rs at milestone 1, crates/efr-sbx/src/fds.rs for the sandbox launcher (docs/adr/0007-efr-sbx-unsafe.md), crates/efr-daemon/src/malloc.rs for glibc's mallopt and malloc_trim in efrd (docs/adr/0020-malloc-trim.md), crates/efr-fdpass/src/lib.rs from milestone 5. Any other unsafe fails CI.
  • unsafe_op_in_unsafe_fn = "deny", clippy::undocumented_unsafe_blocks = "deny": every block has a // SAFETY: comment naming the invariant, following nushell's unsafe policy, including the system behaviour relied on (what pre_exec may call, which fds are open).
  • FFI goes through rustix 1.1.5 and nix 0.31.3 before libc 0.2.190; raw libc calls are allowed only in the allowlisted files.
  • libghostty-vt is consumed through its safe API only; efr-screen-ghostty has no unsafe. If a future pin needs a hand-written trampoline for the semantic prompt effect, it goes in efr-screen-ghostty/src/ffi.rs, added to the tidy allowlist in the same commit, following herdr's pattern of local trampolines.