Migrating a Rust Suite to cargo-nextest
Why the guide's retry-budget, per-test-timeout, and flake-telemetry policy is unimplementable on plain cargo test, and a mechanical checklist for migrating a Rust suite to cargo-nextest.
Why nextest Is the Gate for the Rust Flake Pipeline
The Heavy Test Decision Rule and Execution Tiers impose the same three obligations on every CI suite in this guide: a retry budget of 1–2, pass-on-retry recorded as a triage signal, and a per-test timeout so a hung test cannot hang the whole job. On a Rust project those are not merely awkward on plain cargo test — they are unimplementable. cargo test has no retry mechanism, no per-test timeout, and no stable machine-readable output. cargo-nextest supplies all three, which is why the Rust flake pipeline runs through nextest rather than the built-in harness.
| Policy requirement (this guide) | plain cargo test | cargo nextest |
|---|---|---|
| CI retry budget 1–2 | none | retries = 2 |
| Pass-on-retry recorded as triage signal | invisible | reported as FLAKY, in JUnit |
| Per-test timeout (no hung CI jobs) | none (job-level only) | slow-timeout + terminate-after |
| Machine-readable output for issue filing | none stable | JUnit XML |
The retry budget maps to retries, the pass-on-retry triage signal to nextest's FLAKY report and JUnit output, the per-test timeout to slow-timeout + terminate-after, and the machine-readable issue-filing feed to JUnit XML. Each policy line in this guide has a direct nextest mechanism and no cargo test equivalent.
Config Sketch
Two files carry the policy. The nextest profile encodes the retry budget, the per-test timeout, and the JUnit telemetry, and it re-establishes the serialization that plain cargo test gave the suite for free:
# .config/nextest.toml
[profile.ci]
retries = 2 # the CI retry budget (1–2)
slow-timeout = { period = "60s", terminate-after = 4 } # per-test timeout
[profile.ci.junit]
path = "junit.xml" # flake telemetry / deduped issue filing
# Preserve the serialization the suite silently relied on under cargo test:
[test-groups]
heavy-serial = { max-threads = 1 }
[[profile.default.overrides]]
# every integration-test binary that boots a dev server / binds ports / embeds V8
filter = 'binary(dev_serve_e2e) | binary(dev_build_static_parity) | binary(dev_serve_injected_routes_e2e)'
test-group = 'heavy-serial'The CI step swaps cargo test for cargo nextest run — but nextest does not run doctests, so the doctest step has to be kept explicitly:
# CI
- uses: taiki-e/install-action@nextest # cargo nextest is not part of the default toolchain — install it first
- run: cargo nextest run --workspace --profile ci
- run: cargo test --doc --workspace # nextest does not run doctests — drop this step and doctest coverage silently disappearsWarning
Dropping the cargo test --doc step is a silent coverage loss. nextest never runs doctests; if the migration replaces cargo test with cargo nextest run and nothing else, every doctest stops executing and the suite still goes green. Keep the doctest run as its own step.
Migration Checklist (Mechanical)
Measure the doctest surface first — classify fences (runnable
rustvstext/ts/html/jsonthat rustdoc never runs); record the real count in the migration PR. Keepcargo test --doc --workspaceas a separate step. (Folklore counts are usually far higher than the measured count.)Enumerate implicit serialization —
cargo testruns test binaries sequentially; nextest parallelizes across binaries. List every integration-test binary that binds ports / boots servers / embeds a heavyweight runtime and put them in amax-threads = 1test-group before the first parallel run, not after the first flake storm. Optionally add an advisoryflockin the shared test-utils crate as defense-in-depth.Audit concurrency claims in comments — grep for the claimed mechanism (
flock, lock files, mutexes) and verify it actually exists before trusting it.Reconcile inventories — diff
cargo test -- --listvscargo nextest listper binary; explain every delta in the PR. Silent omission (a test nextest filters differently) is the main failure mode — a green migration PR running fewer tests is worse than no migration.Quarantine interop — the existing
#[ignore]quarantine Note maps cleanly: the T3 allowed-to-fail job becomescargo nextest run --run-ignored ignored-only(optionally name-filtered, same caveat as the current Note).
Note
Step 5 connects this recipe back to the quarantine pipeline in the Heavy Test Decision Rule: the #[ignore]-based quarantine described there runs under nextest as cargo nextest run --run-ignored ignored-only, with the same name-filter caveat.
Executing the Taxonomy
The reason-string taxonomy on Heavy Test Decision Rule § Same Rules, Rust Syntax classifies why a test carries #[ignore]. This section covers how nextest turns that classification into an actual execution lane.
--run-ignored: the Quarantine/Exam Lane
cargo nextest run --workspace --profile ci # T1: PR gate -- no quarantined tests; env-gated ignored tests use explicit lanes
cargo nextest run --workspace --profile exam --run-ignored ignored-only # T3: the quarantine/exam lane -- runs ONLY the ignored set
cargo nextest run --workspace --profile exam --run-ignored all # full-inventory sanity run -- everything, ignored and not; not a gateThe PR gate excludes quarantined tests by policy; selecting --profile ci alone does not distinguish quarantine from environment requirements. Tests ignored solely for environment requirements run under --profile ci in explicit, name-filtered --run-ignored ignored-only lanes, as the env-gate lanes in the worked YAML below show.
--run-ignored ignored-only is the nextest analogue of Playwright's --grep "@flaky" quarantine lane from Scheduled Re-exam — except it selects on the presence of #[ignore], not on the reason string's content. nextest never parses what is inside the quotes; the string is for humans and grep, not for nextest's own filtering. A workspace whose ignored set mixes heavy: and flaky: reasons runs both in the same ignored-only invocation, which erases a distinction the Playwright side keeps separate via independent --grep expressions. Narrow with a name filter when the two need to be judged differently:
# separate "expected to always pass, just slow" from "expected to sometimes fail"
cargo nextest run --profile exam --run-ignored ignored-only -E 'test(/^heavy_/)'
cargo nextest run --profile exam --run-ignored ignored-only -E 'test(/^flaky_/)'This generalizes the caveat the quarantine Note above already raises for flaky: alone: #[ignore] is a broader marker than any single reason across the whole taxonomy, not just against @flaky.
Test-Groups: Budget and Serialization Control in the Exam Lane
The Config Sketch above uses a test-groups entry (heavy-serial) to re-serialize integration binaries that bind ports. The exam lane needs the same mechanism for a different reason: it is allowed to be slow, but not unbounded — a heavy:-ignored set that saturates every thread on the runner turns "a few slow tests" into the exam lane becoming its own heavy-suite problem.
Add the new group to the existing [test-groups] table from the Config Sketch above -- a second [test-groups] header in the same file is invalid TOML, since a table cannot be declared twice:
# .config/nextest.toml -- inside the [test-groups] table already opened by the Config Sketch
heavy-serial = { max-threads = 1 } # existing, from the Config Sketch above
exam-heavy = { max-threads = 2 } # new: caps concurrency for `heavy:`-ignored tests specificallyThe exam profile itself is new, so it gets its own tables:
# .config/nextest.toml (new tables, appended after the Config Sketch above)
[profile.exam]
retries = 0 # exam is diagnostic, not gated -- no retry budget to hide a real fail
[profile.exam.junit]
path = "junit.xml" # same machine-readable feed the ci profile uses, for the filing script
[[profile.exam.overrides]]
filter = 'test(/^heavy_/)' # name-convention filter narrowing to the `heavy:` reason
test-group = 'exam-heavy'A dedicated [profile.exam] — not the ci profile with --run-ignored bolted on — matters because the exam lane's retry and threading defaults are legitimately different from the PR gate's: T1 wants a real retry budget (see Retry Budgets); the exam lane wants zero, so a pass is unambiguous and a fail is real.
The Allowed-to-Fail Scheduled Exam Job
The scheduled job mirrors the Playwright quarantine lane's exit-code-capture pattern from Scheduled Re-exam § Flake Telemetry Mechanics exactly: continue-on-error: true paired with if: failure() is a dead filing step there for the same reason it would be here — step-level continue-on-error rewrites the step's conclusion to success, so a downstream if: failure() never fires.
# .github/workflows/exam.yml
name: exam
on:
schedule:
- cron: "51 3 * * *" # off-minute on purpose -- see Scheduled Re-exam
workflow_dispatch:
permissions:
contents: read
issues: write
jobs:
exam:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: taiki-e/install-action@nextest
- name: Run the quarantine/exam lane (allowed-to-fail BY DESIGN)
id: exam
run: |
set +e
cargo nextest run --workspace --profile exam --run-ignored ignored-only
rc=$?
set -e
echo "rc=$rc" >> "$GITHUB_OUTPUT"
# Deliberate: this lane is green-by-design. The captured rc -- not the
# job status -- drives the filing step below.
exit 0
- name: File or update the tracking issue
if: steps.exam.outputs.rc != '0'
env:
GH_TOKEN: ${{ github.token }}
run: bash scripts/file-exam-issue.shfile-exam-issue.sh is the same one-tracking-issue-per-workflow pattern from Scheduled Re-exam — the dedup-by-label-and-title, comment-vs-create, and close-on-green logic all port unchanged. Only the failure-extraction step differs: parse the junit.xml produced by [profile.exam.junit] above instead of Playwright's JSON reporter.
After Migration: Optimize Without Losing Evidence
Everything above gets the suite migrated and correct. What follows is what the same suite needs once it is also the gate's critical path. Two things break here that a green run never reports: a single max-threads = 1 group quietly over-serializes classes that never actually contended, and the JUnit report that would prove what a run cost gets overwritten by the next command in the same job.
The worked numbers in this section come from one Rust gate, each labelled by lane and by clock: 63 #[ignore]d env-gate tests across three lanes, 5,807 workspace tests, and 457 required-features tests; 25 audited JUnit files holding 50,301 <testcase> records, with zero flaky, failure, and error records among them; an 18m30s required health job inside a 19m42s complete-gate wall time.
Split Serialization Along Proven Contention Classes
heavy-serial in the Config Sketch is one group because the migration had one question to answer: which binaries used to run one at a time? Once the suite is green, that group is usually holding two or more different contention classes together, and the whole group runs at the speed of their union.
In the worked gate, 41 V8/esbuild-heavy integration binaries shared one max-threads = 1 group. Twenty-four of them had adopted the process-level advisory flock from step 2 of the Migration Checklist above; the remaining seventeen were build-only binaries that never take it. Those two classes do not contend with each other -- only within themselves -- so two independent max-threads = 1 groups can admit one flock adopter and one build-only binary concurrently while preserving serialization inside each class:
# .config/nextest.toml -- REPLACES the Config Sketch's `heavy-serial` group and
# its single override. Paste this fragment in their place rather than beside
# them: a second [test-groups] header in the same file is invalid TOML, and a
# binary claimed by two overrides is a configuration error, not a wider group.
[test-groups]
heavy-serial-flock = { max-threads = 1 }
heavy-serial-build = { max-threads = 1 }
exam-heavy = { max-threads = 2 } # unchanged, from the exam lane above: its override still names this group
[[profile.default.overrides]]
# Class A: binaries that take the process-level advisory flock (checklist step 2).
filter = 'binary(dev_serve_e2e) | binary(dev_serve_injected_routes_e2e)'
test-group = 'heavy-serial-flock'
[[profile.default.overrides]]
# Class B: build-only binaries that never take the flock. Disjoint from Class A
# by construction -- no binary() name appears in both filters.
filter = 'binary(dev_build_static_parity) | binary(dev_build_asset_graph)'
test-group = 'heavy-serial-build'Three identical-SHA hosted runs of that split reduced the median required health job time from 22m22s to 18m54s -- the clock is that one job's own duration, not the gate's wall time -- while running 5,807 of 5,807 tests with zero retries and zero failures. That interval is this one wave's own start and end; the 18m30s at the top of this section is the same job clock after every later wave, so the two figures are different endpoints, not a disagreement.
The decision rule is narrow on purpose: split only when the two classes are independently proven not to contend. Proven means each class's isolation mechanism is identified and verified to exist (Migration Checklist step 3), not inferred from two groups happening to go green once. A split made on a guess reintroduces exactly the port and shared-state races the max-threads = 1 group was created to suppress, and it reintroduces them as intermittent failures rather than as a config error.
The worked case measured the required health job's wall-clock median and checked retries and test identity across identical-SHA hosted runs. It did not measure peak memory, so these results do not establish that the runner has enough memory to run both classes concurrently.
Accept the split on evidence, and state the evidence: the median over at least three identical-SHA hosted runs, with test identity unchanged (key the comparison on test identity, not test title), zero new retries, and every artifact still retained. The median is the right statistic for a claim about a job's typical duration; a claim about the tail is a different claim needing a different one -- flipping fullyParallel compares maxima over at least five attempts for exactly that reason, and this section does not displace it. Read the durations from job-level timestamps, and hold the retry budget fixed across every sample -- a split that buys wall clock by spending retry budget has bought nothing.
Enforce Heavy-Group Membership Mechanically
Both binary(...) filters above are handwritten lists, and handwritten lists drift. The failure is asymmetric: a stale entry naming a deleted binary is loud, while a newly added integration test that nobody classified is silent -- it lands in the default group, runs fully parallel against the very binaries the groups exist to serialize, and the suite stays green until it does not.
The guard is a check that runs in the same gate, over five steps:
Derive the candidate set from every integration-test source file -- the
tests/*.rstargets across the workspace, not the#[ignore]set and not a second handwritten list. A guard fed from the config it is guarding proves nothing.Subtract an explicit exclusions list, one entry per deliberately unclassified binary, each carrying a reviewed reason on the line above it. The worked gate has five reviewed exclusions; the reasons are the artifact, not the count.
Require exact equality with the union of the two configured
binary(...)filters. The derived set minus the exclusions must equal that union in both directions.Require the two groups to be disjoint. Their intersection must be empty, so no binary can be silently claimed by whichever override nextest evaluates first.
Pin the shared-target test-predicate classes -- the
test(...)predicates that select across binaries -- so a new predicate cannot rehome a binary out of its group without failing this check.
Steps 3 and 4 are the load-bearing pair. A one-sided assertion -- "every configured binary(...) name resolves to a real binary" -- passes forever while unclassified sources pile up on the other side of the equality. This is the same bidirectional-inventory shape as Ignored-Test-Manifest Parity, where a stale manifest row and a newly homeless test have to fail identically, and as Counterweight 3: Manifest Parity, where a per-chunk assertion cannot notice a chunk that stopped running at all.
Stash Every JUnit Report Before the Next Same-Profile Run
[profile.<name>.junit] path resolves under target/nextest/<profile>/, so every run of a given profile writes the same file. Two sequential cargo nextest run --profile ci ... commands in one job therefore leave exactly one report: the last producer's. The worked gate runs five same-profile commands -- three --run-ignored ignored-only env-gate lanes, the --workspace run, and the required-features run -- so four of its five reports would vanish without a move between each pair:
# .github/workflows/health.yml (excerpt) -- five same-profile nextest runs in one
# job. Each report is moved out of target/nextest/ci/ BEFORE the next run
# overwrites it, because the profile writes the same path every time.
- run: cargo nextest run --profile ci --run-ignored ignored-only -E 'test(/^env_gate_/)'
env:
ENV_GATE: docker
- if: always() # keep the report from a FAILED run too -- that is the one worth reading
run: mkdir -p reports && mv target/nextest/ci/junit.xml reports/env-gate-docker.xml
- run: cargo nextest run --profile ci --run-ignored ignored-only -E 'test(/^env_gate_/)'
env:
ENV_GATE: network
- if: always()
run: mkdir -p reports && mv target/nextest/ci/junit.xml reports/env-gate-network.xml
- run: cargo nextest run --profile ci --run-ignored ignored-only -E 'test(/^env_gate_/)'
env:
ENV_GATE: credentials
- if: always()
run: mkdir -p reports && mv target/nextest/ci/junit.xml reports/env-gate-credentials.xml
- run: cargo nextest run --workspace --profile ci
- if: always()
run: mkdir -p reports && mv target/nextest/ci/junit.xml reports/workspace.xml
- run: cargo nextest run --workspace --profile ci --all-features # builds the targets gated behind [[test]] required-features
- if: always()
run: mkdir -p reports && mv target/nextest/ci/junit.xml reports/required-features.xml
- if: always()
uses: actions/upload-artifact@v4
with:
name: junit-reports
path: reports/
if-no-files-found: error # an empty upload is the silent failure this block exists to preventif: always() on each stash step is the point of the pattern, not a decoration: the lane that fails is the lane whose report you most want, and without it the move is skipped and the evidence dies with the step. Pair each stash with its own producer rather than collecting all five at the end -- after a failure the later lanes never run, and a single trailing collector would only ever see whichever report happened to be last on disk.
The upload is unconditional here, which is worth stating because the nearest rule on this site points the other way. Playwright traces are gated on failure() || cancelled() because a full trace directory from every green run burns the storage cap for no diagnostic gain. JUnit reports are kilobytes, and they are the retry evidence for green runs too -- the formulas in the next section have nothing to read otherwise -- so they upload every time. The shared rule is that the upload condition is a decision made per artifact class, never a default inherited from another page. And if-no-files-found: error is what turns "every move silently no-op'd" into a red job instead of an empty artifact.
The overwrite itself is the same trap in a different tool: a second Playwright run empties outputDir before the upload. Same shape, same fix -- move the artifact out of the tool's own output directory before the tool runs again.
One caveat belongs with the block above rather than with its reports: the --all-features run in it builds the maximal feature union of the workspace, which is a narrower claim than it looks -- see Crate Isolation and Feature Ownership for what a union build cannot say about any individual crate's manifest.
Price Retries From the Real nextest JUnit Shape
A <testcase> that reads as a pass can still have cost three attempts, and <flakyFailure> is only one of the four elements that carry that cost. A parser that looks for <flakyFailure> alone prices a plain retried failure at zero.
<!-- Elided for shape: the <testsuites>/<testsuite> wrappers, the timestamp
attributes, the <system-out>/<system-err> children, and the full captured
panic text that each element body actually carries. -->
<!-- flaky PASS. The testcase is a success and its time is the PASSING attempt.
Every earlier attempt nests as flakyFailure -- flakyError for an execution
failure or a leak -- each with its own time. -->
<testcase name="test_flaky_mod_4" classname="fixture-project::basic" time="0.004">
<flakyFailure time="0.004" type="test failure">attempt 1 panicked</flakyFailure>
<flakyFailure time="0.005" type="test failure">attempt 2 panicked</flakyFailure>
</testcase>
<!-- flaky FAIL under the default flaky-fail-status = "failure": a failure element
whose type is the literal "flaky failure", PLUS the same nested
flakyFailure/flakyError set. testcase@time is the PASSING attempt here too.
Under flaky-fail-status = "success" this testcase is indistinguishable from
the flaky PASS above while the run itself still fails -- pin the knob or the
report cannot be read at all. -->
<testcase name="test_known_flaky" classname="fixture-project::basic" time="0.006">
<failure type="flaky failure">flaky: passed only on a retry</failure>
<flakyFailure time="0.004" type="test failure">attempt 1 panicked</flakyFailure>
<flakyFailure time="0.005" type="test failure">attempt 2 panicked</flakyFailure>
</testcase>
<!-- plain FAILURE with retries: failure/error plus one rerunFailure or rerunError
per retry. Here testcase@time is the FIRST attempt, not the last -- the
opposite end of the sequence from the two flaky cases above. -->
<testcase name="test_failure_assert" classname="fixture-project::basic" time="0.004">
<failure type="test failure">assertion failed: left == right</failure>
<rerunFailure time="0.004" type="test failure">assertion failed: left == right</rerunFailure>
<rerunFailure time="0.004" type="test failure">assertion failed: left == right</rerunFailure>
</testcase>Two formulas, one input. For any <testcase>:
Retry-inclusive time =
testcase@timeplus the sum of@timeover its nestedflakyFailure,flakyError,rerunFailure, andrerunErrorchildren.Retry tax = that nested sum on its own.
testcase@time is always exactly one attempt and never a sum, in all three shapes above. A dashboard that reads it as the test's total execution time under-reports every retried test by the whole retry tax.
Captured from cargo-nextest 0.9.138 -- re-check the element set against whatever version you pin; flaky-fail-status (0.9.131+) and report-skipped (0.9.143+) change the shape.
A parser for any of this needs a real captured report as its fixture, and here that rule has a sharp edge. The 25 audited reports from the worked gate hold 50,301 <testcase> records with zero flaky, failure, and error records among them -- a healthy suite emits no nested retry elements at all, so nothing in a normal artifact haul exercises either formula. Capture the fixture deliberately: force a test to fail on attempt 1 under retries >= 1 and keep that run's junit.xml. Failing that, label the sample as reproduced from nexte.st's documentation rather than captured from your own suite -- a synthetic shape you assumed is exactly what the fixture rule exists to keep out of the test.
Crate Isolation and Feature Ownership
cargo nextest run --workspace --profile ci --all-features is the widest build the gate runs, and its width is what makes it easy to over-read. Cargo resolves a dependency's feature set once, unified across every workspace member selected for that build; --all-features then switches on every feature every selected member declares. What the run proves is that the resulting union compiles and passes. It does not prove that any individual crate's Cargo.toml declares the features that crate's own code needs. A crate can use a feature it never asked for and stay green for years, because a sibling asked for it.
Setting resolver = "2" does not close this. The v2 resolver stops unifying features across dependency kinds -- normal versus build versus dev -- and across target-specific edges. It still unifies across every selected workspace member, and that is the unification this failure rides on.
The Sibling That Owns Your Feature
One crate called reqwest::blocking::Response::json() while its own manifest declared only blocking and rustls-tls. A sibling crate -- one that does not depend on it, is not depended on by it, and shares nothing with it but the workspace -- declared reqwest/json. Every --workspace build and every --all-features run was green, for as long as the sibling kept that feature. The break arrived as an unrelated cleanup: the sibling stopped needing JSON, json left its feature list, and the crate that actually called json() stopped compiling. That crate had not changed in months.
Two manifests reproduce it:
# consumer/Cargo.toml -- calls Response::json(), and never declares `json`
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }# sibling/Cargo.toml -- unrelated to consumer, and the only crate that owns `json`
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json"] }cargo check --workspace # green: one reqwest, resolved with blocking + rustls-tls + json
cargo check -p consumer # RED: `Response::json` is behind a feature consumer never declaredThe second line is the whole finding. Nothing about consumer differs between the two commands -- only which packages were selected, and therefore whose feature declarations got folded together.
Isolated cargo check -p Lanes Are the Detection
Narrowing the package selection is what turns the latent defect into a compile error, so the gate needs lanes that compile the relevant crates as selected packages rather than only as members of the workspace union:
# .github/workflows/health.yml (excerpt) -- isolated manifest lanes. They run IN
# ADDITION to the --workspace run above, never instead of it: that run proves the
# union builds, these prove each manifest stands up on its own.
- run: cargo check -p consumer --all-targets
- run: cargo check -p sibling --all-targets--all-targets is load-bearing. A bare cargo check -p compiles the lib and the bins; a feature used only from tests/ or benches/ is exactly as unowned as one used from src/, and without the flag those targets are never compiled to find out.
Pick the crates by contract, not by count. A manifest is worth an isolated lane when something outside your workspace will believe it: anything you cargo publish, anything a downstream consumer builds without your Cargo.lock, and anything that ships inside a runtime artifact. An isolated lane for every internal helper crate in a forty-crate workspace buys a much smaller guarantee for a much larger share of the gate's clock, which is the aggregate suite budget argument applied to check lanes rather than test lanes.
Where the isolated selection resolves a different feature set -- which is exactly the case the lane exists to catch -- it cannot reuse the workspace build's artifacts, and that is not a caching bug to fix: a different feature set is a different fingerprint, so reqwest compiles again for that selection. That recompile is the price of the check, and it is the reason the lane list is a short reviewed set rather than every member of the workspace.
None of this replaces the --workspace --all-features run. That run is still the only lane that builds the targets gated behind [[test]] required-features, and still the only one that catches code which compiles only when everything is on. The two claims are complementary and neither implies the other -- the union lane proves the maximum works, the isolated lanes prove each part works alone.
--no-default-features and Matrices Only Where the Contract Is Real
cargo check -p <crate> --no-default-features is a second lane, not a better one. It answers a different question: does the crate still build when a consumer turns the defaults off? That question is only worth gating when defaults-off is a combination you actually support -- a no_std build, a crate documented as default-features = false-friendly, a backend a consumer is expected to choose between. Add the lane where the README makes the promise. Do not add it because it is cheap to type.
The same rule governs feature matrices. cargo hack --each-feature and --feature-powerset will enumerate combinations for you, and on a crate with eight optional features the powerset is 256 compiles of configurations nobody supports and nobody will fix. Enumerate the documented contracts explicitly instead, so the list stays reviewable and every entry has an owner:
cargo check -p consumer --no-default-features # documented: builds with defaults off
cargo check -p consumer --no-default-features --features rustls-tls # documented: consumer's own rustls backend flag
cargo check -p consumer --no-default-features --features native-tls # documented: consumer's own native-tls flagA speculative combination that goes red is not a bug report. It is a failure nobody agreed to support, and the gate that raises it teaches the team to read red as noise -- the same false-signal cost any unactionable assertion carries.
cargo tree -e features Explains, It Does Not Gate
Once a lane is red, the question is which package turned the feature on. cargo tree with feature edges answers it, and the answer changes with the package selection -- which is precisely why it diagnoses rather than gates:
cargo tree -p consumer -e features # the feature edges consumer resolves on its own
cargo tree --workspace -e features -i reqwest # inverted: who in the workspace enables each reqwest featureThe first invocation shows the graph the isolated lane compiles; the second shows the union the workspace build compiles, with the enabling package named on the inverted edge. Reading the two side by side is the explanation of the failure. Neither is the detection: a tree is output to be interpreted by a person, and a gate has to be a command whose exit code is the verdict. Keep cargo tree in the triage notes and the isolated cargo check -p lane in the workflow.
Where to Go Next
Heavy Test Decision Rule — the full
#[ignore]reason-string taxonomy (env-gate:,heavy:,flaky:,verification:,pending-feature:) this nextest config executes, and how a Rust flake is quarantinedExecution Tiers — the retry budget this migration exists to make implementable on a Rust suite
Flake Root-Cause Catalog & Deflaking Recipe — the native-suite flake causes (port races, implicit serialization, shared state) that surface the moment nextest parallelizes across binaries
CI Gate Optimization — the gate-wide protocol this section's split experiment is one wave of: naming the clock, the median-of-three sample rule, and the identity proof that the faster suite still runs the same tests