diff --git a/.anvil.lock b/.anvil.lock
index eb10892f4..7d0f19e69 100644
--- a/.anvil.lock
+++ b/.anvil.lock
@@ -1,7 +1,7 @@
version = 1
tool = "anvil"
-tool_version = "0.11.1"
-catalog_checksum = "sha256:50ec8969dd7f576cef150439b044e29cef03a1c72c0f1838197cd5bb6d8a6b9e"
+tool_version = "0.12.0"
+catalog_checksum = "sha256:b097d6ce3927a9bf4acb84ec0d17f18d32a4fc0fd46ea999ea14feab96634dd9"
[[file]]
path = ".anvil/container/Dockerfile.dockerignore"
@@ -141,7 +141,7 @@ checksum = "sha256:bab029c5ca3be78fcea2d6af02763a0e0640722dfe1431a0eaa541801a981
[[file]]
path = "justfiles/anvil/checks/miri-tree-borrows.just"
-checksum = "sha256:ea3349941fd3cf3811bce48a679490fe466dddafdb6d0955c1f5471155669f79"
+checksum = "sha256:65f7a333394fc9ce48a54c281b6b87b28cc622710fb5c985cdd5292921da490e"
[[file]]
path = "justfiles/anvil/checks/miri.just"
@@ -288,8 +288,18 @@ checksum = "sha256:b91854c7f0e6d14c74751f607d2c0e3dfcefef55f5178bbafae59d47e577a
[[region]]
host = "Cargo.toml"
-id = "anvil-workspace-lints"
-checksum = "sha256:c0d399b21c665de0831143a3d78715ceb38f1d22511d8705f164aeb62e12f174"
+id = "anvil-workspace-clippy-lints"
+checksum = "sha256:cf78b123235b694a8222e499e7ba91729de9325d1de3f5275e58a85880fabc4e"
+
+[[region]]
+host = "Cargo.toml"
+id = "anvil-workspace-rust-lints"
+checksum = "sha256:ade16a15a1ecfbe6a8f98aca044266f570a7e658bef51f4b3f6e23d8ebd14798"
+
+[[region]]
+host = "Cargo.toml"
+id = "anvil-workspace-rustdoc-lints"
+checksum = "sha256:5040c38d64a80ddedc2c51d25881841a1670de77e68bbd14b67ac66fd0062091"
[[region]]
host = "Justfile"
@@ -299,7 +309,7 @@ checksum = "sha256:f8affd59b69c7083c2f3b6f593c63672116dda974c1e661dcb66a4412eb3e
[[region]]
host = "clippy.toml"
id = "anvil-clippy"
-checksum = "sha256:aba0733632eac4cb54c4768db578fe1f7b7cfe730aa0d7dc13e2828c9062d67d"
+checksum = "sha256:0c37975f24d538358d792626e32d67a043c3a892d076ddf90267ce6757b3245b"
[[region]]
host = "crates/automation/Cargo.toml"
diff --git a/Cargo.lock b/Cargo.lock
index a7b65564e..6fe865cf5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -427,7 +427,7 @@ dependencies = [
[[package]]
name = "cargo-anvil"
-version = "0.11.1"
+version = "0.12.0"
dependencies = [
"assert_cmd",
"clap",
diff --git a/Cargo.toml b/Cargo.toml
index 9fe00d78c..382da0550 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -122,104 +122,94 @@ wiremock = { version = "0.6.5", default-features = false }
yaml_serde = { version = "0.10.7", default-features = false }
zstd = { version = "0.14.0", default-features = false }
-# >>> anvil-managed: anvil-workspace-lints
-[workspace.lints]
-# Catalog of opinionated lints, in dotted-key form so users can extend the
-# same scope (`[workspace.lints]` or `[lints]`) outside the sentinels.
-# The host-specific table header (`[workspace.lints]` or `[lints]`) is
-# prepended by cargo-anvil based on whether the manifest is a workspace
-# root or a single-crate Cargo.toml.
-
-# --- rust ------------------------------------------------------------------
-rust.ambiguous_negative_literals = "warn"
-rust.missing_debug_implementations = "warn"
-rust.redundant_imports = "warn"
-rust.redundant_lifetimes = "warn"
-rust.trivial_numeric_casts = "warn"
-rust.unsafe_op_in_unsafe_fn = "warn"
-rust.unused_lifetimes = "warn"
+# >>> anvil-managed: anvil-workspace-rust-lints
+[workspace.lints.rust]
+# Rust compiler lint policy. Repository-specific Rust lints can follow this
+# region as bare keys in the same table.
+ambiguous_negative_literals = "warn"
+missing_debug_implementations = "warn"
+redundant_imports = "warn"
+redundant_lifetimes = "warn"
+trivial_numeric_casts = "warn"
+unsafe_op_in_unsafe_fn = "warn"
+unused_lifetimes = "warn"
# `unexpected_cfgs` is on-by-default at warn since Rust 1.80; combined
# with the catalog's `-D warnings` cloud-workflow policy, any custom cfg name
# becomes a hard build failure. Pre-declare the cfgs that
# `cargo llvm-cov` sets so the recommended coverage-exclusion pattern
# `#[cfg_attr(coverage_nightly, coverage(off))]` works out of the box.
# Adopters who need additional cfg names take ownership of this one
-# line (edit the check-cfg array); anvil's drift detector will
+# assignment (edit the check-cfg array); anvil's drift detector will
# emit a `.anvil-proposed` sibling on future catalog bumps so the
# customization is preserved.
-rust.unexpected_cfgs = { level = "warn", check-cfg = [
+unexpected_cfgs = { level = "warn", check-cfg = [
'cfg(coverage,coverage_nightly)',
'cfg(loom)',
'cfg(miri_race_coverage)',
'cfg(miri_strict_provenance)',
'cfg(miri_tree_borrows)',
] }
-
-# --- rustdoc ---------------------------------------------------------------
-rustdoc.broken_intra_doc_links = "warn"
-rustdoc.missing_crate_level_docs = "warn"
-rustdoc.unescaped_backticks = "warn"
-
-# --- clippy: category gates (priority -1 so per-lint allows can override) --
-clippy.cargo = { level = "warn", priority = -1 }
-clippy.complexity = { level = "warn", priority = -1 }
-clippy.correctness = { level = "warn", priority = -1 }
-clippy.nursery = { level = "warn", priority = -1 }
-clippy.pedantic = { level = "warn", priority = -1 }
-clippy.perf = { level = "warn", priority = -1 }
-clippy.style = { level = "warn", priority = -1 }
-clippy.suspicious = { level = "warn", priority = -1 }
-
-# --- clippy: opinionated additions -----------------------------------------
+# <<< anvil-managed: anvil-workspace-rust-lints
+
+# >>> anvil-managed: anvil-workspace-rustdoc-lints
+[workspace.lints.rustdoc]
+# Rustdoc lint policy. Repository-specific rustdoc lints can follow this
+# region as bare keys in the same table.
+broken_intra_doc_links = "warn"
+missing_crate_level_docs = "warn"
+unescaped_backticks = "warn"
+# <<< anvil-managed: anvil-workspace-rustdoc-lints
+
+# >>> anvil-managed: anvil-workspace-clippy-lints
+[workspace.lints.clippy]
+# Clippy category gates use priority -1 so per-lint allows can override them.
+cargo = { level = "warn", priority = -1 }
+complexity = { level = "warn", priority = -1 }
+correctness = { level = "warn", priority = -1 }
+nursery = { level = "warn", priority = -1 }
+pedantic = { level = "warn", priority = -1 }
+perf = { level = "warn", priority = -1 }
+style = { level = "warn", priority = -1 }
+suspicious = { level = "warn", priority = -1 }
# Two-repo consensus (oxidizer + oxidizer-github). Restriction-group
# lints that catch real code-smell cases. Adding a workspace-wide lint
# means adopters can only opt out per-crate or by taking ownership of
# this region; only enable when the consensus is strong enough to
# justify that cost.
-clippy.allow_attributes = "warn"
-clippy.allow_attributes_without_reason = "warn"
-clippy.as_pointer_underscore = "warn"
-clippy.assertions_on_result_states = "warn"
-clippy.clone_on_ref_ptr = "warn"
-clippy.deref_by_slicing = "warn"
-clippy.disallowed_script_idents = "warn"
-clippy.empty_drop = "warn"
-clippy.empty_enum_variants_with_brackets = "warn"
-clippy.fn_to_numeric_cast_any = "warn"
-clippy.if_then_some_else_none = "warn"
-clippy.map_err_ignore = "warn"
-clippy.multiple_unsafe_ops_per_block = "warn"
-clippy.redundant_type_annotations = "warn"
-clippy.renamed_function_params = "warn"
-clippy.semicolon_outside_block = "warn"
-clippy.undocumented_unsafe_blocks = "warn"
-clippy.unnecessary_safety_comment = "warn"
-clippy.unnecessary_safety_doc = "warn"
-clippy.unneeded_field_pattern = "warn"
-clippy.unused_result_ok = "warn"
-clippy.unwrap_used = "warn"
-
-# --- clippy: opinionated suppressions of category-enabled lints ------------
-clippy.missing_const_for_fn = "allow"
-clippy.multiple_crate_versions = "allow"
-clippy.option_if_let_else = "allow"
-clippy.redundant_pub_crate = "allow"
-clippy.should_panic_without_expect = "allow"
-clippy.significant_drop_tightening = "allow"
+allow_attributes = "warn"
+allow_attributes_without_reason = "warn"
+as_pointer_underscore = "warn"
+assertions_on_result_states = "warn"
+clone_on_ref_ptr = "warn"
+deref_by_slicing = "warn"
+disallowed_script_idents = "warn"
+empty_drop = "warn"
+empty_enum_variants_with_brackets = "warn"
+empty_structs_with_brackets = "warn"
+fn_to_numeric_cast_any = "warn"
+if_then_some_else_none = "warn"
+map_err_ignore = "warn"
+multiple_unsafe_ops_per_block = "warn"
+redundant_type_annotations = "warn"
+renamed_function_params = "warn"
+semicolon_outside_block = "warn"
+undocumented_unsafe_blocks = "warn"
+unnecessary_safety_comment = "warn"
+unnecessary_safety_doc = "warn"
+unneeded_field_pattern = "warn"
+unused_result_ok = "warn"
+unwrap_used = "warn"
+# Literal braces are valid data in templates and structured-logging messages.
+literal_string_with_formatting_args = "allow"
+missing_const_for_fn = "allow"
+multiple_crate_versions = "allow"
+option_if_let_else = "allow"
+redundant_pub_crate = "allow"
+should_panic_without_expect = "allow"
+significant_drop_tightening = "allow"
# Blocked by Clippy bug: https://github.com/rust-lang/rust-clippy/issues/15036
-clippy.wildcard_imports = "allow"
-# <<< anvil-managed: anvil-workspace-lints
-
-# --- clippy: catalog entries this project adds outside the managed region ---
-# The dotted-key form above keeps `[workspace.lints]` open, so these extend the
-# same scope without editing the anvil-managed block and provoking its drift
-# detector. They complete the `M-STATIC-VERIFICATION` catalog, which names all
-# three: https://microsoft.github.io/rust-guidelines/guidelines/universal/#M-STATIC-VERIFICATION
-clippy.empty_structs_with_brackets = "warn"
-clippy.too_long_first_doc_paragraph = "warn"
-# The guideline's own opt-out: a structured-logging call site legitimately
-# passes a literal holding `{field}` placeholders for the logger to expand.
-clippy.literal_string_with_formatting_args = "allow"
+wildcard_imports = "allow"
+# <<< anvil-managed: anvil-workspace-clippy-lints
# A bit of debugging support for release builds.
[profile.release]
diff --git a/clippy.toml b/clippy.toml
index c3292d956..569b4011b 100644
--- a/clippy.toml
+++ b/clippy.toml
@@ -28,7 +28,7 @@ allow-panic-in-tests = true
allow-unwrap-in-tests = true
# Aspirational: when clippy.wildcard_imports is re-enabled (currently
-# allowed in cargo-lints-body.toml due to upstream bug rust-clippy#15036),
+# allowed in cargo-clippy-lints.toml due to upstream bug rust-clippy#15036),
# we want the stricter variant that warns on ALL wildcard imports
# including prelude. Setting it now means flipping the lint level to
# warn later is a one-line change with no tuning afterthought.
diff --git a/crates/cargo-anvil/CHANGELOG.md b/crates/cargo-anvil/CHANGELOG.md
index 9d74c8f4b..b6457634b 100644
--- a/crates/cargo-anvil/CHANGELOG.md
+++ b/crates/cargo-anvil/CHANGELOG.md
@@ -2,6 +2,25 @@
## [Unreleased]
+## [0.12.0] - 2026-09-18
+
+- โจ Features
+
+ - split managed lint regions
+ - lint empty braced structs
+
+- ๐ Bug Fixes
+
+ - scope coverage reports to affected packages ([#190](https://github.com/microsoft/ox-tools/pull/190))
+
+- โป๏ธ Code Refactoring
+
+ - centralize lint overrides
+
+- ๐งฉ Miscellaneous
+
+ - Merge origin/main into feat/anvil-empty-struct-lint
+
## [0.11.1] - 2026-09-21
- ๐ Bug Fixes
diff --git a/crates/cargo-anvil/Cargo.toml b/crates/cargo-anvil/Cargo.toml
index 05b807591..4be9c4dc7 100644
--- a/crates/cargo-anvil/Cargo.toml
+++ b/crates/cargo-anvil/Cargo.toml
@@ -3,7 +3,7 @@
[package]
name = "cargo-anvil"
-version = "0.11.1"
+version = "0.12.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
diff --git a/crates/cargo-anvil/README.md b/crates/cargo-anvil/README.md
index e5a2e9a90..96d039bb3 100644
--- a/crates/cargo-anvil/README.md
+++ b/crates/cargo-anvil/README.md
@@ -400,7 +400,7 @@ More detailed design and operational guidance is available in the
This crate was developed as part of The Oxidizer Project. Browse this crate's source code.
- [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjNhdIQblRYhli3L8qob_NSi_WYo69wbWnMVqZw3jJwb3u56HnT6RDphYvRhcoQbBALu36V1VAYbFUDdfp-8dCobobFjKbRep8AbYNMPgi_aMhFhZIGDa2NhcmdvLWFudmlsZjAuMTEuMWtjYXJnb19hbnZpbA
+ [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjNhdIQblRYhli3L8qob_NSi_WYo69wbWnMVqZw3jJwb3u56HnT6RDphYvRhcoQbBALu36V1VAYbFUDdfp-8dCobobFjKbRep8AbYNMPgi_aMhFhZIGDa2NhcmdvLWFudmlsZjAuMTIuMGtjYXJnb19hbnZpbA
[__link0]: https://github.com/casey/just
[__link1]: https://rust-lang.github.io/rustfmt/
[__link10]: https://embarkstudios.github.io/cargo-deny/
@@ -419,9 +419,9 @@ This crate was developed as part of The Oxidizer Project. Br
[__link22]: https://mutants.rs/
[__link23]: https://crates.io/crates/cargo-hack
[__link24]: https://crates.io/crates/cargo-coverage-gate
- [__link25]: https://docs.rs/cargo-anvil/0.11.1/cargo_anvil/?search=Catalog::anvil
- [__link26]: https://docs.rs/cargo-anvil/0.11.1/cargo_anvil/?search=Artifact
- [__link27]: https://docs.rs/cargo-anvil/0.11.1/cargo_anvil/fn.run_app.html
+ [__link25]: https://docs.rs/cargo-anvil/0.12.0/cargo_anvil/?search=Catalog::anvil
+ [__link26]: https://docs.rs/cargo-anvil/0.12.0/cargo_anvil/?search=Artifact
+ [__link27]: https://docs.rs/cargo-anvil/0.12.0/cargo_anvil/fn.run_app.html
[__link3]: https://crates.io/crates/cargo-sort
[__link4]: https://crates.io/crates/cargo-heather
[__link5]: https://crates.io/crates/cargo-ensure-no-cyclic-deps
diff --git a/crates/cargo-anvil/docs/design/README.md b/crates/cargo-anvil/docs/design/README.md
index 4140f15ff..88075ddd3 100644
--- a/crates/cargo-anvil/docs/design/README.md
+++ b/crates/cargo-anvil/docs/design/README.md
@@ -117,8 +117,9 @@ Corollaries that drive every section below:
- Drift detection lives inside the files themselves (per-file checksums and per-managed-region
checksums). There is no parallel metadata file. See [updates.md](./updates.md).
- The tool inserts managed sections into the user's `Justfile` and into a small set of shared
- config files (`deny.toml`, `[workspace.lints]` in the workspace `Cargo.toml`, and `[lints]`
- in each crate's `Cargo.toml`, plus `.delta.toml` and `rustfmt.toml`). Outside those sections,
+ config files (`deny.toml`, separate `[workspace.lints.]` regions in the workspace
+ `Cargo.toml`, and `[lints]` in each member crate's `Cargo.toml`, plus `.delta.toml` and
+ `rustfmt.toml`). Outside those sections,
the user's content is preserved verbatim. Everything else is in tool-owned files under
`justfiles/anvil/` and the backend-specific cloud workflows directories.
@@ -255,7 +256,7 @@ repo/
โโโ Justfile managed-region: anvil-imports
โโโ justfiles/anvil/ owned (see local.md)
โโโ .anvil/container/ owned โ the container image definition (see containers.md)
-โโโ Cargo.toml managed-region: anvil-workspace-lints (or anvil-lints in single-crate)
+โโโ Cargo.toml managed-regions: anvil-{workspace-,}{rust,rustdoc,clippy}-lints
โโโ crates//Cargo.toml managed-region: anvil-lints (one per workspace member)
โโโ deny.toml managed-regions: anvil-deny-{advisories,licenses,bans,sources}
โโโ rustfmt.toml managed-region: anvil-rustfmt
@@ -296,19 +297,25 @@ Detail on each host:
credentials. `justfiles/` holds `.just` recipes and nothing else, so these
live in a tool-owned directory of their own; see
[containers.md](./containers.md).
-- **`Cargo.toml` lints regions** โ workspace `Cargo.toml` carries the
- `anvil-workspace-lints` region containing a single `[workspace.lints]` table whose
- rust/clippy/rustdoc entries are written in dotted-key form
- (`rust.unsafe_op_in_unsafe_fn = "warn"`, `clippy.unwrap_used = "warn"`, etc.). This
- form is chosen because TOML forbids re-declaring a table header โ if anvil wrote
- `[workspace.lints.clippy]` inside the region, users couldn't add another
- `[workspace.lints.clippy]` block elsewhere in the file. With dotted keys, users
- append new lints in the same scope right after the closing sentinel; see ยง7. Each
- member `Cargo.toml` carries an `anvil-lints` region with exactly
+- **`Cargo.toml` lints regions** โ workspace `Cargo.toml` carries separate
+ `anvil-workspace-rust-lints`, `anvil-workspace-rustdoc-lints`, and
+ `anvil-workspace-clippy-lints` regions. Each opens its explicit
+ `[workspace.lints.]` table and contains bare lint names. Users append
+ repository-specific bare lint keys immediately after the corresponding closing
+ sentinel, before the next table header. This gives `cargo sort --grouped` a stable
+ sorting unit for each namespace without mixing managed and repository-owned keys.
+ Each member `Cargo.toml` carries an `anvil-lints` region with exactly
`[lints]\nworkspace = true`. The emitter uses `toml-edit` for round-trip-safe
- manipulation. In a single-crate repo (no `[workspace]` table), the workspace region
- becomes `anvil-lints` and contains a single `[lints]` table with the same
- dotted-key layout.
+ manipulation. In a single-crate repo (no `[workspace]` table), the root manifest
+ instead carries `anvil-rust-lints`, `anvil-rustdoc-lints`, and
+ `anvil-clippy-lints` regions under `[lints.]`. The catalog favors broadly
+ applicable, low-false-positive diagnostics. It warns on empty braced structs whose unit form is clearer
+ (`empty_structs_with_brackets`) and allows literal strings with formatting-like
+ braces because templates and structured-logging messages legitimately carry such
+ text. Documentation completeness, whether production code may panic, and whether
+ crate-internal APIs may use `pub` remain repository policy: `missing_docs`,
+ `clippy::panic`, and `unreachable_pub` are not catalog defaults and adopters can
+ add them outside the managed region.
- **`deny.toml`** โ one managed region per top-level section (`[advisories]`, `[licenses]`,
`[bans]`, `[sources]`) carrying the tool's baseline license/advisory rules. The bans baseline
rejects wildcard registry requirements while allowing versionless path or Git
@@ -356,12 +363,12 @@ Four escape valves, in increasing severity:
The path of least resistance and the recommended approach for project-specific checks.
2. **Edit a managed-region host file outside the sentinels**: extra recipes in your
`Justfile`, extra rules in `deny.toml` outside the managed regions (or in the gaps
- between its per-section regions), extra clippy
- lints written in dotted-key form after the closing sentinel (e.g. `clippy.pedantic = "warn"`
- in the `[workspace.lints]` scope). The tool preserves everything outside the
- sentinels verbatim. Note that TOML forbids redeclaring a table header (`[workspace.lints.clippy]`
- etc.), so user extensions must use dotted-key form or sit in a different parent
- table. Repeating a managed key is invalid TOML; editing it inside the block
+ between its per-section regions), and extra lint keys written in bare form immediately
+ after the matching namespace sentinel (for example, `pedantic = "warn"` after
+ `anvil-workspace-clippy-lints`). The tool preserves everything outside the
+ sentinels verbatim. Note that TOML forbids redeclaring a table header
+ (`[workspace.lints.clippy]` etc.), so user extensions continue the table opened by
+ the managed region. Repeating a managed key is invalid TOML; editing it inside the block
causes a refusal, even if the template has not changed.
3. **Disable an owned file by emptying it.** An unchanged template leaves it alone;
a changed template can produce a `.anvil-proposed` sibling. Emptying a managed
diff --git a/crates/cargo-anvil/docs/design/extensibility.md b/crates/cargo-anvil/docs/design/extensibility.md
index 3edfcffb4..523a49d6d 100644
--- a/crates/cargo-anvil/docs/design/extensibility.md
+++ b/crates/cargo-anvil/docs/design/extensibility.md
@@ -22,7 +22,8 @@ front-end binary. Every tool built on the engine emits the *same* fixed namespac
- sidecar manifest `.anvil.lock`
- review-sibling suffix `.anvil-proposed`
- managed-region sentinels `# >>> anvil-managed: ` โฆ `# <<< anvil-managed: `
-- region IDs `anvil-imports`, `anvil-workspace-lints`, `anvil-lints`
+- region IDs such as `anvil-imports`, `anvil-workspace-rust-lints`,
+ `anvil-rust-lints`, and `anvil-lints`
- recipe-name prefix `anvil-` (`anvil-pr`, `anvil-clippy`, โฆ)
That shared vocabulary is a feature: it signals "this content is managed by the anvil engine โ
@@ -208,8 +209,12 @@ pub mod artifacts {
// Managed regions spliced into user-composed host files.
pub mod region {
pub fn justfile_imports() -> Artifact; // Justfile / anvil-imports
- pub fn workspace_lints() -> Artifact; // Cargo.toml (workspace) / anvil-workspace-lints
- pub fn single_crate_lints() -> Artifact; // Cargo.toml (single crate) / anvil-lints
+ pub fn workspace_rust_lints() -> Artifact;
+ pub fn workspace_rustdoc_lints() -> Artifact;
+ pub fn workspace_clippy_lints() -> Artifact;
+ pub fn single_crate_rust_lints() -> Artifact;
+ pub fn single_crate_rustdoc_lints() -> Artifact;
+ pub fn single_crate_clippy_lints() -> Artifact;
pub fn member_lints() -> Artifact; // /Cargo.toml / anvil-lints
pub fn deny_advisories() -> Artifact; // deny.toml / anvil-deny-advisories
pub fn deny_licenses() -> Artifact; // deny.toml / anvil-deny-licenses
@@ -305,13 +310,13 @@ crate's `Cargo.toml` just adds one artifact:
and the engine replicates it across all members, tracks each in `.anvil.lock`, and reconciles
drift per member โ no per-fork engine changes.
-> Note anvil's own lint regions are modeled as three separate artifacts under this scheme, with no
-> region-id-specific engine logic: a `WorkspaceCargoToml` region carrying `[workspace.lints]`
-> (`anvil-workspace-lints`), a `SingleCrateCargoToml` region carrying the full `[lints]` catalog
-> (`anvil-lints`), and an `EachMemberManifest` member stub (`anvil-lints`). In a workspace the
-> first and third emit; in a single-crate repo only the second does (it has no workspace members),
-> so the full catalog lands directly in the root `[lints]`. Which set applies is purely a property
-> of the selectors on the built-in artifacts, transparent to forks.
+> Note anvil's own lint regions are modeled as seven separate artifacts under this scheme, with no
+> region-id-specific engine logic: three `WorkspaceCargoToml` regions carrying the Rust, rustdoc,
+> and Clippy `[workspace.lints.]` tables; three `SingleCrateCargoToml` equivalents under
+> `[lints.]`; and an `EachMemberManifest` member stub (`anvil-lints`). In a workspace the
+> workspace trio and member stub emit; in a single-crate repo only the single-crate trio does.
+> Which set applies is purely a property of the selectors on the built-in artifacts, transparent
+> to forks.
> **On-disk casing.** Host paths and owned-file paths are canonical (`Justfile`, `Cargo.toml`), but
> the engine resolves each against the repo case-insensitively and reuses whatever casing already
diff --git a/crates/cargo-anvil/docs/design/updates.md b/crates/cargo-anvil/docs/design/updates.md
index 8e42b364e..e352c5f79 100644
--- a/crates/cargo-anvil/docs/design/updates.md
+++ b/crates/cargo-anvil/docs/design/updates.md
@@ -161,6 +161,25 @@ Independently configurable tables have separate catalog regions:
* `deny.toml`: advisories, licenses, bans, sources.
* `spellcheck.toml`: root settings, `[Hunspell]`, `[Hunspell.quirks]`.
+* root `Cargo.toml`: Rust, rustdoc, and Clippy lint subtables.
+
+The lint regions own `[workspace.lints.rust]`, `[workspace.lints.rustdoc]`, and
+`[workspace.lints.clippy]` in workspaces, or the corresponding `[lints.*]`
+tables in single-crate repositories. A repository extends a subtable with bare
+lint names after that region's closing sentinel. Keeping each namespace in its
+own region lets `cargo sort --grouped` treat the managed and repository-owned
+blocks as independent groups without moving a sentinel through another lint
+namespace.
+
+An unchanged or empty old combined `anvil-workspace-lints` or root `anvil-lints`
+block retires in the same run that introduces the three replacements. An edited
+old block remains tracked and causes a refusal rather than being overwritten or
+retired. During a successful migration, repository-owned dotted assignments
+below the old region are rewritten into bare assignments under the matching new
+subtable. Their values and comments are preserved; assignments outside the
+three lint namespaces are unaffected. An empty legacy block cannot identify its
+parent table when that parent is also declared outside the block, so this
+ambiguous layout is refused and left unchanged for manual reconciliation.
For example, a repository's `[Hunspell] transform_regex = ["^[0-9]+$"]`
stays under `[Hunspell]` below `anvil-spellcheck-hunspell`, not under quirks.
diff --git a/crates/cargo-anvil/docs/verification.md b/crates/cargo-anvil/docs/verification.md
index e2823bc04..9e8776a34 100644
--- a/crates/cargo-anvil/docs/verification.md
+++ b/crates/cargo-anvil/docs/verification.md
@@ -29,8 +29,9 @@ See also:
### 2.1 Self-hosting (primary)
`microsoft/ox-tools` is the canonical adopter of `cargo-anvil`. Its `.github/workflows/`,
-`.github/actions/`, `justfiles/anvil/`, `[workspace.lints]` region in `Cargo.toml`, etc.
-are all emitted by `cargo anvil` against the in-repo version of the binary. There
+`.github/actions/`, `justfiles/anvil/`, and the `anvil-workspace-rust-lints`,
+`anvil-workspace-rustdoc-lints`, and `anvil-workspace-clippy-lints` regions in
+`Cargo.toml` are all emitted by `cargo anvil` against the in-repo version of the binary. There
is no manual maintenance of these files after the initial migration.
Every PR runs (via a small bootstrap workflow described in ยง3):
@@ -255,7 +256,7 @@ Acknowledged limits of this strategy:
| `.github/workflows/anvil-pr.yml` | Hand-written self-validation wrapper (the one bootstrap file). |
| `.github/workflows/anvil-pr-impl.yml` (and friends) | Regenerated by `cargo anvil`. Subject to the regenerate-check. |
| `justfiles/anvil/*.just` | Regenerated. Subject to the regenerate-check. |
-| `Cargo.toml` (anvil-workspace-lints region) | Regenerated. Subject to the regenerate-check. |
+| `Cargo.toml` (anvil workspace lint regions) | Regenerated. Subject to the regenerate-check. |
| `.anvil.lock` | The manifest itself. Diffed on every PR. |
| `docs/release-checklist.md` | Pre-publish checks for things dogfooding misses. |
diff --git a/crates/cargo-anvil/src/anvil/artifacts/mod.rs b/crates/cargo-anvil/src/anvil/artifacts/mod.rs
index f7279229f..49bcfa038 100644
--- a/crates/cargo-anvil/src/anvil/artifacts/mod.rs
+++ b/crates/cargo-anvil/src/anvil/artifacts/mod.rs
@@ -67,8 +67,12 @@ pub(crate) fn anvil_artifacts() -> Vec {
instructions::cargo_anvil(),
instructions::adoption_skill(),
region::justfile_imports(),
- region::workspace_lints(),
- region::single_crate_lints(),
+ region::workspace_rust_lints(),
+ region::workspace_rustdoc_lints(),
+ region::workspace_clippy_lints(),
+ region::single_crate_rust_lints(),
+ region::single_crate_rustdoc_lints(),
+ region::single_crate_clippy_lints(),
region::member_lints(),
region::deny_advisories(),
region::deny_licenses(),
@@ -227,8 +231,12 @@ mod tests {
instructions::cargo_anvil(),
instructions::adoption_skill(),
region::justfile_imports(),
- region::workspace_lints(),
- region::single_crate_lints(),
+ region::workspace_rust_lints(),
+ region::workspace_rustdoc_lints(),
+ region::workspace_clippy_lints(),
+ region::single_crate_rust_lints(),
+ region::single_crate_rustdoc_lints(),
+ region::single_crate_clippy_lints(),
region::member_lints(),
region::deny_advisories(),
region::deny_licenses(),
diff --git a/crates/cargo-anvil/src/anvil/artifacts/region.rs b/crates/cargo-anvil/src/anvil/artifacts/region.rs
index 98ecca239..40ec20a50 100644
--- a/crates/cargo-anvil/src/anvil/artifacts/region.rs
+++ b/crates/cargo-anvil/src/anvil/artifacts/region.rs
@@ -14,16 +14,21 @@ use super::justfile;
use crate::catalog::{Artifact, HostSelector, RegionId, RegionSpec};
use crate::region::CommentSyntax;
-/// Region id for the workspace-scope lints (multi-crate workspaces).
-const WORKSPACE_LINTS_REGION_ID: &str = "anvil-workspace-lints";
+const WORKSPACE_RUST_LINTS_REGION_ID: &str = "anvil-workspace-rust-lints";
+const WORKSPACE_RUSTDOC_LINTS_REGION_ID: &str = "anvil-workspace-rustdoc-lints";
+const WORKSPACE_CLIPPY_LINTS_REGION_ID: &str = "anvil-workspace-clippy-lints";
+const RUST_LINTS_REGION_ID: &str = "anvil-rust-lints";
+const RUSTDOC_LINTS_REGION_ID: &str = "anvil-rustdoc-lints";
+const CLIPPY_LINTS_REGION_ID: &str = "anvil-clippy-lints";
/// Region id for crate-scope lints โ used both for single-crate repos (full
/// catalog) and for each member of a multi-crate workspace (`workspace =
/// true`).
const CRATE_LINTS_REGION_ID: &str = "anvil-lints";
-/// Embedded body of the lint catalog, in dotted-key form (no table header).
-const LINTS_BODY: &str = include_str!("../../../templates/regions/cargo-lints-body.toml");
+const RUST_LINTS_BODY: &str = include_str!("../../../templates/regions/cargo-rust-lints.toml");
+const RUSTDOC_LINTS_BODY: &str = include_str!("../../../templates/regions/cargo-rustdoc-lints.toml");
+const CLIPPY_LINTS_BODY: &str = include_str!("../../../templates/regions/cargo-clippy-lints.toml");
/// Embedded body of a workspace-member lints region.
const MEMBER_LINTS_BODY: &str = include_str!("../../../templates/regions/cargo-member-lints.toml");
@@ -97,24 +102,26 @@ const CLIPPY_BODY: &str = include_str!("../../../templates/regions/clippy.toml")
/// Embedded body of the `.gitattributes` managed region.
const GITATTRIBUTES_BODY: &str = include_str!("../../../templates/regions/gitattributes");
-/// Render the body of the workspace-scope lints region: `[workspace.lints]`
-/// header followed by the embedded catalog.
#[must_use]
-fn render_workspace_lints_body() -> String {
- let mut out = String::with_capacity(LINTS_BODY.len() + 32);
- out.push_str("[workspace.lints]\n");
- out.push_str(LINTS_BODY);
+fn render_lints_body(scope: &str, namespace: &str, body: &str) -> String {
+ let mut out = String::with_capacity(scope.len() + namespace.len() + body.len() + 8);
+ out.push('[');
+ out.push_str(scope);
+ out.push('.');
+ out.push_str(namespace);
+ out.push_str("]\n");
+ out.push_str(body);
out
}
-/// Render the body of the single-crate lints region: `[lints]` header
-/// followed by the embedded catalog.
#[must_use]
-fn render_single_crate_lints_body() -> String {
- let mut out = String::with_capacity(LINTS_BODY.len() + 16);
- out.push_str("[lints]\n");
- out.push_str(LINTS_BODY);
- out
+fn lint_region(host: HostSelector, id: &'static str, scope: &str, namespace: &str, body: &str) -> Artifact {
+ Artifact::region(RegionSpec {
+ host,
+ id: RegionId::new(id),
+ body: render_lints_body(scope, namespace, body),
+ syntax: CommentSyntax::Hash,
+ })
}
/// Build a single-path `Hash`-syntax region artifact.
@@ -137,35 +144,76 @@ pub fn justfile_imports() -> Artifact {
)
}
-/// Root `Cargo.toml` / `anvil-workspace-lints`.
-///
-/// The workspace-scope lint catalog under `[workspace.lints]`. Emitted only
-/// in a multi-crate workspace; the [`HostSelector::WorkspaceCargoToml`] host
-/// skips it in a single-crate repo.
+/// Workspace `Cargo.toml` / `anvil-workspace-rust-lints` โ Rust compiler lints.
#[must_use]
-pub fn workspace_lints() -> Artifact {
- Artifact::region(RegionSpec {
- host: HostSelector::WorkspaceCargoToml,
- id: RegionId::new(WORKSPACE_LINTS_REGION_ID),
- body: render_workspace_lints_body(),
- syntax: CommentSyntax::Hash,
- })
+pub fn workspace_rust_lints() -> Artifact {
+ lint_region(
+ HostSelector::WorkspaceCargoToml,
+ WORKSPACE_RUST_LINTS_REGION_ID,
+ "workspace.lints",
+ "rust",
+ RUST_LINTS_BODY,
+ )
}
-/// Root `Cargo.toml` / `anvil-lints`.
-///
-/// The full lint catalog under `[lints]`. Emitted only in a single-crate
-/// repo; the [`HostSelector::SingleCrateCargoToml`] host skips it in a
-/// workspace, where the catalog lives under `[workspace.lints]` and members
-/// inherit it.
+/// Workspace `Cargo.toml` / `anvil-workspace-rustdoc-lints` โ rustdoc lints.
#[must_use]
-pub fn single_crate_lints() -> Artifact {
- Artifact::region(RegionSpec {
- host: HostSelector::SingleCrateCargoToml,
- id: RegionId::new(CRATE_LINTS_REGION_ID),
- body: render_single_crate_lints_body(),
- syntax: CommentSyntax::Hash,
- })
+pub fn workspace_rustdoc_lints() -> Artifact {
+ lint_region(
+ HostSelector::WorkspaceCargoToml,
+ WORKSPACE_RUSTDOC_LINTS_REGION_ID,
+ "workspace.lints",
+ "rustdoc",
+ RUSTDOC_LINTS_BODY,
+ )
+}
+
+/// Workspace `Cargo.toml` / `anvil-workspace-clippy-lints` โ Clippy lints.
+#[must_use]
+pub fn workspace_clippy_lints() -> Artifact {
+ lint_region(
+ HostSelector::WorkspaceCargoToml,
+ WORKSPACE_CLIPPY_LINTS_REGION_ID,
+ "workspace.lints",
+ "clippy",
+ CLIPPY_LINTS_BODY,
+ )
+}
+
+/// Single-crate `Cargo.toml` / `anvil-rust-lints` โ Rust compiler lints.
+#[must_use]
+pub fn single_crate_rust_lints() -> Artifact {
+ lint_region(
+ HostSelector::SingleCrateCargoToml,
+ RUST_LINTS_REGION_ID,
+ "lints",
+ "rust",
+ RUST_LINTS_BODY,
+ )
+}
+
+/// Single-crate `Cargo.toml` / `anvil-rustdoc-lints` โ rustdoc lints.
+#[must_use]
+pub fn single_crate_rustdoc_lints() -> Artifact {
+ lint_region(
+ HostSelector::SingleCrateCargoToml,
+ RUSTDOC_LINTS_REGION_ID,
+ "lints",
+ "rustdoc",
+ RUSTDOC_LINTS_BODY,
+ )
+}
+
+/// Single-crate `Cargo.toml` / `anvil-clippy-lints` โ Clippy lints.
+#[must_use]
+pub fn single_crate_clippy_lints() -> Artifact {
+ lint_region(
+ HostSelector::SingleCrateCargoToml,
+ CLIPPY_LINTS_REGION_ID,
+ "lints",
+ "clippy",
+ CLIPPY_LINTS_BODY,
+ )
}
/// `/Cargo.toml` / `anvil-lints` โ the per-member `workspace = true`
@@ -258,23 +306,23 @@ mod tests {
use crate::region::{TomlAdoption, adopt_unmanaged_toml_tables, upsert_region};
#[test]
- fn embedded_catalog_uses_dotted_keys() {
- for line in LINTS_BODY.lines() {
+ fn embedded_catalog_uses_bare_keys() {
+ for line in [RUST_LINTS_BODY, RUSTDOC_LINTS_BODY, CLIPPY_LINTS_BODY]
+ .into_iter()
+ .flat_map(str::lines)
+ {
let trimmed = line.trim_start();
- assert!(
- !trimmed.starts_with('['),
- "unexpected table header in cargo-lints-body.toml: {line}"
- );
+ assert!(!trimmed.starts_with('['), "unexpected table header in lint body: {line}");
}
- assert!(LINTS_BODY.contains("rust.unsafe_op_in_unsafe_fn = \"warn\""));
- assert!(LINTS_BODY.contains("clippy.unwrap_used = \"warn\""));
+ assert!(RUST_LINTS_BODY.contains("unsafe_op_in_unsafe_fn = \"warn\""));
+ assert!(CLIPPY_LINTS_BODY.contains("unwrap_used = \"warn\""));
}
#[test]
fn catalog_intentionally_omits_contested_lints() {
- for needle in ["rust.missing_docs", "clippy.expect_used", "clippy.panic "] {
+ for needle in ["missing_docs =", "unreachable_pub =", "expect_used =", "panic ="] {
assert!(
- !LINTS_BODY.contains(needle),
+ !format!("{RUST_LINTS_BODY}{RUSTDOC_LINTS_BODY}{CLIPPY_LINTS_BODY}").contains(needle),
"catalog now contains '{needle}'; if intentional, update the catalog-omission test"
);
}
@@ -288,6 +336,7 @@ mod tests {
"clippy.deref_by_slicing = \"warn\"",
"clippy.empty_drop = \"warn\"",
"clippy.empty_enum_variants_with_brackets = \"warn\"",
+ "clippy.empty_structs_with_brackets = \"warn\"",
"clippy.fn_to_numeric_cast_any = \"warn\"",
"clippy.if_then_some_else_none = \"warn\"",
"clippy.multiple_unsafe_ops_per_block = \"warn\"",
@@ -297,43 +346,41 @@ mod tests {
"clippy.unnecessary_safety_doc = \"warn\"",
"clippy.unneeded_field_pattern = \"warn\"",
"clippy.unused_result_ok = \"warn\"",
+ "clippy.literal_string_with_formatting_args = \"allow\"",
"clippy.redundant_pub_crate = \"allow\"",
"clippy.should_panic_without_expect = \"allow\"",
] {
- assert!(LINTS_BODY.contains(needle), "catalog missing consensus lint '{needle}'");
+ assert!(
+ CLIPPY_LINTS_BODY.contains(needle.trim_start_matches("clippy.")),
+ "catalog missing consensus lint '{needle}'"
+ );
}
}
#[test]
fn catalog_declares_llvm_cov_cfgs_for_unexpected_cfgs_lint() {
assert!(
- LINTS_BODY.contains("rust.unexpected_cfgs"),
+ RUST_LINTS_BODY.contains("unexpected_cfgs"),
"catalog must declare rust.unexpected_cfgs to pre-allow llvm-cov's coverage cfgs"
);
assert!(
- LINTS_BODY.contains("'cfg(coverage,coverage_nightly)'"),
+ RUST_LINTS_BODY.contains("'cfg(coverage,coverage_nightly)'"),
"catalog's unexpected_cfgs check-cfg list must include coverage,coverage_nightly"
);
}
#[test]
- fn workspace_body_prepends_workspace_lints_header() {
- let body = render_workspace_lints_body();
- assert!(body.starts_with("[workspace.lints]\n"));
- assert!(body.contains("clippy.pedantic = { level = \"warn\", priority = -1 }"));
+ fn workspace_bodies_open_namespace_subtables() {
+ assert!(render_lints_body("workspace.lints", "rust", RUST_LINTS_BODY).starts_with("[workspace.lints.rust]\n"));
+ assert!(
+ render_lints_body("workspace.lints", "clippy", CLIPPY_LINTS_BODY).contains("pedantic = { level = \"warn\", priority = -1 }")
+ );
}
#[test]
- fn single_crate_body_prepends_lints_header() {
- let body = render_single_crate_lints_body();
- assert!(body.starts_with("[lints]\n"));
- assert!(body.contains("clippy.unwrap_used = \"warn\""));
- for line in body.lines() {
- let trimmed = line.trim_start();
- if trimmed.starts_with('[') {
- assert_eq!(trimmed, "[lints]", "unexpected table header in single-crate body: {line}");
- }
- }
+ fn single_crate_bodies_open_namespace_subtables() {
+ assert!(render_lints_body("lints", "rustdoc", RUSTDOC_LINTS_BODY).starts_with("[lints.rustdoc]\n"));
+ assert!(render_lints_body("lints", "clippy", CLIPPY_LINTS_BODY).contains("unwrap_used = \"warn\""));
}
#[test]
@@ -343,10 +390,23 @@ mod tests {
}
#[test]
- fn dotted_key_body_parses_as_valid_toml_when_appended_to_workspace() {
+ fn namespace_bodies_compose_as_valid_workspace_toml() {
let host = "[workspace]\nmembers = [\"crates/a\"]\n";
- let region_body = render_workspace_lints_body();
- let spliced = upsert_region(host, WORKSPACE_LINTS_REGION_ID, ®ion_body, CommentSyntax::Hash).unwrap();
+ let spliced = [
+ (WORKSPACE_RUST_LINTS_REGION_ID, "rust", RUST_LINTS_BODY),
+ (WORKSPACE_RUSTDOC_LINTS_REGION_ID, "rustdoc", RUSTDOC_LINTS_BODY),
+ (WORKSPACE_CLIPPY_LINTS_REGION_ID, "clippy", CLIPPY_LINTS_BODY),
+ ]
+ .into_iter()
+ .try_fold(host.to_owned(), |text, (id, namespace, body)| {
+ upsert_region(
+ &text,
+ id,
+ &render_lints_body("workspace.lints", namespace, body),
+ CommentSyntax::Hash,
+ )
+ })
+ .unwrap();
let _: toml_edit::DocumentMut = spliced.parse().expect("spliced TOML must be valid");
}
diff --git a/crates/cargo-anvil/src/emit/managed_region.rs b/crates/cargo-anvil/src/emit/managed_region.rs
index 1f8b41d4d..2e8641d37 100644
--- a/crates/cargo-anvil/src/emit/managed_region.rs
+++ b/crates/cargo-anvil/src/emit/managed_region.rs
@@ -27,8 +27,9 @@ use crate::decision::Decision;
use crate::manifest::Manifest;
use crate::plan::{PlanItem, Target};
use crate::region::{
- CommentSyntax, RegionPlacement, TomlAdoption, adopt_unmanaged_toml_tables, find_region, insert_after_region, managed_region_ids,
- mask_retiring_managed_regions, start_region_offset, text_newline, upsert_region_with_newline,
+ CommentSyntax, RegionPlacement, TomlAdoption, adopt_unmanaged_toml_tables, find_region, insert_after_region, legacy_lint_region_id,
+ lint_region_placement, managed_region_ids, mask_retiring_managed_regions, start_region_offset, text_newline,
+ upsert_region_with_newline,
};
/// What the reader should do about a refused region.
@@ -187,6 +188,32 @@ pub fn plan_managed_region(
RefusalRemedy::EditedRegion,
));
}
+ if let Some(legacy_id) = legacy_lint_region_id(region_id)
+ && let Some(text) = host_text
+ && let Some(legacy_region) =
+ find_region(text, legacy_id, syntax).map_err(|error| ManagedRegionRefusal::new(error, RefusalRemedy::MalformedMarkers))?
+ {
+ let recorded_checksum = manifest.region_checksum(host_relpath, legacy_id);
+ if recorded_checksum.is_none() {
+ return Err(ManagedRegionRefusal::new(
+ app_err!(
+ "the host contains legacy managed region '{legacy_id}', but the manifest does not record it as owned. \
+ Refusing to add replacement lint regions alongside an orphan that cargo-anvil cannot retire."
+ ),
+ RefusalRemedy::EditedRetirement,
+ ));
+ }
+ let disk_checksum = checksum_str(legacy_region.body_str());
+ if !legacy_region.is_empty() && recorded_checksum != Some(disk_checksum.as_str()) {
+ return Err(ManagedRegionRefusal::new(
+ app_err!(
+ "legacy managed region '{legacy_id}' contains edits that do not match its last render. \
+ Restore its generated content, or empty its body before migrating to the replacement lint regions."
+ ),
+ RefusalRemedy::EditedRetirement,
+ ));
+ }
+ }
let spliced = splice(host_relpath, host_text, region_id, rendered_body, syntax, placement, newline)?;
Ok(PlanItem::write_region(
host_relpath,
@@ -369,6 +396,7 @@ fn splice(
base
};
+ let placement = lint_region_placement(region_id, Some(base)).unwrap_or(placement);
let spliced = upsert_region_with_newline(base, region_id, rendered_body, syntax, placement, newline)
.map_err(|error| ManagedRegionRefusal::new(error, RefusalRemedy::MalformedMarkers))?;
insert_after_region(&spliced, region_id, &residue, syntax)
diff --git a/crates/cargo-anvil/src/region.rs b/crates/cargo-anvil/src/region.rs
index bd0ba253b..2a3b18fc7 100644
--- a/crates/cargo-anvil/src/region.rs
+++ b/crates/cargo-anvil/src/region.rs
@@ -19,11 +19,12 @@
//! because TOML rejects a duplicate table header outright. See
//! [`adopt_unmanaged_toml_tables`].
//! The `id` is globally unique within the catalog (e.g. `anvil-imports`,
-//! `anvil-workspace-lints`).
+//! `anvil-workspace-rust-lints`).
//!
//! Empty bodies are regenerated; nonempty edits require reconciliation.
use std::collections::{BTreeMap, BTreeSet};
+use std::fmt::Write as _;
use ohno::{AppError, app_err, bail};
use toml_edit::{Item, Key, RawString, Table};
@@ -592,6 +593,34 @@ pub fn adopt_unmanaged_toml_tables(text: &str, body: &str, syntax: CommentSyntax
boundaries.extend(protected.iter().map(|range| range.start));
boundaries.sort_unstable();
+ for (legacy_id, parent) in [
+ ("anvil-workspace-lints", ["workspace", "lints"].as_slice()),
+ ("anvil-lints", ["lints"].as_slice()),
+ ] {
+ if matches!(find_region(text, legacy_id, syntax), Ok(Some(region)) if region.is_empty())
+ && candidates.iter().any(|table| table.path == parent)
+ {
+ return TomlAdoption::Unrelocatable {
+ table: parent.join("."),
+ tail_table: tail.join("."),
+ };
+ }
+ }
+ if let Some(adoption) = adopt_dotted_child_assignments(text, &managed, &candidates, &boundaries, &protected) {
+ return adoption;
+ }
+ let legacy_masked = mask_legacy_lint_region_to_header(text, syntax);
+ if legacy_masked != masked
+ && let Some(legacy_candidates) = headed_tables(&legacy_masked)
+ {
+ let mut legacy_boundaries: Vec = legacy_candidates.iter().map(|table| table.header.start).collect();
+ legacy_boundaries.extend(protected.iter().map(|range| range.start));
+ legacy_boundaries.sort_unstable();
+ if let Some(adoption) = adopt_dotted_child_assignments(text, &managed, &legacy_candidates, &legacy_boundaries, &protected) {
+ return adoption;
+ }
+ }
+
let mut deletions: Vec = Vec::new();
let mut residue = String::new();
@@ -599,6 +628,7 @@ pub fn adopt_unmanaged_toml_tables(text: &str, body: &str, syntax: CommentSyntax
if candidate.array_of_tables {
continue;
}
+
let Some(managed_values) = managed
.iter()
.find(|table| !table.array_of_tables && table.path == candidate.path)
@@ -661,6 +691,204 @@ pub fn adopt_unmanaged_toml_tables(text: &str, body: &str, syntax: CommentSyntax
}
}
+fn mask_legacy_lint_region_to_header(text: &str, syntax: CommentSyntax) -> String {
+ let mut masked = mask_managed_regions(text, syntax).into_bytes();
+ for (id, header) in [("anvil-workspace-lints", "[workspace.lints]"), ("anvil-lints", "[lints]")] {
+ let Ok(Some(region)) = find_region(text, id, syntax) else {
+ continue;
+ };
+ let start = if let Some(relative) = region.body_str().find(header) {
+ region.body.start + relative
+ } else if region.is_empty() {
+ region.body.start
+ } else {
+ continue;
+ };
+ let end = start + header.len();
+ masked[start..end].copy_from_slice(&text.as_bytes()[start..end]);
+ if region.is_empty() {
+ masked[start..end].copy_from_slice(header.as_bytes());
+ }
+ }
+ String::from_utf8(masked).expect("masking preserves UTF-8 and only restores original UTF-8 slices")
+}
+
+pub(crate) fn legacy_lint_region_id(region_id: &str) -> Option<&'static str> {
+ match region_id {
+ "anvil-workspace-rust-lints" | "anvil-workspace-rustdoc-lints" | "anvil-workspace-clippy-lints" => Some("anvil-workspace-lints"),
+ "anvil-rust-lints" | "anvil-rustdoc-lints" | "anvil-clippy-lints" => Some("anvil-lints"),
+ _ => None,
+ }
+}
+
+pub(crate) fn lint_region_placement(region_id: &str, current: Option<&str>) -> Option {
+ const WORKSPACE: &[&str] = &[
+ "anvil-workspace-rust-lints",
+ "anvil-workspace-rustdoc-lints",
+ "anvil-workspace-clippy-lints",
+ ];
+ const SINGLE_CRATE: &[&str] = &["anvil-rust-lints", "anvil-rustdoc-lints", "anvil-clippy-lints"];
+ let order = if WORKSPACE.contains(®ion_id) {
+ WORKSPACE
+ } else if SINGLE_CRATE.contains(®ion_id) {
+ SINGLE_CRATE
+ } else {
+ return None;
+ };
+ let Some(text) = current else {
+ return Some(RegionPlacement::End);
+ };
+ if matches!(find_region(text, region_id, CommentSyntax::Hash), Ok(Some(_))) {
+ return Some(RegionPlacement::End);
+ }
+ let position = order
+ .iter()
+ .position(|candidate| *candidate == region_id)
+ .expect("region membership was established above");
+ for successor in order.iter().skip(position).skip(1) {
+ if let Ok(Some(region)) = find_region(text, successor, CommentSyntax::Hash) {
+ return Some(RegionPlacement::At(region.start_line.start));
+ }
+ }
+
+ let retiring: BTreeSet = ["anvil-workspace-lints", "anvil-lints"].into_iter().map(str::to_owned).collect();
+ let parseable = mask_retiring_managed_regions(text, CommentSyntax::Hash, &retiring);
+ let before_profiles = headed_tables(&parseable).and_then(|tables| {
+ tables
+ .into_iter()
+ .filter(|table| table.path.first().is_some_and(|root| root == "patch" || root == "profile"))
+ .map(|table| table.header.start)
+ .min()
+ });
+ let before_legacy = retiring
+ .iter()
+ .filter_map(|id| find_region(text, id, CommentSyntax::Hash).ok().flatten())
+ .map(|region| region.start_line.start)
+ .min();
+ Some(RegionPlacement::At(
+ before_profiles.into_iter().chain(before_legacy).min().unwrap_or(text.len()),
+ ))
+}
+
+/// Move dotted assignments from a headed parent table into a managed child
+/// table. For example, introducing `[workspace.lints.rust]` must turn an
+/// existing `rust.missing_docs = "warn"` under `[workspace.lints]` into the
+/// bare `missing_docs = "warn"` residue that follows the new region.
+fn adopt_dotted_child_assignments(
+ text: &str,
+ managed: &[HeadedTable],
+ candidates: &[HeadedTable],
+ boundaries: &[usize],
+ protected: &[ByteRange],
+) -> Option {
+ let [managed] = managed else {
+ return None;
+ };
+ let (namespace, parent_path) = managed.path.split_last()?;
+ let candidate = candidates
+ .iter()
+ .find(|table| !table.array_of_tables && table.path == parent_path)?;
+ let end = boundary_after(boundaries, candidate.header.start, text.len());
+ let mut deletions = Vec::new();
+ let mut residue = String::new();
+ let mut parent_residue = String::new();
+ let is_final_lint_namespace = namespace == "clippy" && parent_path.last().is_some_and(|segment| segment == "lints");
+
+ for entry in &candidate.entries {
+ let start = protected
+ .iter()
+ .find(|range| (range.start..range.end).contains(&entry.span.start))
+ .map_or(entry.span.start, |range| range.end);
+ if entry.path.first().is_none_or(|segment| segment != namespace) {
+ if is_final_lint_namespace {
+ parent_residue.push_str(&text[start..entry.span.end.min(end)]);
+ deletions.push(ByteRange {
+ start,
+ end: entry.span.end.min(end),
+ });
+ }
+ continue;
+ }
+ if entry.path.len() == 1 {
+ return Some(TomlAdoption::Unrelocatable {
+ table: parent_path.iter().chain(entry.path.iter()).cloned().collect::>().join("."),
+ tail_table: managed.path.join("."),
+ });
+ }
+ let relative_path = &entry.path[1..];
+ match managed.values.get(relative_path) {
+ Some(managed_value) if *managed_value == entry.value => {}
+ Some(managed_value) => {
+ return Some(TomlAdoption::Conflict {
+ table: candidate.path.join("."),
+ key: candidate
+ .path
+ .iter()
+ .chain(entry.path.iter())
+ .cloned()
+ .collect::>()
+ .join("."),
+ managed: managed_value.clone(),
+ hand_written: entry.value.clone(),
+ });
+ }
+ None => {
+ let source = &text[start..entry.span.end.min(end)];
+ let bare = strip_dotted_namespace(source, namespace)
+ .expect("the parsed entry starts with the namespace selected by the filter above");
+ residue.push_str(&bare);
+ }
+ }
+ deletions.push(ByteRange {
+ start,
+ end: entry.span.end.min(end),
+ });
+ }
+ if !parent_residue.trim().is_empty() {
+ writeln!(residue, "[{}]", parent_path.join(".")).expect("writing to a String cannot fail");
+ residue.push_str(&parent_residue);
+ }
+
+ if deletions.is_empty() {
+ return None;
+ }
+
+ let mut out = String::with_capacity(text.len());
+ let mut cursor = 0;
+ for range in deletions {
+ out.push_str(&text[cursor..range.start]);
+ cursor = range.end;
+ }
+ out.push_str(&text[cursor..]);
+ Some(TomlAdoption::Adopted {
+ text: out,
+ residue: tidy_residue(&residue, text_newline(text)),
+ })
+}
+
+fn strip_dotted_namespace(source: &str, namespace: &str) -> Option {
+ let prefixes = [format!("{namespace}."), format!("\"{namespace}\"."), format!("'{namespace}'.")];
+ let (offset, prefix_len) = source
+ .split_inclusive('\n')
+ .scan(0, |offset, line| {
+ let start = *offset;
+ *offset += line.len();
+ Some((start, line))
+ })
+ .find_map(|(start, line)| {
+ let indentation = line.len() - line.trim_start().len();
+ prefixes
+ .iter()
+ .find(|prefix| line[indentation..].starts_with(prefix.as_str()))
+ .map(|prefix| (start + indentation, prefix.len()))
+ })?;
+
+ let mut out = String::with_capacity(source.len() - prefix_len);
+ out.push_str(&source[..offset]);
+ out.push_str(&source[offset + prefix_len..]);
+ Some(out)
+}
+
/// Trim the blank lines that bounded the residue inside the table it came
/// from, leaving exactly one trailing newline when anything is left.
///
@@ -2268,4 +2496,110 @@ mod tests {
);
assert!(!masked.contains("yanked = \"deny\""), "the other region is blanked:\n{masked}");
}
+
+ #[test]
+ fn legacy_lint_mask_restores_only_a_nonleading_table_header() {
+ let text = "# >>> anvil-managed: anvil-workspace-lints\n\
+ # old catalog\n\
+ [workspace.lints]\n\
+ rust.unsafe_code = \"warn\"\n\
+ # <<< anvil-managed: anvil-workspace-lints\n";
+ let masked = mask_legacy_lint_region_to_header(text, SYN);
+
+ assert!(
+ masked.contains("[workspace.lints]"),
+ "the parent table remains parseable:\n{masked}"
+ );
+ assert!(!masked.contains("# old catalog"), "other legacy content stays masked:\n{masked}");
+ assert!(!masked.contains("rust.unsafe_code"), "managed assignments stay masked:\n{masked}");
+ }
+
+ #[test]
+ fn legacy_lint_mask_ignores_a_region_without_its_expected_parent_header() {
+ let text = "# >>> anvil-managed: anvil-workspace-lints\n\
+ [workspace.metadata]\n\
+ policy = true\n\
+ # <<< anvil-managed: anvil-workspace-lints\n";
+
+ assert_eq!(mask_legacy_lint_region_to_header(text, SYN), mask_managed_regions(text, SYN));
+ }
+
+ #[test]
+ fn lint_region_placement_uses_the_next_namespace_and_canonical_table_boundary() {
+ let clippy = "# >>> anvil-managed: anvil-workspace-clippy-lints\n\
+ [workspace.lints.clippy]\n\
+ # <<< anvil-managed: anvil-workspace-clippy-lints\n";
+ let with_profile = format!("{clippy}\n[profile.release]\nlto = true\n");
+
+ assert_eq!(
+ lint_region_placement("anvil-workspace-rustdoc-lints", Some(&with_profile)),
+ Some(RegionPlacement::At(0)),
+ "rustdoc belongs before the existing Clippy region"
+ );
+ assert_eq!(
+ lint_region_placement("anvil-workspace-clippy-lints", Some(&with_profile)),
+ Some(RegionPlacement::End),
+ "an existing region updates in place"
+ );
+ assert_eq!(
+ lint_region_placement("anvil-workspace-rust-lints", Some("[profile.release]\nlto = true\n")),
+ Some(RegionPlacement::At(0)),
+ "the first lint table precedes Cargo profiles"
+ );
+ assert_eq!(lint_region_placement("unrelated", Some(&with_profile)), None);
+ assert_eq!(
+ lint_region_placement("anvil-rust-lints", None),
+ Some(RegionPlacement::End),
+ "a new host appends its first region"
+ );
+ }
+
+ #[test]
+ fn differing_dotted_child_assignment_is_a_conflict() {
+ let adoption = adopt_unmanaged_toml_tables(
+ "[workspace.lints]\nrust.unsafe_op_in_unsafe_fn = \"deny\"\n",
+ "[workspace.lints.rust]\nunsafe_op_in_unsafe_fn = \"warn\"\n",
+ SYN,
+ );
+
+ assert_eq!(
+ adoption,
+ TomlAdoption::Conflict {
+ table: "workspace.lints".to_owned(),
+ key: "workspace.lints.rust.unsafe_op_in_unsafe_fn".to_owned(),
+ managed: "\"warn\"".to_owned(),
+ hand_written: "\"deny\"".to_owned(),
+ }
+ );
+ }
+
+ #[test]
+ fn exact_namespace_inline_table_is_refused_instead_of_panicking() {
+ let adoption = adopt_unmanaged_toml_tables(
+ "[workspace.lints]\nrust = { missing_docs = \"warn\" }\n",
+ "[workspace.lints.rust]\nunsafe_op_in_unsafe_fn = \"warn\"\n",
+ SYN,
+ );
+
+ assert_eq!(
+ adoption,
+ TomlAdoption::Unrelocatable {
+ table: "workspace.lints.rust".to_owned(),
+ tail_table: "workspace.lints.rust".to_owned(),
+ }
+ );
+ }
+
+ #[test]
+ fn dotted_namespace_stripping_preserves_leading_trivia_and_quoted_namespaces() {
+ assert_eq!(
+ strip_dotted_namespace("\n# policy\n \"rust\".missing_docs = \"warn\"\n", "rust"),
+ Some("\n# policy\n missing_docs = \"warn\"\n".to_owned())
+ );
+ assert_eq!(
+ strip_dotted_namespace("'rust'.missing_docs = \"warn\"\n", "rust"),
+ Some("missing_docs = \"warn\"\n".to_owned())
+ );
+ assert_eq!(strip_dotted_namespace("clippy.panic = \"warn\"\n", "rust"), None);
+ }
}
diff --git a/crates/cargo-anvil/src/run.rs b/crates/cargo-anvil/src/run.rs
index 59cb9525d..cc9cb07cc 100644
--- a/crates/cargo-anvil/src/run.rs
+++ b/crates/cargo-anvil/src/run.rs
@@ -26,7 +26,10 @@ use crate::manifest::Manifest;
use crate::plan::{Plan, PlanItem, Target};
#[cfg(test)]
use crate::region::upsert_region;
-use crate::region::{CommentSyntax, MarkerRepair, RegionPlacement, find_region, managed_region_ids, remove_region, repair_markers};
+use crate::region::{
+ CommentSyntax, MarkerRepair, RegionPlacement, find_region, legacy_lint_region_id, lint_region_placement, managed_region_ids,
+ remove_region, repair_markers,
+};
use crate::workspace::{self, Workspace};
/// Outcome of an `update` invocation.
@@ -405,7 +408,16 @@ fn push_region_at(
let item = match plan_managed_region(manifest, current.as_deref(), request) {
Ok(item) => item,
Err(refusal) => {
- refuse_region(plan, host, spec.id.as_str(), &refusal.reason.to_string(), refusal.remedy);
+ let reason = refusal.reason.to_string();
+ if legacy_lint_region_id(spec.id.as_str()).is_some_and(|legacy_id| {
+ current
+ .as_deref()
+ .is_some_and(|text| matches!(find_region(text, legacy_id, spec.syntax), Ok(Some(_))))
+ }) {
+ composed.states.insert(host.clone(), ComposedHostState::Unsafe(reason.clone()));
+ composed.reported.insert(host.clone());
+ }
+ refuse_region(plan, host, spec.id.as_str(), &reason, refusal.remedy);
return Ok(());
}
};
@@ -707,6 +719,9 @@ fn region_placement(region_id: &str, current: Option<&str>) -> RegionPlacement {
// empty block establishes no table context; append replacements instead.
return RegionPlacement::At(old.start_line.start);
}
+ if let Some(placement) = lint_region_placement(region_id, current) {
+ return placement;
+ }
RegionPlacement::End
}
@@ -1615,7 +1630,13 @@ mod tests {
}
let root_manifest = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
- assert!(root_manifest.contains("# >>> anvil-managed: anvil-workspace-lints"));
+ for region in [
+ "anvil-workspace-rust-lints",
+ "anvil-workspace-rustdoc-lints",
+ "anvil-workspace-clippy-lints",
+ ] {
+ assert!(root_manifest.contains(&format!("# >>> anvil-managed: {region}")));
+ }
let member_manifest = fs::read_to_string(tmp.path().join("crates/alpha/Cargo.toml")).unwrap();
assert!(member_manifest.contains("# >>> anvil-managed: anvil-lints"));
assert!(member_manifest.contains("workspace = true"));
diff --git a/crates/cargo-anvil/templates/justfiles/anvil/checks/miri-tree-borrows.just b/crates/cargo-anvil/templates/justfiles/anvil/checks/miri-tree-borrows.just
index bfafb7a0c..c58e56e6f 100644
--- a/crates/cargo-anvil/templates/justfiles/anvil/checks/miri-tree-borrows.just
+++ b/crates/cargo-anvil/templates/justfiles/anvil/checks/miri-tree-borrows.just
@@ -12,7 +12,7 @@
# `#[cfg_attr(miri_, ignore = "")]`, keeping the
# suppression next to the test rather than in a sidecar file.
# The cfg names are declared in the workspace check-cfg list
-# (regions/cargo-lints-body.toml) so non-miri builds don't warn.
+# (regions/cargo-rust-lints.toml) so non-miri builds don't warn.
#
# Under scheduled-runtime-analysis these profiles run full-workspace:
# that tier sets ANVIL_IMPACT=off, so _anvil-impact-include resolves
diff --git a/crates/cargo-anvil/templates/regions/cargo-clippy-lints.toml b/crates/cargo-anvil/templates/regions/cargo-clippy-lints.toml
new file mode 100644
index 000000000..833a0dfc9
--- /dev/null
+++ b/crates/cargo-anvil/templates/regions/cargo-clippy-lints.toml
@@ -0,0 +1,47 @@
+# Clippy category gates use priority -1 so per-lint allows can override them.
+cargo = { level = "warn", priority = -1 }
+complexity = { level = "warn", priority = -1 }
+correctness = { level = "warn", priority = -1 }
+nursery = { level = "warn", priority = -1 }
+pedantic = { level = "warn", priority = -1 }
+perf = { level = "warn", priority = -1 }
+style = { level = "warn", priority = -1 }
+suspicious = { level = "warn", priority = -1 }
+# Two-repo consensus (oxidizer + oxidizer-github). Restriction-group
+# lints that catch real code-smell cases. Adding a workspace-wide lint
+# means adopters can only opt out per-crate or by taking ownership of
+# this region; only enable when the consensus is strong enough to
+# justify that cost.
+allow_attributes = "warn"
+allow_attributes_without_reason = "warn"
+as_pointer_underscore = "warn"
+assertions_on_result_states = "warn"
+clone_on_ref_ptr = "warn"
+deref_by_slicing = "warn"
+disallowed_script_idents = "warn"
+empty_drop = "warn"
+empty_enum_variants_with_brackets = "warn"
+empty_structs_with_brackets = "warn"
+fn_to_numeric_cast_any = "warn"
+if_then_some_else_none = "warn"
+map_err_ignore = "warn"
+multiple_unsafe_ops_per_block = "warn"
+redundant_type_annotations = "warn"
+renamed_function_params = "warn"
+semicolon_outside_block = "warn"
+undocumented_unsafe_blocks = "warn"
+unnecessary_safety_comment = "warn"
+unnecessary_safety_doc = "warn"
+unneeded_field_pattern = "warn"
+unused_result_ok = "warn"
+unwrap_used = "warn"
+# Literal braces are valid data in templates and structured-logging messages.
+literal_string_with_formatting_args = "allow"
+missing_const_for_fn = "allow"
+multiple_crate_versions = "allow"
+option_if_let_else = "allow"
+redundant_pub_crate = "allow"
+should_panic_without_expect = "allow"
+significant_drop_tightening = "allow"
+# Blocked by Clippy bug: https://github.com/rust-lang/rust-clippy/issues/15036
+wildcard_imports = "allow"
diff --git a/crates/cargo-anvil/templates/regions/cargo-lints-body.toml b/crates/cargo-anvil/templates/regions/cargo-lints-body.toml
deleted file mode 100644
index 463475eb4..000000000
--- a/crates/cargo-anvil/templates/regions/cargo-lints-body.toml
+++ /dev/null
@@ -1,84 +0,0 @@
-# Catalog of opinionated lints, in dotted-key form so users can extend the
-# same scope (`[workspace.lints]` or `[lints]`) outside the sentinels.
-# The host-specific table header (`[workspace.lints]` or `[lints]`) is
-# prepended by cargo-anvil based on whether the manifest is a workspace
-# root or a single-crate Cargo.toml.
-
-# --- rust ------------------------------------------------------------------
-rust.ambiguous_negative_literals = "warn"
-rust.missing_debug_implementations = "warn"
-rust.redundant_imports = "warn"
-rust.redundant_lifetimes = "warn"
-rust.trivial_numeric_casts = "warn"
-rust.unsafe_op_in_unsafe_fn = "warn"
-rust.unused_lifetimes = "warn"
-# `unexpected_cfgs` is on-by-default at warn since Rust 1.80; combined
-# with the catalog's `-D warnings` cloud-workflow policy, any custom cfg name
-# becomes a hard build failure. Pre-declare the cfgs that
-# `cargo llvm-cov` sets so the recommended coverage-exclusion pattern
-# `#[cfg_attr(coverage_nightly, coverage(off))]` works out of the box.
-# Adopters who need additional cfg names take ownership of this one
-# line (edit the check-cfg array); anvil's drift detector will
-# emit a `.anvil-proposed` sibling on future catalog bumps so the
-# customization is preserved.
-rust.unexpected_cfgs = { level = "warn", check-cfg = [
- 'cfg(coverage,coverage_nightly)',
- 'cfg(loom)',
- 'cfg(miri_race_coverage)',
- 'cfg(miri_strict_provenance)',
- 'cfg(miri_tree_borrows)',
-] }
-
-# --- rustdoc ---------------------------------------------------------------
-rustdoc.broken_intra_doc_links = "warn"
-rustdoc.missing_crate_level_docs = "warn"
-rustdoc.unescaped_backticks = "warn"
-
-# --- clippy: category gates (priority -1 so per-lint allows can override) --
-clippy.cargo = { level = "warn", priority = -1 }
-clippy.complexity = { level = "warn", priority = -1 }
-clippy.correctness = { level = "warn", priority = -1 }
-clippy.nursery = { level = "warn", priority = -1 }
-clippy.pedantic = { level = "warn", priority = -1 }
-clippy.perf = { level = "warn", priority = -1 }
-clippy.style = { level = "warn", priority = -1 }
-clippy.suspicious = { level = "warn", priority = -1 }
-
-# --- clippy: opinionated additions -----------------------------------------
-# Two-repo consensus (oxidizer + oxidizer-github). Restriction-group
-# lints that catch real code-smell cases. Adding a workspace-wide lint
-# means adopters can only opt out per-crate or by taking ownership of
-# this region; only enable when the consensus is strong enough to
-# justify that cost.
-clippy.allow_attributes = "warn"
-clippy.allow_attributes_without_reason = "warn"
-clippy.as_pointer_underscore = "warn"
-clippy.assertions_on_result_states = "warn"
-clippy.clone_on_ref_ptr = "warn"
-clippy.deref_by_slicing = "warn"
-clippy.disallowed_script_idents = "warn"
-clippy.empty_drop = "warn"
-clippy.empty_enum_variants_with_brackets = "warn"
-clippy.fn_to_numeric_cast_any = "warn"
-clippy.if_then_some_else_none = "warn"
-clippy.map_err_ignore = "warn"
-clippy.multiple_unsafe_ops_per_block = "warn"
-clippy.redundant_type_annotations = "warn"
-clippy.renamed_function_params = "warn"
-clippy.semicolon_outside_block = "warn"
-clippy.undocumented_unsafe_blocks = "warn"
-clippy.unnecessary_safety_comment = "warn"
-clippy.unnecessary_safety_doc = "warn"
-clippy.unneeded_field_pattern = "warn"
-clippy.unused_result_ok = "warn"
-clippy.unwrap_used = "warn"
-
-# --- clippy: opinionated suppressions of category-enabled lints ------------
-clippy.missing_const_for_fn = "allow"
-clippy.multiple_crate_versions = "allow"
-clippy.option_if_let_else = "allow"
-clippy.redundant_pub_crate = "allow"
-clippy.should_panic_without_expect = "allow"
-clippy.significant_drop_tightening = "allow"
-# Blocked by Clippy bug: https://github.com/rust-lang/rust-clippy/issues/15036
-clippy.wildcard_imports = "allow"
diff --git a/crates/cargo-anvil/templates/regions/cargo-rust-lints.toml b/crates/cargo-anvil/templates/regions/cargo-rust-lints.toml
new file mode 100644
index 000000000..5d1a1c770
--- /dev/null
+++ b/crates/cargo-anvil/templates/regions/cargo-rust-lints.toml
@@ -0,0 +1,25 @@
+# Rust compiler lint policy. Repository-specific Rust lints can follow this
+# region as bare keys in the same table.
+ambiguous_negative_literals = "warn"
+missing_debug_implementations = "warn"
+redundant_imports = "warn"
+redundant_lifetimes = "warn"
+trivial_numeric_casts = "warn"
+unsafe_op_in_unsafe_fn = "warn"
+unused_lifetimes = "warn"
+# `unexpected_cfgs` is on-by-default at warn since Rust 1.80; combined
+# with the catalog's `-D warnings` cloud-workflow policy, any custom cfg name
+# becomes a hard build failure. Pre-declare the cfgs that
+# `cargo llvm-cov` sets so the recommended coverage-exclusion pattern
+# `#[cfg_attr(coverage_nightly, coverage(off))]` works out of the box.
+# Adopters who need additional cfg names take ownership of this one
+# assignment (edit the check-cfg array); anvil's drift detector will
+# emit a `.anvil-proposed` sibling on future catalog bumps so the
+# customization is preserved.
+unexpected_cfgs = { level = "warn", check-cfg = [
+ 'cfg(coverage,coverage_nightly)',
+ 'cfg(loom)',
+ 'cfg(miri_race_coverage)',
+ 'cfg(miri_strict_provenance)',
+ 'cfg(miri_tree_borrows)',
+] }
diff --git a/crates/cargo-anvil/templates/regions/cargo-rustdoc-lints.toml b/crates/cargo-anvil/templates/regions/cargo-rustdoc-lints.toml
new file mode 100644
index 000000000..74ebdba6f
--- /dev/null
+++ b/crates/cargo-anvil/templates/regions/cargo-rustdoc-lints.toml
@@ -0,0 +1,5 @@
+# Rustdoc lint policy. Repository-specific rustdoc lints can follow this
+# region as bare keys in the same table.
+broken_intra_doc_links = "warn"
+missing_crate_level_docs = "warn"
+unescaped_backticks = "warn"
diff --git a/crates/cargo-anvil/templates/regions/clippy.toml b/crates/cargo-anvil/templates/regions/clippy.toml
index 8d5be6246..347933766 100644
--- a/crates/cargo-anvil/templates/regions/clippy.toml
+++ b/crates/cargo-anvil/templates/regions/clippy.toml
@@ -24,7 +24,7 @@ allow-panic-in-tests = true
allow-unwrap-in-tests = true
# Aspirational: when clippy.wildcard_imports is re-enabled (currently
-# allowed in cargo-lints-body.toml due to upstream bug rust-clippy#15036),
+# allowed in cargo-clippy-lints.toml due to upstream bug rust-clippy#15036),
# we want the stricter variant that warns on ALL wildcard imports
# including prelude. Setting it now means flipping the lint level to
# warn later is a one-line change with no tuning afterthought.
diff --git a/crates/cargo-anvil/tests/fixtures.rs b/crates/cargo-anvil/tests/fixtures.rs
index 0176df1ee..b192dc878 100644
--- a/crates/cargo-anvil/tests/fixtures.rs
+++ b/crates/cargo-anvil/tests/fixtures.rs
@@ -106,12 +106,14 @@ fn single_crate_emits_crate_lints_and_justfiles() {
run(&tmp);
let cargo = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
+ for region in ["anvil-rust-lints", "anvil-rustdoc-lints", "anvil-clippy-lints"] {
+ assert!(
+ cargo.contains(&format!("anvil-managed: {region}")),
+ "single-crate fixture should receive the per-crate {region} region; got:\n{cargo}"
+ );
+ }
assert!(
- cargo.contains("anvil-managed: anvil-lints"),
- "single-crate fixture should receive the per-crate lints region; got:\n{cargo}"
- );
- assert!(
- !cargo.contains("anvil-workspace-lints"),
+ !cargo.contains("anvil-workspace-rust-lints"),
"single-crate fixture must not receive the workspace lints region"
);
@@ -280,10 +282,16 @@ fn migration_preserves_user_content() {
cargo.contains("lto = \"thin\""),
"user-authored [profile.release] must survive migration; got:\n{cargo}"
);
- assert!(
- cargo.contains("anvil-workspace-lints"),
- "anvil workspace lints region must be spliced into Cargo.toml"
- );
+ for region in [
+ "anvil-workspace-rust-lints",
+ "anvil-workspace-rustdoc-lints",
+ "anvil-workspace-clippy-lints",
+ ] {
+ assert!(
+ cargo.contains(&format!("anvil-managed: {region}")),
+ "anvil workspace {region} region must be spliced into Cargo.toml"
+ );
+ }
// The defect this fixture used to hide: the hand-written `[advisories]`
// declares an `ignore` list the managed body does not, so adoption cannot
diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap
index e525f8f11..71278ea64 100644
--- a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap
+++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap
@@ -1663,93 +1663,94 @@ stages:
resolver = "2"
members = ["crates/*"]
-# >>> anvil-managed: anvil-workspace-lints
-[workspace.lints]
-# Catalog of opinionated lints, in dotted-key form so users can extend the
-# same scope (`[workspace.lints]` or `[lints]`) outside the sentinels.
-# The host-specific table header (`[workspace.lints]` or `[lints]`) is
-# prepended by cargo-anvil based on whether the manifest is a workspace
-# root or a single-crate Cargo.toml.
-
-# --- rust ------------------------------------------------------------------
-rust.ambiguous_negative_literals = "warn"
-rust.missing_debug_implementations = "warn"
-rust.redundant_imports = "warn"
-rust.redundant_lifetimes = "warn"
-rust.trivial_numeric_casts = "warn"
-rust.unsafe_op_in_unsafe_fn = "warn"
-rust.unused_lifetimes = "warn"
+# >>> anvil-managed: anvil-workspace-rust-lints
+[workspace.lints.rust]
+# Rust compiler lint policy. Repository-specific Rust lints can follow this
+# region as bare keys in the same table.
+ambiguous_negative_literals = "warn"
+missing_debug_implementations = "warn"
+redundant_imports = "warn"
+redundant_lifetimes = "warn"
+trivial_numeric_casts = "warn"
+unsafe_op_in_unsafe_fn = "warn"
+unused_lifetimes = "warn"
# `unexpected_cfgs` is on-by-default at warn since Rust 1.80; combined
# with the catalog's `-D warnings` cloud-workflow policy, any custom cfg name
# becomes a hard build failure. Pre-declare the cfgs that
# `cargo llvm-cov` sets so the recommended coverage-exclusion pattern
# `#[cfg_attr(coverage_nightly, coverage(off))]` works out of the box.
# Adopters who need additional cfg names take ownership of this one
-# line (edit the check-cfg array); anvil's drift detector will
+# assignment (edit the check-cfg array); anvil's drift detector will
# emit a `.anvil-proposed` sibling on future catalog bumps so the
# customization is preserved.
-rust.unexpected_cfgs = { level = "warn", check-cfg = [
+unexpected_cfgs = { level = "warn", check-cfg = [
'cfg(coverage,coverage_nightly)',
'cfg(loom)',
'cfg(miri_race_coverage)',
'cfg(miri_strict_provenance)',
'cfg(miri_tree_borrows)',
] }
-
-# --- rustdoc ---------------------------------------------------------------
-rustdoc.broken_intra_doc_links = "warn"
-rustdoc.missing_crate_level_docs = "warn"
-rustdoc.unescaped_backticks = "warn"
-
-# --- clippy: category gates (priority -1 so per-lint allows can override) --
-clippy.cargo = { level = "warn", priority = -1 }
-clippy.complexity = { level = "warn", priority = -1 }
-clippy.correctness = { level = "warn", priority = -1 }
-clippy.nursery = { level = "warn", priority = -1 }
-clippy.pedantic = { level = "warn", priority = -1 }
-clippy.perf = { level = "warn", priority = -1 }
-clippy.style = { level = "warn", priority = -1 }
-clippy.suspicious = { level = "warn", priority = -1 }
-
-# --- clippy: opinionated additions -----------------------------------------
+# <<< anvil-managed: anvil-workspace-rust-lints
+
+# >>> anvil-managed: anvil-workspace-rustdoc-lints
+[workspace.lints.rustdoc]
+# Rustdoc lint policy. Repository-specific rustdoc lints can follow this
+# region as bare keys in the same table.
+broken_intra_doc_links = "warn"
+missing_crate_level_docs = "warn"
+unescaped_backticks = "warn"
+# <<< anvil-managed: anvil-workspace-rustdoc-lints
+
+# >>> anvil-managed: anvil-workspace-clippy-lints
+[workspace.lints.clippy]
+# Clippy category gates use priority -1 so per-lint allows can override them.
+cargo = { level = "warn", priority = -1 }
+complexity = { level = "warn", priority = -1 }
+correctness = { level = "warn", priority = -1 }
+nursery = { level = "warn", priority = -1 }
+pedantic = { level = "warn", priority = -1 }
+perf = { level = "warn", priority = -1 }
+style = { level = "warn", priority = -1 }
+suspicious = { level = "warn", priority = -1 }
# Two-repo consensus (oxidizer + oxidizer-github). Restriction-group
# lints that catch real code-smell cases. Adding a workspace-wide lint
# means adopters can only opt out per-crate or by taking ownership of
# this region; only enable when the consensus is strong enough to
# justify that cost.
-clippy.allow_attributes = "warn"
-clippy.allow_attributes_without_reason = "warn"
-clippy.as_pointer_underscore = "warn"
-clippy.assertions_on_result_states = "warn"
-clippy.clone_on_ref_ptr = "warn"
-clippy.deref_by_slicing = "warn"
-clippy.disallowed_script_idents = "warn"
-clippy.empty_drop = "warn"
-clippy.empty_enum_variants_with_brackets = "warn"
-clippy.fn_to_numeric_cast_any = "warn"
-clippy.if_then_some_else_none = "warn"
-clippy.map_err_ignore = "warn"
-clippy.multiple_unsafe_ops_per_block = "warn"
-clippy.redundant_type_annotations = "warn"
-clippy.renamed_function_params = "warn"
-clippy.semicolon_outside_block = "warn"
-clippy.undocumented_unsafe_blocks = "warn"
-clippy.unnecessary_safety_comment = "warn"
-clippy.unnecessary_safety_doc = "warn"
-clippy.unneeded_field_pattern = "warn"
-clippy.unused_result_ok = "warn"
-clippy.unwrap_used = "warn"
-
-# --- clippy: opinionated suppressions of category-enabled lints ------------
-clippy.missing_const_for_fn = "allow"
-clippy.multiple_crate_versions = "allow"
-clippy.option_if_let_else = "allow"
-clippy.redundant_pub_crate = "allow"
-clippy.should_panic_without_expect = "allow"
-clippy.significant_drop_tightening = "allow"
+allow_attributes = "warn"
+allow_attributes_without_reason = "warn"
+as_pointer_underscore = "warn"
+assertions_on_result_states = "warn"
+clone_on_ref_ptr = "warn"
+deref_by_slicing = "warn"
+disallowed_script_idents = "warn"
+empty_drop = "warn"
+empty_enum_variants_with_brackets = "warn"
+empty_structs_with_brackets = "warn"
+fn_to_numeric_cast_any = "warn"
+if_then_some_else_none = "warn"
+map_err_ignore = "warn"
+multiple_unsafe_ops_per_block = "warn"
+redundant_type_annotations = "warn"
+renamed_function_params = "warn"
+semicolon_outside_block = "warn"
+undocumented_unsafe_blocks = "warn"
+unnecessary_safety_comment = "warn"
+unnecessary_safety_doc = "warn"
+unneeded_field_pattern = "warn"
+unused_result_ok = "warn"
+unwrap_used = "warn"
+# Literal braces are valid data in templates and structured-logging messages.
+literal_string_with_formatting_args = "allow"
+missing_const_for_fn = "allow"
+multiple_crate_versions = "allow"
+option_if_let_else = "allow"
+redundant_pub_crate = "allow"
+should_panic_without_expect = "allow"
+significant_drop_tightening = "allow"
# Blocked by Clippy bug: https://github.com/rust-lang/rust-clippy/issues/15036
-clippy.wildcard_imports = "allow"
-# <<< anvil-managed: anvil-workspace-lints
+wildcard_imports = "allow"
+# <<< anvil-managed: anvil-workspace-clippy-lints
=== Justfile ===
# >>> anvil-managed: anvil-imports
@@ -1784,7 +1785,7 @@ allow-panic-in-tests = true
allow-unwrap-in-tests = true
# Aspirational: when clippy.wildcard_imports is re-enabled (currently
-# allowed in cargo-lints-body.toml due to upstream bug rust-clippy#15036),
+# allowed in cargo-clippy-lints.toml due to upstream bug rust-clippy#15036),
# we want the stricter variant that warns on ALL wildcard imports
# including prelude. Setting it now means flipping the lint level to
# warn later is a one-line change with no tuning afterthought.
@@ -3203,7 +3204,7 @@ anvil-miri-strict-provenance-validate-prereqs: anvil-component-nightly-miri-vali
# `#[cfg_attr(miri_, ignore = "")]`, keeping the
# suppression next to the test rather than in a sidecar file.
# The cfg names are declared in the workspace check-cfg list
-# (regions/cargo-lints-body.toml) so non-miri builds don't warn.
+# (regions/cargo-rust-lints.toml) so non-miri builds don't warn.
#
# Under scheduled-runtime-analysis these profiles run full-workspace:
# that tier sets ANVIL_IMPACT=off, so _anvil-impact-include resolves
diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap
index 1b48dc0eb..b24e22199 100644
--- a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap
+++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap
@@ -1776,93 +1776,94 @@ jobs:
resolver = "2"
members = ["crates/*"]
-# >>> anvil-managed: anvil-workspace-lints
-[workspace.lints]
-# Catalog of opinionated lints, in dotted-key form so users can extend the
-# same scope (`[workspace.lints]` or `[lints]`) outside the sentinels.
-# The host-specific table header (`[workspace.lints]` or `[lints]`) is
-# prepended by cargo-anvil based on whether the manifest is a workspace
-# root or a single-crate Cargo.toml.
-
-# --- rust ------------------------------------------------------------------
-rust.ambiguous_negative_literals = "warn"
-rust.missing_debug_implementations = "warn"
-rust.redundant_imports = "warn"
-rust.redundant_lifetimes = "warn"
-rust.trivial_numeric_casts = "warn"
-rust.unsafe_op_in_unsafe_fn = "warn"
-rust.unused_lifetimes = "warn"
+# >>> anvil-managed: anvil-workspace-rust-lints
+[workspace.lints.rust]
+# Rust compiler lint policy. Repository-specific Rust lints can follow this
+# region as bare keys in the same table.
+ambiguous_negative_literals = "warn"
+missing_debug_implementations = "warn"
+redundant_imports = "warn"
+redundant_lifetimes = "warn"
+trivial_numeric_casts = "warn"
+unsafe_op_in_unsafe_fn = "warn"
+unused_lifetimes = "warn"
# `unexpected_cfgs` is on-by-default at warn since Rust 1.80; combined
# with the catalog's `-D warnings` cloud-workflow policy, any custom cfg name
# becomes a hard build failure. Pre-declare the cfgs that
# `cargo llvm-cov` sets so the recommended coverage-exclusion pattern
# `#[cfg_attr(coverage_nightly, coverage(off))]` works out of the box.
# Adopters who need additional cfg names take ownership of this one
-# line (edit the check-cfg array); anvil's drift detector will
+# assignment (edit the check-cfg array); anvil's drift detector will
# emit a `.anvil-proposed` sibling on future catalog bumps so the
# customization is preserved.
-rust.unexpected_cfgs = { level = "warn", check-cfg = [
+unexpected_cfgs = { level = "warn", check-cfg = [
'cfg(coverage,coverage_nightly)',
'cfg(loom)',
'cfg(miri_race_coverage)',
'cfg(miri_strict_provenance)',
'cfg(miri_tree_borrows)',
] }
-
-# --- rustdoc ---------------------------------------------------------------
-rustdoc.broken_intra_doc_links = "warn"
-rustdoc.missing_crate_level_docs = "warn"
-rustdoc.unescaped_backticks = "warn"
-
-# --- clippy: category gates (priority -1 so per-lint allows can override) --
-clippy.cargo = { level = "warn", priority = -1 }
-clippy.complexity = { level = "warn", priority = -1 }
-clippy.correctness = { level = "warn", priority = -1 }
-clippy.nursery = { level = "warn", priority = -1 }
-clippy.pedantic = { level = "warn", priority = -1 }
-clippy.perf = { level = "warn", priority = -1 }
-clippy.style = { level = "warn", priority = -1 }
-clippy.suspicious = { level = "warn", priority = -1 }
-
-# --- clippy: opinionated additions -----------------------------------------
+# <<< anvil-managed: anvil-workspace-rust-lints
+
+# >>> anvil-managed: anvil-workspace-rustdoc-lints
+[workspace.lints.rustdoc]
+# Rustdoc lint policy. Repository-specific rustdoc lints can follow this
+# region as bare keys in the same table.
+broken_intra_doc_links = "warn"
+missing_crate_level_docs = "warn"
+unescaped_backticks = "warn"
+# <<< anvil-managed: anvil-workspace-rustdoc-lints
+
+# >>> anvil-managed: anvil-workspace-clippy-lints
+[workspace.lints.clippy]
+# Clippy category gates use priority -1 so per-lint allows can override them.
+cargo = { level = "warn", priority = -1 }
+complexity = { level = "warn", priority = -1 }
+correctness = { level = "warn", priority = -1 }
+nursery = { level = "warn", priority = -1 }
+pedantic = { level = "warn", priority = -1 }
+perf = { level = "warn", priority = -1 }
+style = { level = "warn", priority = -1 }
+suspicious = { level = "warn", priority = -1 }
# Two-repo consensus (oxidizer + oxidizer-github). Restriction-group
# lints that catch real code-smell cases. Adding a workspace-wide lint
# means adopters can only opt out per-crate or by taking ownership of
# this region; only enable when the consensus is strong enough to
# justify that cost.
-clippy.allow_attributes = "warn"
-clippy.allow_attributes_without_reason = "warn"
-clippy.as_pointer_underscore = "warn"
-clippy.assertions_on_result_states = "warn"
-clippy.clone_on_ref_ptr = "warn"
-clippy.deref_by_slicing = "warn"
-clippy.disallowed_script_idents = "warn"
-clippy.empty_drop = "warn"
-clippy.empty_enum_variants_with_brackets = "warn"
-clippy.fn_to_numeric_cast_any = "warn"
-clippy.if_then_some_else_none = "warn"
-clippy.map_err_ignore = "warn"
-clippy.multiple_unsafe_ops_per_block = "warn"
-clippy.redundant_type_annotations = "warn"
-clippy.renamed_function_params = "warn"
-clippy.semicolon_outside_block = "warn"
-clippy.undocumented_unsafe_blocks = "warn"
-clippy.unnecessary_safety_comment = "warn"
-clippy.unnecessary_safety_doc = "warn"
-clippy.unneeded_field_pattern = "warn"
-clippy.unused_result_ok = "warn"
-clippy.unwrap_used = "warn"
-
-# --- clippy: opinionated suppressions of category-enabled lints ------------
-clippy.missing_const_for_fn = "allow"
-clippy.multiple_crate_versions = "allow"
-clippy.option_if_let_else = "allow"
-clippy.redundant_pub_crate = "allow"
-clippy.should_panic_without_expect = "allow"
-clippy.significant_drop_tightening = "allow"
+allow_attributes = "warn"
+allow_attributes_without_reason = "warn"
+as_pointer_underscore = "warn"
+assertions_on_result_states = "warn"
+clone_on_ref_ptr = "warn"
+deref_by_slicing = "warn"
+disallowed_script_idents = "warn"
+empty_drop = "warn"
+empty_enum_variants_with_brackets = "warn"
+empty_structs_with_brackets = "warn"
+fn_to_numeric_cast_any = "warn"
+if_then_some_else_none = "warn"
+map_err_ignore = "warn"
+multiple_unsafe_ops_per_block = "warn"
+redundant_type_annotations = "warn"
+renamed_function_params = "warn"
+semicolon_outside_block = "warn"
+undocumented_unsafe_blocks = "warn"
+unnecessary_safety_comment = "warn"
+unnecessary_safety_doc = "warn"
+unneeded_field_pattern = "warn"
+unused_result_ok = "warn"
+unwrap_used = "warn"
+# Literal braces are valid data in templates and structured-logging messages.
+literal_string_with_formatting_args = "allow"
+missing_const_for_fn = "allow"
+multiple_crate_versions = "allow"
+option_if_let_else = "allow"
+redundant_pub_crate = "allow"
+should_panic_without_expect = "allow"
+significant_drop_tightening = "allow"
# Blocked by Clippy bug: https://github.com/rust-lang/rust-clippy/issues/15036
-clippy.wildcard_imports = "allow"
-# <<< anvil-managed: anvil-workspace-lints
+wildcard_imports = "allow"
+# <<< anvil-managed: anvil-workspace-clippy-lints
=== Justfile ===
# >>> anvil-managed: anvil-imports
@@ -1897,7 +1898,7 @@ allow-panic-in-tests = true
allow-unwrap-in-tests = true
# Aspirational: when clippy.wildcard_imports is re-enabled (currently
-# allowed in cargo-lints-body.toml due to upstream bug rust-clippy#15036),
+# allowed in cargo-clippy-lints.toml due to upstream bug rust-clippy#15036),
# we want the stricter variant that warns on ALL wildcard imports
# including prelude. Setting it now means flipping the lint level to
# warn later is a one-line change with no tuning afterthought.
@@ -3316,7 +3317,7 @@ anvil-miri-strict-provenance-validate-prereqs: anvil-component-nightly-miri-vali
# `#[cfg_attr(miri_, ignore = "")]`, keeping the
# suppression next to the test rather than in a sidecar file.
# The cfg names are declared in the workspace check-cfg list
-# (regions/cargo-lints-body.toml) so non-miri builds don't warn.
+# (regions/cargo-rust-lints.toml) so non-miri builds don't warn.
#
# Under scheduled-runtime-analysis these profiles run full-workspace:
# that tier sets ANVIL_IMPACT=off, so _anvil-impact-include resolves
diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap
index cf721233a..1efc24205 100644
--- a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap
+++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap
@@ -460,93 +460,94 @@ Finish with:
resolver = "2"
members = ["crates/*"]
-# >>> anvil-managed: anvil-workspace-lints
-[workspace.lints]
-# Catalog of opinionated lints, in dotted-key form so users can extend the
-# same scope (`[workspace.lints]` or `[lints]`) outside the sentinels.
-# The host-specific table header (`[workspace.lints]` or `[lints]`) is
-# prepended by cargo-anvil based on whether the manifest is a workspace
-# root or a single-crate Cargo.toml.
-
-# --- rust ------------------------------------------------------------------
-rust.ambiguous_negative_literals = "warn"
-rust.missing_debug_implementations = "warn"
-rust.redundant_imports = "warn"
-rust.redundant_lifetimes = "warn"
-rust.trivial_numeric_casts = "warn"
-rust.unsafe_op_in_unsafe_fn = "warn"
-rust.unused_lifetimes = "warn"
+# >>> anvil-managed: anvil-workspace-rust-lints
+[workspace.lints.rust]
+# Rust compiler lint policy. Repository-specific Rust lints can follow this
+# region as bare keys in the same table.
+ambiguous_negative_literals = "warn"
+missing_debug_implementations = "warn"
+redundant_imports = "warn"
+redundant_lifetimes = "warn"
+trivial_numeric_casts = "warn"
+unsafe_op_in_unsafe_fn = "warn"
+unused_lifetimes = "warn"
# `unexpected_cfgs` is on-by-default at warn since Rust 1.80; combined
# with the catalog's `-D warnings` cloud-workflow policy, any custom cfg name
# becomes a hard build failure. Pre-declare the cfgs that
# `cargo llvm-cov` sets so the recommended coverage-exclusion pattern
# `#[cfg_attr(coverage_nightly, coverage(off))]` works out of the box.
# Adopters who need additional cfg names take ownership of this one
-# line (edit the check-cfg array); anvil's drift detector will
+# assignment (edit the check-cfg array); anvil's drift detector will
# emit a `.anvil-proposed` sibling on future catalog bumps so the
# customization is preserved.
-rust.unexpected_cfgs = { level = "warn", check-cfg = [
+unexpected_cfgs = { level = "warn", check-cfg = [
'cfg(coverage,coverage_nightly)',
'cfg(loom)',
'cfg(miri_race_coverage)',
'cfg(miri_strict_provenance)',
'cfg(miri_tree_borrows)',
] }
-
-# --- rustdoc ---------------------------------------------------------------
-rustdoc.broken_intra_doc_links = "warn"
-rustdoc.missing_crate_level_docs = "warn"
-rustdoc.unescaped_backticks = "warn"
-
-# --- clippy: category gates (priority -1 so per-lint allows can override) --
-clippy.cargo = { level = "warn", priority = -1 }
-clippy.complexity = { level = "warn", priority = -1 }
-clippy.correctness = { level = "warn", priority = -1 }
-clippy.nursery = { level = "warn", priority = -1 }
-clippy.pedantic = { level = "warn", priority = -1 }
-clippy.perf = { level = "warn", priority = -1 }
-clippy.style = { level = "warn", priority = -1 }
-clippy.suspicious = { level = "warn", priority = -1 }
-
-# --- clippy: opinionated additions -----------------------------------------
+# <<< anvil-managed: anvil-workspace-rust-lints
+
+# >>> anvil-managed: anvil-workspace-rustdoc-lints
+[workspace.lints.rustdoc]
+# Rustdoc lint policy. Repository-specific rustdoc lints can follow this
+# region as bare keys in the same table.
+broken_intra_doc_links = "warn"
+missing_crate_level_docs = "warn"
+unescaped_backticks = "warn"
+# <<< anvil-managed: anvil-workspace-rustdoc-lints
+
+# >>> anvil-managed: anvil-workspace-clippy-lints
+[workspace.lints.clippy]
+# Clippy category gates use priority -1 so per-lint allows can override them.
+cargo = { level = "warn", priority = -1 }
+complexity = { level = "warn", priority = -1 }
+correctness = { level = "warn", priority = -1 }
+nursery = { level = "warn", priority = -1 }
+pedantic = { level = "warn", priority = -1 }
+perf = { level = "warn", priority = -1 }
+style = { level = "warn", priority = -1 }
+suspicious = { level = "warn", priority = -1 }
# Two-repo consensus (oxidizer + oxidizer-github). Restriction-group
# lints that catch real code-smell cases. Adding a workspace-wide lint
# means adopters can only opt out per-crate or by taking ownership of
# this region; only enable when the consensus is strong enough to
# justify that cost.
-clippy.allow_attributes = "warn"
-clippy.allow_attributes_without_reason = "warn"
-clippy.as_pointer_underscore = "warn"
-clippy.assertions_on_result_states = "warn"
-clippy.clone_on_ref_ptr = "warn"
-clippy.deref_by_slicing = "warn"
-clippy.disallowed_script_idents = "warn"
-clippy.empty_drop = "warn"
-clippy.empty_enum_variants_with_brackets = "warn"
-clippy.fn_to_numeric_cast_any = "warn"
-clippy.if_then_some_else_none = "warn"
-clippy.map_err_ignore = "warn"
-clippy.multiple_unsafe_ops_per_block = "warn"
-clippy.redundant_type_annotations = "warn"
-clippy.renamed_function_params = "warn"
-clippy.semicolon_outside_block = "warn"
-clippy.undocumented_unsafe_blocks = "warn"
-clippy.unnecessary_safety_comment = "warn"
-clippy.unnecessary_safety_doc = "warn"
-clippy.unneeded_field_pattern = "warn"
-clippy.unused_result_ok = "warn"
-clippy.unwrap_used = "warn"
-
-# --- clippy: opinionated suppressions of category-enabled lints ------------
-clippy.missing_const_for_fn = "allow"
-clippy.multiple_crate_versions = "allow"
-clippy.option_if_let_else = "allow"
-clippy.redundant_pub_crate = "allow"
-clippy.should_panic_without_expect = "allow"
-clippy.significant_drop_tightening = "allow"
+allow_attributes = "warn"
+allow_attributes_without_reason = "warn"
+as_pointer_underscore = "warn"
+assertions_on_result_states = "warn"
+clone_on_ref_ptr = "warn"
+deref_by_slicing = "warn"
+disallowed_script_idents = "warn"
+empty_drop = "warn"
+empty_enum_variants_with_brackets = "warn"
+empty_structs_with_brackets = "warn"
+fn_to_numeric_cast_any = "warn"
+if_then_some_else_none = "warn"
+map_err_ignore = "warn"
+multiple_unsafe_ops_per_block = "warn"
+redundant_type_annotations = "warn"
+renamed_function_params = "warn"
+semicolon_outside_block = "warn"
+undocumented_unsafe_blocks = "warn"
+unnecessary_safety_comment = "warn"
+unnecessary_safety_doc = "warn"
+unneeded_field_pattern = "warn"
+unused_result_ok = "warn"
+unwrap_used = "warn"
+# Literal braces are valid data in templates and structured-logging messages.
+literal_string_with_formatting_args = "allow"
+missing_const_for_fn = "allow"
+multiple_crate_versions = "allow"
+option_if_let_else = "allow"
+redundant_pub_crate = "allow"
+should_panic_without_expect = "allow"
+significant_drop_tightening = "allow"
# Blocked by Clippy bug: https://github.com/rust-lang/rust-clippy/issues/15036
-clippy.wildcard_imports = "allow"
-# <<< anvil-managed: anvil-workspace-lints
+wildcard_imports = "allow"
+# <<< anvil-managed: anvil-workspace-clippy-lints
=== Justfile ===
# >>> anvil-managed: anvil-imports
@@ -581,7 +582,7 @@ allow-panic-in-tests = true
allow-unwrap-in-tests = true
# Aspirational: when clippy.wildcard_imports is re-enabled (currently
-# allowed in cargo-lints-body.toml due to upstream bug rust-clippy#15036),
+# allowed in cargo-clippy-lints.toml due to upstream bug rust-clippy#15036),
# we want the stricter variant that warns on ALL wildcard imports
# including prelude. Setting it now means flipping the lint level to
# warn later is a one-line change with no tuning afterthought.
@@ -2000,7 +2001,7 @@ anvil-miri-strict-provenance-validate-prereqs: anvil-component-nightly-miri-vali
# `#[cfg_attr(miri_, ignore = "")]`, keeping the
# suppression next to the test rather than in a sidecar file.
# The cfg names are declared in the workspace check-cfg list
-# (regions/cargo-lints-body.toml) so non-miri builds don't warn.
+# (regions/cargo-rust-lints.toml) so non-miri builds don't warn.
#
# Under scheduled-runtime-analysis these profiles run full-workspace:
# that tier sets ANVIL_IMPACT=off, so _anvil-impact-include resolves
diff --git a/crates/cargo-anvil/tests/snapshots/toml_adoption__keeps_an_unmanaged_dotted_lint.snap b/crates/cargo-anvil/tests/snapshots/toml_adoption__keeps_an_unmanaged_dotted_lint.snap
index d1b87263b..3e48cc05c 100644
--- a/crates/cargo-anvil/tests/snapshots/toml_adoption__keeps_an_unmanaged_dotted_lint.snap
+++ b/crates/cargo-anvil/tests/snapshots/toml_adoption__keeps_an_unmanaged_dotted_lint.snap
@@ -12,8 +12,14 @@ members = ["crates/*"]
rust.a_custom_lint = "warn"
rust.unsafe_op_in_unsafe_fn = "warn"
+[profile.release]
+lto = "thin"
+incremental = false
+
--- decisions ---
-anvil-workspace-lints: Write
+anvil-workspace-clippy-lints: Write
+anvil-workspace-rust-lints: Write
+anvil-workspace-rustdoc-lints: Write
--- refusals ---
(none)
@@ -23,92 +29,98 @@ anvil-workspace-lints: Write
resolver = "2"
members = ["crates/*"]
-# >>> anvil-managed: anvil-workspace-lints
[workspace.lints]
-# Catalog of opinionated lints, in dotted-key form so users can extend the
-# same scope (`[workspace.lints]` or `[lints]`) outside the sentinels.
-# The host-specific table header (`[workspace.lints]` or `[lints]`) is
-# prepended by cargo-anvil based on whether the manifest is a workspace
-# root or a single-crate Cargo.toml.
-
-# --- rust ------------------------------------------------------------------
-rust.ambiguous_negative_literals = "warn"
-rust.missing_debug_implementations = "warn"
-rust.redundant_imports = "warn"
-rust.redundant_lifetimes = "warn"
-rust.trivial_numeric_casts = "warn"
-rust.unsafe_op_in_unsafe_fn = "warn"
-rust.unused_lifetimes = "warn"
+# >>> anvil-managed: anvil-workspace-rust-lints
+[workspace.lints.rust]
+# Rust compiler lint policy. Repository-specific Rust lints can follow this
+# region as bare keys in the same table.
+ambiguous_negative_literals = "warn"
+missing_debug_implementations = "warn"
+redundant_imports = "warn"
+redundant_lifetimes = "warn"
+trivial_numeric_casts = "warn"
+unsafe_op_in_unsafe_fn = "warn"
+unused_lifetimes = "warn"
# `unexpected_cfgs` is on-by-default at warn since Rust 1.80; combined
# with the catalog's `-D warnings` cloud-workflow policy, any custom cfg name
# becomes a hard build failure. Pre-declare the cfgs that
# `cargo llvm-cov` sets so the recommended coverage-exclusion pattern
# `#[cfg_attr(coverage_nightly, coverage(off))]` works out of the box.
# Adopters who need additional cfg names take ownership of this one
-# line (edit the check-cfg array); anvil's drift detector will
+# assignment (edit the check-cfg array); anvil's drift detector will
# emit a `.anvil-proposed` sibling on future catalog bumps so the
# customization is preserved.
-rust.unexpected_cfgs = { level = "warn", check-cfg = [
+unexpected_cfgs = { level = "warn", check-cfg = [
'cfg(coverage,coverage_nightly)',
'cfg(loom)',
'cfg(miri_race_coverage)',
'cfg(miri_strict_provenance)',
'cfg(miri_tree_borrows)',
] }
+# <<< anvil-managed: anvil-workspace-rust-lints
+# House rule, not in anvil's catalog.
+a_custom_lint = "warn"
-# --- rustdoc ---------------------------------------------------------------
-rustdoc.broken_intra_doc_links = "warn"
-rustdoc.missing_crate_level_docs = "warn"
-rustdoc.unescaped_backticks = "warn"
-
-# --- clippy: category gates (priority -1 so per-lint allows can override) --
-clippy.cargo = { level = "warn", priority = -1 }
-clippy.complexity = { level = "warn", priority = -1 }
-clippy.correctness = { level = "warn", priority = -1 }
-clippy.nursery = { level = "warn", priority = -1 }
-clippy.pedantic = { level = "warn", priority = -1 }
-clippy.perf = { level = "warn", priority = -1 }
-clippy.style = { level = "warn", priority = -1 }
-clippy.suspicious = { level = "warn", priority = -1 }
+# >>> anvil-managed: anvil-workspace-rustdoc-lints
+[workspace.lints.rustdoc]
+# Rustdoc lint policy. Repository-specific rustdoc lints can follow this
+# region as bare keys in the same table.
+broken_intra_doc_links = "warn"
+missing_crate_level_docs = "warn"
+unescaped_backticks = "warn"
+# <<< anvil-managed: anvil-workspace-rustdoc-lints
-# --- clippy: opinionated additions -----------------------------------------
+# >>> anvil-managed: anvil-workspace-clippy-lints
+[workspace.lints.clippy]
+# Clippy category gates use priority -1 so per-lint allows can override them.
+cargo = { level = "warn", priority = -1 }
+complexity = { level = "warn", priority = -1 }
+correctness = { level = "warn", priority = -1 }
+nursery = { level = "warn", priority = -1 }
+pedantic = { level = "warn", priority = -1 }
+perf = { level = "warn", priority = -1 }
+style = { level = "warn", priority = -1 }
+suspicious = { level = "warn", priority = -1 }
# Two-repo consensus (oxidizer + oxidizer-github). Restriction-group
# lints that catch real code-smell cases. Adding a workspace-wide lint
# means adopters can only opt out per-crate or by taking ownership of
# this region; only enable when the consensus is strong enough to
# justify that cost.
-clippy.allow_attributes = "warn"
-clippy.allow_attributes_without_reason = "warn"
-clippy.as_pointer_underscore = "warn"
-clippy.assertions_on_result_states = "warn"
-clippy.clone_on_ref_ptr = "warn"
-clippy.deref_by_slicing = "warn"
-clippy.disallowed_script_idents = "warn"
-clippy.empty_drop = "warn"
-clippy.empty_enum_variants_with_brackets = "warn"
-clippy.fn_to_numeric_cast_any = "warn"
-clippy.if_then_some_else_none = "warn"
-clippy.map_err_ignore = "warn"
-clippy.multiple_unsafe_ops_per_block = "warn"
-clippy.redundant_type_annotations = "warn"
-clippy.renamed_function_params = "warn"
-clippy.semicolon_outside_block = "warn"
-clippy.undocumented_unsafe_blocks = "warn"
-clippy.unnecessary_safety_comment = "warn"
-clippy.unnecessary_safety_doc = "warn"
-clippy.unneeded_field_pattern = "warn"
-clippy.unused_result_ok = "warn"
-clippy.unwrap_used = "warn"
-
-# --- clippy: opinionated suppressions of category-enabled lints ------------
-clippy.missing_const_for_fn = "allow"
-clippy.multiple_crate_versions = "allow"
-clippy.option_if_let_else = "allow"
-clippy.redundant_pub_crate = "allow"
-clippy.should_panic_without_expect = "allow"
-clippy.significant_drop_tightening = "allow"
+allow_attributes = "warn"
+allow_attributes_without_reason = "warn"
+as_pointer_underscore = "warn"
+assertions_on_result_states = "warn"
+clone_on_ref_ptr = "warn"
+deref_by_slicing = "warn"
+disallowed_script_idents = "warn"
+empty_drop = "warn"
+empty_enum_variants_with_brackets = "warn"
+empty_structs_with_brackets = "warn"
+fn_to_numeric_cast_any = "warn"
+if_then_some_else_none = "warn"
+map_err_ignore = "warn"
+multiple_unsafe_ops_per_block = "warn"
+redundant_type_annotations = "warn"
+renamed_function_params = "warn"
+semicolon_outside_block = "warn"
+undocumented_unsafe_blocks = "warn"
+unnecessary_safety_comment = "warn"
+unnecessary_safety_doc = "warn"
+unneeded_field_pattern = "warn"
+unused_result_ok = "warn"
+unwrap_used = "warn"
+# Literal braces are valid data in templates and structured-logging messages.
+literal_string_with_formatting_args = "allow"
+missing_const_for_fn = "allow"
+multiple_crate_versions = "allow"
+option_if_let_else = "allow"
+redundant_pub_crate = "allow"
+should_panic_without_expect = "allow"
+significant_drop_tightening = "allow"
# Blocked by Clippy bug: https://github.com/rust-lang/rust-clippy/issues/15036
-clippy.wildcard_imports = "allow"
-# <<< anvil-managed: anvil-workspace-lints
-# House rule, not in anvil's catalog.
-rust.a_custom_lint = "warn"
+wildcard_imports = "allow"
+# <<< anvil-managed: anvil-workspace-clippy-lints
+
+[profile.release]
+lto = "thin"
+incremental = false
diff --git a/crates/cargo-anvil/tests/toml_adoption.rs b/crates/cargo-anvil/tests/toml_adoption.rs
index a551b2580..7d4f6d437 100644
--- a/crates/cargo-anvil/tests/toml_adoption.rs
+++ b/crates/cargo-anvil/tests/toml_adoption.rs
@@ -30,7 +30,7 @@
use std::fmt::Write as _;
use std::path::Path;
-use cargo_anvil::test_support::{Cli, RunOutcome, Target, run_update};
+use cargo_anvil::test_support::{Cli, Decision, Manifest, RunOutcome, Target, checksum_str, run_update};
use tempfile::TempDir;
/// A workspace with nothing in it but the manifest anvil needs to find, plus
@@ -214,13 +214,246 @@ members = [\"crates/*\"]
# House rule, not in anvil's catalog.
rust.a_custom_lint = \"warn\"
rust.unsafe_op_in_unsafe_fn = \"warn\"
+
+[profile.release]
+lto = \"thin\"
+incremental = false
";
let tmp = workspace_with("Cargo.toml", before);
let outcome = run(&tmp);
+ let after = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
+ let document: toml_edit::DocumentMut = after.parse().unwrap();
+ assert_eq!(document["workspace"]["lints"]["rust"]["a_custom_lint"].as_str(), Some("warn"));
+ assert_eq!(document["profile"]["release"]["lto"].as_str(), Some("thin"));
+ assert_eq!(document["profile"]["release"]["incremental"].as_bool(), Some(false));
insta::assert_snapshot!("keeps_an_unmanaged_dotted_lint", report(before, &tmp, "Cargo.toml", &outcome));
}
+#[test]
+fn replaces_the_combined_lint_region_without_losing_local_lints() {
+ let old_body = "[workspace.lints]\nrust.unsafe_op_in_unsafe_fn = \"warn\"\n";
+ let before = format!(
+ "[workspace]\nresolver = \"2\"\nmembers = [\"crates/*\"]\n\n\
+ # >>> anvil-managed: anvil-workspace-lints\n{old_body}\
+ # <<< anvil-managed: anvil-workspace-lints\n\n\
+ # Repository-specific lint policy.\n\
+ rust.missing_docs = \"warn\"\n\
+ clippy.panic = \"warn\"\n"
+ );
+ let tmp = workspace_with("Cargo.toml", &before);
+ let mut manifest = Manifest::default();
+ manifest.set_region("Cargo.toml", "anvil-workspace-lints", checksum_str(old_body));
+ manifest.save(tmp.path()).unwrap();
+
+ let outcome = run(&tmp);
+ let after = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
+ assert!(outcome.plan.refusals().is_empty(), "{:?}\n{after}", outcome.plan.refusals());
+ let document: toml_edit::DocumentMut = after.parse().unwrap();
+ assert!(!after.contains("anvil-workspace-lints"));
+ assert!(after.contains("[workspace.lints.rust]\n"));
+ assert_eq!(document["workspace"]["lints"]["rust"]["missing_docs"].as_str(), Some("warn"));
+ assert!(after.contains("[workspace.lints.clippy]\n"));
+ assert_eq!(document["workspace"]["lints"]["clippy"]["panic"].as_str(), Some("warn"));
+ assert!(!after.contains("rust.missing_docs"), "{after}");
+ assert!(!after.contains("clippy.panic"), "{after}");
+
+ let second = run(&tmp);
+ assert!(
+ second.plan.items().iter().all(|item| item.decision == Decision::InSync),
+ "the migration must settle in one run: {:?}",
+ second.plan.items()
+ );
+}
+
+#[test]
+fn combined_lint_migration_preserves_other_lint_namespaces() {
+ let old_body = "[workspace.lints]\nrust.unsafe_op_in_unsafe_fn = \"warn\"\n";
+ let before = format!(
+ "[workspace]\nresolver = \"2\"\nmembers = [\"crates/*\"]\n\n\
+ # >>> anvil-managed: anvil-workspace-lints\n{old_body}\
+ # <<< anvil-managed: anvil-workspace-lints\n\n\
+ rust.missing_docs = \"warn\"\n\
+ other.custom = \"warn\"\n"
+ );
+ let tmp = workspace_with("Cargo.toml", &before);
+ let mut manifest = Manifest::default();
+ manifest.set_region("Cargo.toml", "anvil-workspace-lints", checksum_str(old_body));
+ manifest.save(tmp.path()).unwrap();
+
+ let outcome = run(&tmp);
+ let after = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
+ assert!(outcome.plan.refusals().is_empty(), "{:?}\n{after}", outcome.plan.refusals());
+ let document: toml_edit::DocumentMut = after.parse().unwrap();
+ assert_eq!(document["workspace"]["lints"]["rust"]["missing_docs"].as_str(), Some("warn"));
+ assert_eq!(document["workspace"]["lints"]["other"]["custom"].as_str(), Some("warn"));
+
+ let second = run(&tmp);
+ assert!(
+ second.plan.items().iter().all(|item| item.decision == Decision::InSync),
+ "the migration must settle in one run: {:?}",
+ second.plan.items()
+ );
+}
+
+#[test]
+fn empty_combined_lint_region_still_migrates_following_local_lints() {
+ let old_body = "";
+ let before = "\
+[workspace]
+resolver = \"2\"
+members = [\"crates/*\"]
+
+# >>> anvil-managed: anvil-workspace-lints
+# <<< anvil-managed: anvil-workspace-lints
+
+rust.missing_docs = \"warn\"
+clippy.panic = \"warn\"
+";
+ let tmp = workspace_with("Cargo.toml", before);
+ let mut manifest = Manifest::default();
+ manifest.set_region("Cargo.toml", "anvil-workspace-lints", checksum_str(old_body));
+ manifest.save(tmp.path()).unwrap();
+
+ let outcome = run(&tmp);
+ let after = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
+ assert!(outcome.plan.refusals().is_empty(), "{:?}\n{after}", outcome.plan.refusals());
+ let document: toml_edit::DocumentMut = after.parse().unwrap();
+ assert_eq!(document["workspace"]["lints"]["rust"]["missing_docs"].as_str(), Some("warn"));
+ assert_eq!(document["workspace"]["lints"]["clippy"]["panic"].as_str(), Some("warn"));
+ assert!(!after.contains("anvil-workspace-lints"));
+}
+
+#[test]
+fn untracked_combined_lint_region_is_refused() {
+ let old_body = "[workspace.lints]\nrust.unsafe_op_in_unsafe_fn = \"warn\"\n";
+ let before = format!(
+ "[workspace]\nresolver = \"2\"\nmembers = [\"crates/*\"]\n\n\
+ # >>> anvil-managed: anvil-workspace-lints\n{old_body}\
+ # <<< anvil-managed: anvil-workspace-lints\n"
+ );
+ let tmp = workspace_with("Cargo.toml", &before);
+
+ let outcome = run(&tmp);
+ let after = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
+ assert!(
+ outcome
+ .plan
+ .refusals()
+ .iter()
+ .any(|item| item.contains("manifest does not record it as owned")),
+ "{:?}",
+ outcome.plan.refusals()
+ );
+ assert_eq!(after, before);
+}
+
+#[test]
+fn untracked_single_crate_combined_lint_region_is_refused() {
+ let old_body = "[lints]\nrust.unsafe_op_in_unsafe_fn = \"warn\"\n";
+ let before = format!(
+ "[package]\nname = \"alpha\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n\
+ # >>> anvil-managed: anvil-lints\n{old_body}\
+ # <<< anvil-managed: anvil-lints\n"
+ );
+ let tmp = TempDir::new().unwrap();
+ write(&tmp.path().join("Cargo.toml"), &before);
+ write(&tmp.path().join("src/lib.rs"), "");
+
+ let outcome = run(&tmp);
+ let after = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
+ assert!(
+ outcome
+ .plan
+ .refusals()
+ .iter()
+ .any(|item| item.contains("manifest does not record it as owned")),
+ "{:?}",
+ outcome.plan.refusals()
+ );
+ assert_eq!(after, before);
+}
+
+#[test]
+fn edited_combined_lint_region_is_refused() {
+ let old_body = "[workspace.lints]\nrust.unsafe_op_in_unsafe_fn = \"warn\"\n";
+ let edited_body = "[workspace.lints]\nrust.unsafe_op_in_unsafe_fn = \"deny\"\n";
+ let before = format!(
+ "[workspace]\nresolver = \"2\"\nmembers = [\"crates/*\"]\n\n\
+ # >>> anvil-managed: anvil-workspace-lints\n{edited_body}\
+ # <<< anvil-managed: anvil-workspace-lints\n"
+ );
+ let tmp = workspace_with("Cargo.toml", &before);
+ let mut manifest = Manifest::default();
+ manifest.set_region("Cargo.toml", "anvil-workspace-lints", checksum_str(old_body));
+ manifest.save(tmp.path()).unwrap();
+
+ let outcome = run(&tmp);
+ let after = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
+ assert!(
+ outcome
+ .plan
+ .refusals()
+ .iter()
+ .any(|item| item.contains("legacy managed region 'anvil-workspace-lints' contains edits")),
+ "{:?}",
+ outcome.plan.refusals()
+ );
+ assert_eq!(after, before);
+}
+
+#[test]
+fn empty_combined_lint_region_after_external_parent_table_is_refused() {
+ let old_body = "";
+ let before = "\
+[workspace]
+resolver = \"2\"
+members = [\"crates/*\"]
+
+[workspace.lints]
+rust.unreachable_pub = \"warn\"
+
+# >>> anvil-managed: anvil-workspace-lints
+# <<< anvil-managed: anvil-workspace-lints
+
+rust.missing_docs = \"warn\"
+";
+ let tmp = workspace_with("Cargo.toml", before);
+ let mut manifest = Manifest::default();
+ manifest.set_region("Cargo.toml", "anvil-workspace-lints", checksum_str(old_body));
+ manifest.save(tmp.path()).unwrap();
+
+ let outcome = run(&tmp);
+ let after = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
+ assert!(!outcome.plan.refusals().is_empty(), "{after}");
+ assert_eq!(after, before);
+}
+
+#[test]
+fn replaces_the_single_crate_combined_lint_region() {
+ let old_body = "[lints]\nrust.unsafe_op_in_unsafe_fn = \"warn\"\n";
+ let before = format!(
+ "[package]\nname = \"alpha\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n\
+ # >>> anvil-managed: anvil-lints\n{old_body}\
+ # <<< anvil-managed: anvil-lints\n\n\
+ rust.missing_docs = \"warn\"\n"
+ );
+ let tmp = TempDir::new().unwrap();
+ write(&tmp.path().join("Cargo.toml"), &before);
+ write(&tmp.path().join("src/lib.rs"), "");
+ let mut manifest = Manifest::default();
+ manifest.set_region("Cargo.toml", "anvil-lints", checksum_str(old_body));
+ manifest.save(tmp.path()).unwrap();
+
+ let outcome = run(&tmp);
+ let after = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
+ assert!(outcome.plan.refusals().is_empty(), "{:?}\n{after}", outcome.plan.refusals());
+ let document: toml_edit::DocumentMut = after.parse().unwrap();
+ assert_eq!(document["lints"]["rust"]["missing_docs"].as_str(), Some("warn"));
+ assert!(after.contains("anvil-managed: anvil-rust-lints"));
+ assert!(!after.contains("anvil-managed: anvil-lints\n"));
+}
+
/// Catalog arrays-of-tables are unsupported, but user bin targets survive.
#[test]
fn preserves_user_binary_targets() {
diff --git a/justfiles/anvil/checks/miri-tree-borrows.just b/justfiles/anvil/checks/miri-tree-borrows.just
index bfafb7a0c..c58e56e6f 100644
--- a/justfiles/anvil/checks/miri-tree-borrows.just
+++ b/justfiles/anvil/checks/miri-tree-borrows.just
@@ -12,7 +12,7 @@
# `#[cfg_attr(miri_, ignore = "")]`, keeping the
# suppression next to the test rather than in a sidecar file.
# The cfg names are declared in the workspace check-cfg list
-# (regions/cargo-lints-body.toml) so non-miri builds don't warn.
+# (regions/cargo-rust-lints.toml) so non-miri builds don't warn.
#
# Under scheduled-runtime-analysis these profiles run full-workspace:
# that tier sets ANVIL_IMPACT=off, so _anvil-impact-include resolves