Skip to content
Merged
30 changes: 30 additions & 0 deletions docs/adr-005-injected-environment-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# ADR-005: Inject Process Environment Access

- Status: Accepted
- Date: 2026-08-02
- Deciders: Agent template maintainers

## Context and Decision

In the context of generated Rust projects whose behaviour depends on
environment variables, facing process-global reads that hide dependencies and
in-process mutation that makes parallel and property-based tests interfere, we
decided for injecting `mockable::Env`, constructing `mockable::DefaultEnv` only
at the production composition root, using `mockable::MockEnv` in tests, and
allowing `assert_cmd` to configure only isolated child processes, and against
direct `std::env` access in domain code, harness-process mutation, shared
locks, or serial-test attributes, to achieve explicit production signatures and
deterministic tests that remain safe under concurrency, accepting a small
adapter at each composition root and dependency parameters on environment-aware
behaviour.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

## Consequences

- Clippy rejects direct process-environment reads, iteration, and mutation;
sanctioned composition-root adapters require a narrow, reasoned expectation.
- Production code receives `mockable::Env`, composition roots construct
`mockable::DefaultEnv`, and tests use `mockable::MockEnv` without changing
the harness environment.
- Process-wide locks are prohibited because they serialize the suite without
removing the ambient dependency; only `assert_cmd` child-process environment
configuration is exempt.
34 changes: 34 additions & 0 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,40 @@ Makefiles with `mbake`, and parse generated Cargo and workflow configuration.
The Polonius contract checks every `RUSTFLAGS` override, including Linux mold
linking, LLVM coverage, and cross-platform application releases.

## Generated Lint and Environment Contract

The template treats warnings as failures across generated compilation, tests,
documentation, and doctests. Package manifests deny unsafe code, missing
documentation, malformed Rustdoc, assertions without custom diagnostics, and
the process-environment methods listed in `template/clippy.toml`. The generated
Makefile preserves mandatory Rust, Rustdoc, and optional Polonius flags while
appending inherited flags. `make lint` runs the warning-denied documentation
build before Clippy and Whitaker; `make test` applies warning denial to its
test runner and its separate all-feature workspace doctest run.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

The environment boundary is dependency injection. Production behaviour accepts
`mockable::Env`; only the composition root constructs `mockable::DefaultEnv`.
Tests supply `mockable::MockEnv`. In-process mutation and shared locking are
not fallbacks. Only `assert_cmd` child-process configuration may set or clear
an environment because it cannot mutate the harness process. The rationale and
consequences are recorded in
[ADR-005](adr-005-injected-environment-boundary.md).

Parent tests prove the contract at three levels:

- `test_rendered_agents_requires_injected_environment_and_diagnostics` reads a
rendered `AGENTS.md` and asserts the contributor-facing boundaries.
- `test_generated_lint_configuration_enforces_environment_injection` parses
the generated lint tables and disallowed-method configuration.
- `test_make_lint_rejects_environment_policy_violations` and
`test_make_lint_rejects_rust_and_rustdoc_policy_violations` render isolated
projects, introduce one violation, run the public `make lint` command, and
require the corresponding diagnostic.

Keep these as semantic assertions. Add a rejection case when adding a lint with
a deterministic compiler or Rustdoc failure; do not replace focused contracts
with a broad generated-file snapshot.

## Formatting, Linting, and Type Checking

The parent gates run Ruff and mypy over the `tests/` tree; install `uv` so
Expand Down
49 changes: 47 additions & 2 deletions docs/users-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,62 @@ Development builds use Cranelift for debug code generation. On Linux targets,
link quickly. Coverage generation uses `lld` instead because LLVM coverage
tools expect LLVM-compatible linker behaviour.

## Validation and Environment Policy

Generated manifests deny `unknown_lints`, `renamed_and_removed_lints`,
`unsafe_code`, and `missing_docs`. Rustdoc denies
`missing_crate_level_docs`, `broken_intra_doc_links`,
`private_intra_doc_links`, `bare_urls`, `invalid_html_tags`,
`invalid_codeblock_attributes`, and `unescaped_backticks`. Clippy denies
`missing_assert_message` and uses `disallowed_methods` to reject direct calls
to process-environment readers, iterators, and mutation functions. Warnings are
validation failures; these policies are not advisory.

Code that depends on environment variables must receive `mockable::Env` (or a
narrow equivalent closure) as a dependency. The production composition root
constructs `mockable::DefaultEnv`, while tests use `mockable::MockEnv`.
In-process tests must not mutate the process environment or serialize mutation
behind `Mutex`, `OnceLock`, or `serial_test`. The only mutation exception is an
end-to-end test using `assert_cmd`: `Command::env` and `Command::env_clear`
configure the isolated child process, not the test harness.

`make lint` builds documentation with Rustdoc warnings denied before running
Clippy and Whitaker. `make test` exports warning-denial flags to the selected
test runner and separately runs all-feature workspace doctests with Rustdoc
warnings denied. A warning from normal tests, the documentation build, or a
doctest therefore fails the command.

### Migrate an Existing Generated Project

1. Copy the current `[lints.clippy]`, `[lints.rust]`, and `[lints.rustdoc]`
policy into each package manifest, or opt every workspace member into
equivalent workspace lints.
2. Add the current `disallowed-methods` entries to the workspace-root
`clippy.toml`.
3. Update the generated Makefile so `make lint` passes mandatory
`RUSTDOCFLAGS` to `cargo doc` and `make test` passes them to workspace
doctests. Preserve mandatory flags when appending inherited flags.
4. Refactor direct `std::env` reads behind an injected `mockable::Env` and
replace in-process environment mutation with `mockable::MockEnv`. Keep
`DefaultEnv` at the production composition root.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
5. Add diagnostic messages to assertions and crate/module/public-item
documentation where the denied lints require it.
6. Run `make check-fmt`, `make lint`, `make typecheck`, and `make test`. Fix
every warning rather than suppressing or downgrading it.

## Makefile Targets

The generated `Makefile` exposes these public targets:

- `make all` runs formatting checks, linting, tests, and spelling checks.
- `make check-fmt` verifies Rust formatting.
- `make fmt` formats Rust and Markdown sources.
- `make lint` runs rustdoc, Clippy, and Whitaker with warnings denied.
- `make lint` builds documentation, then runs Clippy and Whitaker, with every
warning denied.
- `make typecheck` type-checks the workspace without building.
- `make test` runs `cargo nextest run` when cargo-nextest is installed and
falls back to `cargo test` otherwise. All projects also run doctests.
falls back to `cargo test` otherwise. It denies warnings in normal tests and
in the separate all-feature workspace doctest run.
- `make build` builds the debug target.
- `make release` builds the release target.
- `make coverage` writes `lcov.info` using `cargo llvm-cov` and `lld`.
Expand Down
52 changes: 37 additions & 15 deletions template/AGENTS.md.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@
conventions and practices in [developers' guide](docs/developers-guide.md).
- **Style:** All documentation must adhere to the
[documentation style guide](docs/documentation-style-guide.md).
- **Completed execplans:** Completed execplans are historical documents and
should reflect the state of the repository at time of implementation. Do not
retroactively update completed execplan documents to reflect subsequent
changes in API, structures, or behaviour.

## Change quality & committing

Expand Down Expand Up @@ -182,23 +186,29 @@ built lazily. See `docs/polonius.md` for the full contract.
- Clippy warnings MUST be disallowed.
- Fix any warnings emitted during tests in the code itself rather than
silencing them.
- Every `assert!`, `assert_eq!`, and `assert_ne!` invocation must include a
custom diagnostic message that states the failed expectation and, where
useful, the relevant values.
- Where a function is too long, extract meaningfully named helper functions
adhering to separation of concerns and CQRS.
- Where a function has too many parameters, group related parameters in
meaningfully named structs.
- Where a function is returning a large error, consider using `Arc` to reduce
the amount of data returned.
- Keep returned error types semantically focused and small. If a necessary
payload makes an error variant large, place that payload behind `Box`; use
`Arc` only when shared ownership or cheap cloning is required.
- Ensure that new features are validated with unit tests using `rstest` and
behavioural tests using `rstest-bdd` where applicable. Cover happy paths,
unhappy paths, and relevant edge cases.
- Add snapshot tests using `insta` where multivariant output format consistency
is relevant to the requirements. Snapshots must capture meaningful,
reviewer-useful contracts rather than generic dumps of broad objects. Keep
snapshots focused on stable output boundaries, pair them with semantic
assertions for business rules and schema contracts, and normalize
nondeterministic fields before snapshotting. Do not accept brittle snapshots
that churn after unrelated changes; update snapshots only after confirming a
snapshot failure identifies a real contract change.
- Add snapshot tests using `insta` where structured output, user-interface
output, diagnostics, or other multivariant output formats need meaningful,
stable assertions. Keep snapshots focused on stable output boundaries, pair
them with semantic assertions for business rules and schema contracts, and
normalize nondeterministic fields before snapshotting. Do not accept brittle
snapshots that churn after unrelated changes; update snapshots only after
confirming a snapshot failure identifies a real contract change.
- Add compile-time behaviour tests using `trybuild` where macro expansion,
trait bounds, feature-gated APIs, or other compile-fail and compile-pass
contracts are relevant to the requirements.
- Add end-to-end tests where a change affects externally observable workflows,
integration contracts, persistence, command-line behaviour, network
boundaries, UI flows, or other system-level behaviour.
Expand Down Expand Up @@ -263,11 +273,23 @@ built lazily. See `docs/polonius.md` for the full contract.
- Use `rstest` fixtures for shared setup.
- Replace duplicated tests with `#[rstest(...)]` parameterized cases.
- Prefer `mockall` for ad hoc mocks/stubs.
- For testing of functionality depending upon environment variables, dependency
injection and the `mockable` crate are the preferred option.
- If mockable cannot be used, env mutations in tests MUST be wrapped in shared
guards and mutexes placed in a shared `test_utils` or `test_helpers` crate.
Direct environment mutation is FORBIDDEN in tests.
- **Inject the environment instead.** Any behaviour depending upon environment
variables must accept an injected environment: `mockable::Env`, with
`mockable::DefaultEnv` supplied at the production composition root and
`mockable::MockEnv` in tests. A narrow closure seam of the same shape is
acceptable where a trait object would be disproportionate. Production code
must not reach for `std::env::var`/`var_os` outside that composition root.
- **Process-wide locks are not an escape hatch.** Serializing environment
mutation behind a shared `Mutex`, `OnceLock`, or `serial_test` attribute is
not an accepted fallback. It serializes the suite, defeats property-based
tests that run many cases within one process, and leaves the ambient
dependency embedded in the production signature. Where injection looks
impossible the signature is wrong; correct the signature.
- **Subprocess isolation is the sole exemption.** End-to-end behavioural tests
invoking the generated application binary through `assert_cmd` may configure
the environment via `Command::env`/`Command::env_clear`, because the mutation
is confined to the child process. A test manipulating the environment
in-process does not qualify, whatever layer it claims to occupy.

### Dependency management

Expand Down
11 changes: 11 additions & 0 deletions template/Cargo.toml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ allow_attributes = "deny"
allow_attributes_without_reason = "deny"
blanket_clippy_restriction_lints = "deny"
cognitive_complexity = "deny"
disallowed_methods = "deny"
needless_pass_by_value = "deny"
implicit_hasher = "deny"
missing_assert_message = "deny"

# 2. debugging leftovers
dbg_macro = "deny"
Expand Down Expand Up @@ -88,7 +90,16 @@ error_impl_error = "deny"
result_large_err = "deny"

[lints.rust]
unknown_lints = "deny"
renamed_and_removed_lints = "deny"
unsafe_code = "forbid"
missing_docs = "deny"

[lints.rustdoc]
missing_crate_level_docs = "deny"
broken_intra_doc_links = "deny"
private_intra_doc_links = "deny"
bare_urls = "deny"
invalid_html_tags = "deny"
invalid_codeblock_attributes = "deny"
unescaped_backticks = "deny"
9 changes: 5 additions & 4 deletions template/Makefile.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ DEV_LINKER_FLAGS ?= $(if $(filter Linux,$(shell uname -s)),-C link-arg=-fuse-ld=
DEV_LINKER_FLAGS ?=
{% endif -%}
DEV_RUST_FLAGS ?= $(RUST_FLAGS) $(POLONIUS_FLAGS) $(DEV_LINKER_FLAGS)
RUSTDOC_FLAGS ?= $(POLONIUS_FLAGS)
RUSTDOC_FLAGS := -D warnings $(RUSTDOC_FLAGS)
RUSTDOC_FLAGS ?=
RUSTDOC_FLAGS := --cfg docsrs -D warnings $(POLONIUS_FLAGS) $(RUSTDOC_FLAGS)
CARGO_FLAGS ?= --all-targets --all-features
CLIPPY_FLAGS ?= $(CARGO_FLAGS) -- $(RUST_FLAGS)
TEST_FLAGS ?= $(CARGO_FLAGS)
Expand All @@ -50,9 +50,10 @@ clean: ## Remove build artifacts
$(CARGO) clean
rm -f .typos-oxendict-base.json .typos-oxendict-base.toml

test: export RUSTFLAGS := $(DEV_RUST_FLAGS)
test: ## Run tests with warnings treated as errors
RUSTFLAGS="$(DEV_RUST_FLAGS)" $(CARGO) $(TEST_CMD) $(TEST_FLAGS) $(BUILD_JOBS)
RUSTDOCFLAGS="$(RUSTDOC_FLAGS)" RUSTFLAGS="$(DEV_RUST_FLAGS)" $(CARGO) test --doc --workspace --all-features
$(CARGO) $(TEST_CMD) $(TEST_FLAGS) $(BUILD_JOBS)
RUSTDOCFLAGS="$(RUSTDOC_FLAGS)" $(CARGO) test --doc --workspace --all-features

target/%/$(TARGET): ## Build binary in debug or release mode
RUSTFLAGS="$(DEV_RUST_FLAGS)" $(CARGO) build $(BUILD_JOBS) $(if $(findstring release,$(@)),--release){% if flavour == 'app' %} --bin $(TARGET){% endif %}
Expand Down
8 changes: 8 additions & 0 deletions template/clippy.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,11 @@ excessive-nesting-threshold = 4 # default is off

allow-expect-in-tests = true

disallowed-methods = [
{ path = "std::env::var", reason = "inject an environment reader" },
{ path = "std::env::var_os", reason = "inject an environment reader" },
{ path = "std::env::vars", reason = "inject an environment reader" },
{ path = "std::env::vars_os", reason = "inject an environment reader" },
{ path = "std::env::set_var", reason = "use a stub environment in tests" },
{ path = "std::env::remove_var", reason = "use a stub environment in tests" },
]
Loading
Loading