The goal of this file is to describe the common mistakes and confusion points
an agent might face as they work in this codebase.
If you ever encounter something in the project that surprises you,
please alert the developer working with you and indicate that this is the case by editing the AGENTS.md file to help prevent future agents from having the same issue.
Bencher is a continuous benchmarking platform that detects and prevents performance regressions.
- Bencher API Server (
services/api) - seeservices/api/AGENTS.md bencherCLI (services/cli) - seeservices/cli/AGENTS.md- Bencher Console (
services/console) - seeservices/console/AGENTS.md - Bare Metal
runner(services/runner) - Bare Metal benchmark runner
Terminology: use the definitions in docs/glossary.md.
Version control uses Jujutsu (jj) with Git.
- Do NOT add
Co-Authored-Bylines to commit messages - Do NOT add
🤖 Generated with [Claude Code](https://claude.com/claude-code)to PR descriptions
This is a public repository. NEVER disclose sensitive information about the production setup, data, or operations in any public-facing artifact: PR descriptions, PR/issue comments, commit messages, code comments, or docs. This includes production database sizes, row counts, table statistics, customer or project identifiers, incident details and timelines, and measured production performance characteristics. Describe changes in relative terms (e.g., "a large report" instead of actual production numbers) and keep concrete production figures in private channels.
- NEVER use emdashes in any writing output: code comments, commit messages, PR descriptions, docs, issue/PR replies, and chat responses. Use a colon, semicolon, comma, parentheses, or two sentences instead.
- Practice test-driven development (TDD)
- All new code should be designed for testability and maintainability
- All changes should include appropriate unit and integration tests
cargo buildcargo nextest runTest a single package:
cargo nextest run -p my_packagebencher_schema tests require the plus feature (cargo nextest run -p bencher_schema --features plus); some #[cfg(test)] modules (e.g., model/project/metric.rs) use plus-only items without a feature gate, so the default-feature test build fails to compile.
nextest does not support doctests, so also run:
cargo test --docCrates that depend on bencher_valid will need to specify either:
serverfeature for server-side usage
cargo nextest run -p my_package --features serverclientfeature for client-side usage
cargo nextest run -p my_package --features clientOtherwise, you will see:
use of undeclared type `Regex`
bencher_otel has no tests, so nextest needs --no-tests pass (cargo nextest run -p bencher_otel --no-tests pass). With --all-features it also needs --features bencher_json/server, since its plus feature reaches bencher_valid through bencher_json without picking a side.
cargo fmtcargo clippy --no-deps --all-targets --all-features -- -Dwarningscargo check --no-default-featuresWhen modifying target_os = "linux" crates (bencher_init, bencher_noise, bencher_rootfs, bencher_runner, bencher_runner_cli),
also run the cross-compilation checks locally:
./scripts/clippy.sh # Runs clippy natively + cross-compiles to x86_64-unknown-linux-gnu
./scripts/test.sh --linux-only # Cross-compiles tests for the Linux-only cratesThese scripts require a cross-compiler (zig, x86_64-linux-gnu-gcc, or x86_64-unknown-linux-gnu-gcc)
and the x86_64-unknown-linux-gnu Rust target.
The clippy script will install the target automatically and warn if no cross-compiler is found.
Core Axiom: Less is more. Err on the side of simplicity. KISS.
- Always run
cargo fmtandcargo clippywhen testing or before committing - Run
cargo fmtone final time after all changes are complete (including any generated code or lint fixes), since clippy fixes and other automated changes can introduce formatting drift - Use
#[expect(...)]instead of#[allow(...)]for lint suppression - Do NOT suppress a lint outside of a test module without explicit approval
- All dependency versions go in the workspace
Cargo.toml - When reviewing code, also check:
cargo check --no-default-featurescargo gen-types(if the API changed at all)cargo deny check(if dependencies were added or updated)
- Use idiomatic, strong types instead of
Stringandserde_json::Valuewhere possible - Database model fields should use strong validated types (e.g.,
ProjectId,ProjectUuid,ProjectName,DateTime,VersionNumber) with DieselToSql/FromSqlimpls rather than raw primitives (i32,i64,String). All conversion happens inside the Diesel impls, not in the model layer. - Avoid
select!macros - usefutures_concurrency::stream::Merge::merge - Prefer stream combinators (
try_fold,try_for_each,try_collect, etc.) over manualwhile let Some(chunk) = stream.next().awaitloops when processing streams - Prefer structured concurrency: every spawned task has an owner that holds its
JoinHandleand awaits it at shutdown. Where cancel-on-drop semantics make sense, use aJoinSet, which aborts its tasks when the owner drops. Do NOT detach a task by dropping its handle without explicit justification. A detached task is unsupervised, its panics are silent, and shutdown cannot order itself against it - All time-based tests should be deterministic and use time manipulation not real wall-clock time
- Use
bencher_json::Clock::Custom(behind thetest-clockfeature) to inject a fake clock in tests instead of callingDateTime::now()directly.Clockis available onApiContext. - With
tokio::time::pause(), calltokio::task::yield_now().awaitbeforetokio::time::advance()when the code under test spawns a task that sleeps: a task is not polled until the test yields, so an advance issued first moves the clock before the sleep is registered, and the timer then fires that much later in virtual time, after the test has already asserted - For unit tests without access to
ApiContext/Clock, usebencher_json::DateTime::TEST(a fixed deterministic const). Enabletest-clockinbencher_jsondev-dependencies to access it. - No tautological tests. Do not write tests for anything the type system already guarantees.
- Most wire type definitions are in the
bencher_validorbencher_jsoncrate - Always pass strong types (
MyTypeId,MyTypeUuid, etc) into a function instead of its stringly typed equivalent, even in tests - Do NOT use shared, global mutable state
- Always use
thiserrorfor error types in libraries and production binaries (services/). Do not useanyhowin those crates.anyhowis acceptable intasks/crates (build tasks, test harnesses) where convenience outweighs structured errors. - Error enum variants must wrap the original error type, not
String. Use#[error("context: {0}")] Variant(OriginalError), notVariant(String)with.to_string()at the call site. - Do not use
Box<dyn Error>(orBox<dyn std::error::Error + Send + Sync>) as a return type. UseHttpErrorfor API endpoint errors or define specificthiserrorerror enums. The only acceptable uses ofBox<dyn Error>are when wrapping third-party APIs that return boxed errors (e.g., diesel migrations, dropshot server creation). - Track adverse events (auth failures, write errors, rate limit hits) with OpenTelemetry metrics via
bencher_otel::ApiMeter::increment. These events may not warrant a Sentry error but give SRE/security visibility. Use dimensional attributes (reason, resource type) for drill-down. - Do NOT use
dyn std::any::Anywithout explicit justification and approval - When adding workspace dependencies without extra options (no
optional, nofeatures), use the shorthanddep.workspace = trueform instead ofdep = { workspace = true } - Use
camino(Utf8Path/Utf8PathBuf) for file paths whenever practical instead ofstd::path::Path/PathBuf. Exception:tempfile::tempdir()in tests may usestd::pathsince it returnsTempDirwith&Path; convert viaUtf8Path::from_path()at the boundary when needed. - Use
clapfor CLI argument parsing- The
clapstruct definitions should live in a separateparsermodule - The subcommand handler logic should live in a separate module named after the binary for production code (ie
bencher) or a module namedtaskfortasks/*crates - Do NOT use
num_argson flags inbencher run— it usestrailing_var_arg = trueto matchdocker runsemantics, andnum_argsconflicts with trailing vararg parsing. Validate collection sizes at the type/deserialization layer instead (e.g.,TryFromimpls inbencher_json).
- The
- Within a module, order definitions top-down by scope: primary struct first, then its error type, then
implblocks, then supporting/child types (enums, helpers) used by the primary type. Readers should encounter the "what" before the "how". - Prefer destructuring a struct (
let Self { field1, field2, .. } = self;orlet Foo { .. } = foo;) over individual field access (.field1,.field2) when consuming or converting all fields. This ensures the compiler flags a build error when a field is added, preventing silent omissions. - Use macros for database connection access. All of these macros have a single-use and expanded closure-like form for use multiple times in the same scope.
public_conn!()- For read-only public access- This optionally takes in a
PublicUser
- This optionally takes in a
auth_conn!()- For read-only authenticated accesswrite_conn!()- For single writer access
- Use
diesel::QueryResult<T>instead ofResult<T, diesel::result::Error>— it is a type alias and more idiomatic - Database write methods that may be called both standalone and from within an outer transaction should NOT wrap in
conn.transaction()internally. Instead, the standalone callers should wrap the call in a transaction. This avoids unnecessary SQLite savepoints when the method is called from batch operations. - Use the
write_transaction!()macro (orconn.immediate_transaction(...)on an existing writer&mut DbConnection) for any transaction that writes — neverconn.transaction(...).
Shell scripts are used very sparingly. Prefer creating tasks in tasks/ (invoked via cargo aliases). Administrative-only tasks go in xtask/. Shell scripts are only acceptable as ultra-lightweight wrappers around commands like git or docker.
Defined in .cargo/config.toml:
cargo xtask- Administrative taskscargo gen-types/cargo gen-spec/cargo gen-ts- Type generationcargo test-api- API testing and DB seedingcargo test-api seedneeds the API server already running (cargo runinservices/api) with a fresh database (services/api/dataholds only a tracked.gitignore; deleteservices/api/data/bencher.db, not the whole directory)cargo test-api seedalso needs thebencherCLI binary already built (cargo build --bin bencher); it shells out viaassert_cmd, which panics with`CARGO_BIN_EXE_bencher` is unsetif the binary is missing- Pass
--no-gitwhen running the seed test in this repo: there is no colocated.git, sobencher runcannot derive a git context and the on-the-fly project naming assertions (benchervsProject) will fail without it
cargo test-runner- Runner integration tests (requires Linux + KVM + root)cargo test-runner scenariosalways fails unelevated: the sandbox is built by dropping privilege, so the scenarios refuse to start without it. Build unprivileged, then run elevated, which also keepscargofrom leaving root-owned artifacts intarget:cargo test-runner scenarios --build-onlysudo BENCHER_RUNNER_BIN=./target/debug/runner ./target/debug/test_runner scenarios
- PRs are opened against the
develbranch - Deploy to Bencher Cloud: once devel CI has uploaded the commit's builds, reset
cloudtodeveland push - After successful deploy: CI resets
maintocloud - Release tags (e.g.,
v0.5.10) are created offdevel - Do NOT use auto-closing keywords (
Closes #N,Fixes #N) in PR descriptions if there have been changes to the CLI
Rust is the single source of truth for types:
- Rust structs annotated with
#[typeshare]inbencher_jsonand other crates cargo gen-typesgenerates OpenAPI spec (services/api/openapi.json) and TypeScript types (services/console/src/types/bencher.ts)bencher_validis compiled to WASM for browser-side validationbencher_clientis auto-generated from the OpenAPI spec via progenitor
If cargo gen-types fails with Failed to read input: .ts and No such file or directory,
look for a dangling symlink in the repo (for example lib/bencher_client/codegen.rs,
a gitignored convenience symlink into target/ that dangles after the build dir is cleaned).
Typeshare walks the whole repo and errors on dangling symlinks; delete the symlink.
When adding a new crate, update all four Dockerfiles:
services/api/Dockerfilecopies the whole workspace (COPY . .), so it usually needs no changeservices/cli/Dockerfile,services/console/Dockerfile, anddocker/bench.Dockerfilestub out unused workspace members withcargo init --lib, so each needs a stub line for the new crate
When adding a new crate or changing crate dependencies, update the path filters in .github/workflows/ci.yml (changes job) for any affected filter (rust, cli, runner, console, action, docker, nix).