Mehdi Akiki
Rust Failure Atlas / Cargo and dependencies

RFA-714 · Case file with fixtures · Case 686 of 694 · Cargo workspace evidence

Cargo build.warnings Can Turn Local Lints Into Build Errors

Cargo 1.97 added build.warnings for local packages. The deny setting promotes adjustable lint warnings to errors without adding -Dwarnings to every rustc invocation, but its scope and configuration discovery matter.

Reviewed
Rust
Rust 1.98.1, Cargo 1.98.1, edition 2024
Targets
all Cargo targets
Profiles
dev, release, test

Direct answer

What this Rust failure means

Why it happens
Cargo 1.97 and later can raise adjustable lint warnings for local packages to errors through build.warnings, independently of source-level lint attributes.
First discriminating check
Inspect merged Cargo configuration and CARGO_BUILD_WARNINGS, then identify whether the message is an adjustable lint from a local package before changing code or policy.

An ordinary warning can now make a Cargo build fail without any #![deny(warnings)] in the Rust source:

# .cargo/config.toml
[build]
warnings = "deny"

With Cargo 1.97 or later, this setting raises adjustable lint warnings in local packages to errors. A dead function is enough:

fn unused_probe() {}

fn main() {
    println!("warning policy is active");
}

Cargo prints the normal dead_code diagnostic, then explains that warnings are denied by build.warnings configuration.

The failing package, its Cargo manifest, and its Cargo configuration make this boundary executable. The repaired package uses the function while keeping the same deny policy. The Atlas verifier passes each configuration explicitly, so the result does not depend on a developer's home-directory settings.

The policy is outside the crate source

Rust lint levels can come from attributes, command-line flags, Cargo manifest lint tables, and now this Cargo build configuration. The source line which emits the warning may be correct in isolation while the merged build policy promotes it.

This is why I do not start by adding #[allow(dead_code)] when CI says a warning is an error. I first locate the policy owner.

build.warnings accepts three documented values:

  • "warn" keeps the normal warning behavior;
  • "allow" hides adjustable lint warnings;
  • "deny" fails a local crate which emitted lint warnings.

The setting can also come from CARGO_BUILD_WARNINGS. A CI environment can therefore differ from a local shell even when both use the same commit and toolchain.

“Local packages” is an important boundary

Cargo applies this setting to local packages rather than turning every dependency warning into my build failure. That makes the option useful for workspace quality gates without making a third-party release instantly unbuildable because of a warning under a newer compiler.

The word local still needs a concrete check in a complex build. Workspace members, path packages, registry dependencies, build scripts, proc macros, and host/target units can travel through different compilation paths. When behavior surprises me, I run Cargo with verbose output and identify the exact package and rustc invocation.

Cargo's documentation also distinguishes lint warnings from non-lint warnings. build.warnings changes adjustable lint levels. It is not a universal filter for every message labelled warning by a compiler, linker, build script, or native tool.

Configuration discovery can explain local/CI disagreement

Cargo configuration is hierarchical. It looks for .cargo/config.toml from the current working directory and its ancestors, then merges applicable sources with environment and command-line configuration.

One subtle case is invoking a manifest elsewhere:

cargo check --manifest-path path/to/project/Cargo.toml

The manifest path does not mean Cargo starts configuration discovery inside that project directory. A command launched from another directory can miss the project's nearby .cargo/config.toml unless the caller changes directory or passes the configuration explicitly.

The evidence verifier deliberately uses --config with the downloaded file. In a normal repository I usually run from the workspace root, where a checked-in .cargo/config.toml is discoverable. In automation I make the working directory explicit.

I also check for both .cargo/config and .cargo/config.toml, user-level Cargo configuration, and the CARGO_BUILD_WARNINGS environment value. The effective policy is the merged result, not whichever file I opened first.

Denying every warning has a maintenance cost

A warning-free local codebase is valuable, but new Rust versions can add lints or improve existing analysis. A toolchain update can therefore turn unrelated source into a build break. That may be exactly what the team wants, but it should be an owned upgrade step.

For an application with a pinned toolchain, deny gives a predictable quality gate. For a library testing a wide MSRV-to-stable matrix, I may keep one required lane warning-clean and allow diagnostic discovery in another. The policy should match who controls the compiler version and how quickly warnings can be repaired.

I avoid blanket source-level allow(warnings). It hides future diagnostics across the crate and can make a temporary compatibility workaround permanent. When one lint is intentionally accepted, I allow that named lint at the narrowest useful scope and leave a reason.

Why this is better than an ambient RUSTFLAGS workaround

Teams have often used RUSTFLAGS=-Dwarnings. That flag can reach more units than intended, affect dependency reuse, and become difficult to compose with target-specific flags. Its presence may be invisible in a developer shell.

build.warnings gives Cargo a named policy with documented local-package semantics. It is easier to store in project configuration and easier for Cargo to explain in the final error.

Manifest lint configuration can also be appropriate when lint policy belongs to a particular package or workspace and should be published with package metadata. I choose the owner deliberately instead of stacking all mechanisms.

My investigation checklist

When warnings unexpectedly fail a build, I record:

  1. Cargo and rustc versions;
  2. the current working directory and manifest path;
  3. every discovered Cargo configuration layer;
  4. CARGO_BUILD_WARNINGS, RUSTFLAGS, and encoded flags;
  5. whether the diagnostic is an adjustable Rust lint;
  6. whether the emitting package is local;
  7. whether the policy should be repaired or the code should be repaired.

The last question matters. The Atlas repaired fixture uses the dead function, because weakening the quality gate would not address that warning. In other cases a generated or target-specific item may deserve a narrow lint configuration instead.

The core principle is that build behavior includes policy inputs outside source code. Reproducible diagnosis needs the Rust file, manifest, Cargo configuration, environment, working directory, and toolchain—not only the line where the warning points.