-
Notifications
You must be signed in to change notification settings - Fork 0
Give the workspace fallback switch an injectable env seam (#487) #498
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
leynos
merged 10 commits into
main
from
issue-487-inject-env-seam-into-workspace-fallback
Aug 7, 2026
Merged
Changes from 9 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
89a67fa
Capture the workspace switch as snapshot data the cache can hash
3e7841d
Resolve the Ninja program through the mockable::Env trait
afc4747
Record the environment seam taxonomy as ADR-008
f11f626
Gate the switch boundary test per-file and pin the combined-flag clean
7fb71db
Action the review round on the workspace-switch seam
aed068c
Document the workspace fallback switch in the users guide
3f37716
Build the Ninja env mock through an rstest fixture
e2ece13
Use the bare date format in the ADR-008 date section
a665284
Give the workspace switch a domain state at the adapter boundary
5041c7d
State the cache-key and seam-selection rules as the code has them
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,198 @@ | ||
| # Architecture decision record (ADR): Environment seam taxonomy | ||
|
|
||
| ## Status | ||
|
|
||
| Accepted. | ||
|
|
||
| ## Date | ||
|
|
||
| 2026-08-06 | ||
|
|
||
| ## Context and problem statement | ||
|
|
||
| `clippy.toml` disallows `std::env::var`, `var_os`, `set_var`, `remove_var`, | ||
| `vars`, and `vars_os` across the workspace, per the testing mandate in | ||
| `AGENTS.md`: behaviour that depends on an environment variable should accept | ||
| that value as an argument rather than read the process directly. Several | ||
| callers have satisfied that mandate with different shapes — a bare closure | ||
| parameter, a mockable trait object, a shared `Arc`-wrapped reader — chosen | ||
| independently as each site migrated off the disallowed calls under issues 484, | ||
| 488, and 493. Without a stated taxonomy, a future migration has no way to pick | ||
| the right shape for a new boundary, and a reviewer lacks a yardstick for | ||
| whether a proposed seam is over- or under-engineered for its call-site count. | ||
|
|
||
| Netsuke's design record (`docs/netsuke-design.md`) does not describe these | ||
| seams. This ADR fills that gap and gives `docs/developers-guide.md`'s | ||
| "Environment and template ports", "Environment lookup seams", and "Manifest | ||
| `env()` reader" sections a single decision record to point back to. | ||
|
|
||
| ## Decision | ||
|
|
||
| Adopt three seam shapes, selected by how many call sites a boundary has and | ||
| whether it is expected to grow: | ||
|
|
||
| - **Narrow closure seams**, for a single variable read by a single caller. | ||
| The module owns a private function that takes an | ||
| `FnOnce(&str) -> Result<String, env::VarError>` (or the equivalent | ||
| `OsString`-typed form) instead of calling `std::env::var` itself. Examples: | ||
| the `resolve_with` variants in `output_mode.rs` and `output_prefs.rs` | ||
| described earlier in the developer guide. A related but distinct pattern | ||
| injects a resolved *value* rather than a closure: the `stdlib::path` | ||
| home-directory resolver's `HomeDirectory` enum (`Ambient`/`Missing`/ | ||
| `Explicit`) lets a caller supply the home directory directly, so the | ||
| process-reading `home_from_env` ladder in `src/stdlib/path/path_utils.rs` | ||
| remains a directly annotated composition root rather than gaining its own | ||
| `_with` closure parameter. | ||
| - **The `mockable::Env` trait**, for a boundary mocked across many tests or | ||
| expected to grow further inputs. `resolve_ninja_program_utf8_with` in | ||
| `src/runner/process/ninja_program.rs` takes `&impl Env`; production supplies | ||
| `mockable::DefaultEnv`, and tests supply `mockable::MockEnv` for every | ||
| resolution branch without mutating the process (#488). | ||
| `stdlib::which::env::EnvSnapshot::capture_with_env` takes the same | ||
| `&impl Env` and reads `PATH`, `PATHEXT`, and `NETSUKE_WHICH_WORKSPACE` | ||
| through it, so one provider covers every ambient input the resolver has | ||
| (#487). | ||
| - **`EnvReader` `Arc` closures**, for a boundary whose registration point | ||
| requires `Send + Sync`. The manifest `env()` Jinja helper | ||
| (`src/manifest/env_reader.rs`) reads through an injected `EnvReader`, a | ||
| shared `Fn(&str) -> Result<String, EnvReadError>` (a manifest-owned error | ||
| type distinguishing an absent variable from a non-UTF-8 one, so the helper | ||
| does not expose the process adapter's `VarError`); `minijinja` requires | ||
| registered functions to be `Send + Sync`, so the reader is captured as an | ||
| `Arc` by the registered closure rather than borrowed (#484). | ||
|
|
||
| None of these shapes is a general-purpose environment service. Each is owned by | ||
| the module that reads its variable, stays private to it, and covers one | ||
| variable or one precedence ladder; see "Environment and template ports" in | ||
| `docs/developers-guide.md` for the composition rules that apply to all three, | ||
| and "Ownership and permitted call sites" under that guide's "Manifest `env()` | ||
| reader" section for the `EnvReader` shape specifically. | ||
|
|
||
| ### `EnvSnapshot` ownership | ||
|
|
||
| `stdlib::which::env::EnvSnapshot::capture` is the resolver's single ambient | ||
| boundary: it captures `PATH`, `PATHEXT`, and the `NETSUKE_WHICH_WORKSPACE` | ||
| switch as *data*, in one place, rather than letting each downstream decision | ||
| read the process independently. Absence and malformed-UTF-8 outcomes are | ||
| stored, not resolved, at capture time. | ||
|
|
||
| Capture is also the only place the platform's `std::env::VarError` is spoken. | ||
| It translates the reading into the `WorkspaceSwitch` domain state (`Value`, | ||
| `Absent`, `NotUnicode`) and emits the non-UTF-8 warning there, so the policy | ||
| behind the boundary carries neither the platform error type nor a logging | ||
| dependency. `workspace_switch.rs` holds only the variable name and that state, | ||
| making it a leaf module: it is used by `env` and by `lookup::workspace`, and it | ||
| calls back into neither, so there is no environment-to-lookup cycle. | ||
|
|
||
| The which-resolver cache fingerprint (`stdlib::which::cache::env_fingerprint`) | ||
| hashes every input the snapshot captured — `raw_path`, `raw_pathext`, and the | ||
| `WorkspaceSwitch` state, which derives `Hash` precisely so it can be hashed | ||
| directly — so two resolutions that differ only in one captured environment | ||
| input cannot share a cache entry. | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| ### Explicit child-environment composition | ||
|
|
||
| Tests that need a controlled child-process environment configure the child | ||
| explicitly; nothing sanctioned mutates the parent test process's environment to | ||
| influence a spawned `netsuke` binary. `test_support::netsuke`'s | ||
| `run_netsuke_in_with_env` calls `env_clear()` on the constructed | ||
| `assert_cmd::Command`, forwards the host `PATH`, and then applies the caller's | ||
| `extra_env` pairs through `Command::env`. The BDD helper | ||
| `build_netsuke_command` follows the same pattern: it clears the inherited | ||
| environment and forwards only `PATH` and the scenario's tracked | ||
| `env_vars_forward` map, one `cmd.env(key, value)` call per entry. Since #493, | ||
| nothing reads `NETSUKE_NINJA` (or any other override) from the parent process | ||
| to populate a child; the value always travels explicitly through `extra_env` or | ||
| `env_vars_forward`. | ||
|
|
||
| Subprocess isolation constructed this way — explicit `Command::env` calls | ||
| against a cleared child environment — is the only sanctioned route for getting | ||
| an ambient-looking variable such as `NETSUKE_NINJA` or `PATH` to a spawned | ||
| process under test. Mutating the test process's own environment to achieve the | ||
| same effect is not an accepted alternative to any of the three seam shapes | ||
| above. | ||
|
|
||
| Composition does not stop at the child-process boundary: in-process callers | ||
| that need a deterministic Ninja executable use `runner::run_with_ninja_program` | ||
| to supply the already-resolved program path directly, bypassing `NETSUKE_NINJA` | ||
| resolution entirely rather than setting the variable for a child to read. | ||
|
|
||
| ## Rationale | ||
|
|
||
| - **Proportionate abstraction.** A trait object for a single-variable, | ||
| single-caller boundary would recreate the ambient coupling the seam exists to | ||
| remove, just one layer down; a bare closure is cheaper to read and to test. | ||
| `mockable::Env` earns its weight only when a boundary is exercised by many | ||
| tests or is expected to grow (#488). | ||
| - **`Send + Sync` is a real constraint, not a preference.** `EnvReader`'s | ||
| `Arc` wrapping is not a stylistic choice; `minijinja`'s function-registration | ||
| API requires it, and a plain closure parameter cannot satisfy that bound when | ||
| the closure must be captured by a registered, potentially cloned function | ||
| (#484). | ||
| - **Data over decisions at the boundary.** Capturing `EnvSnapshot` once and | ||
| deriving decisions downstream keeps the resolver testable without process | ||
| mutation, and keeps `workspace_switch` a leaf module rather than a second | ||
| place that reads the process. | ||
| - **Cache correctness follows from capture completeness.** Hashing every | ||
| captured input, including the workspace switch, is what prevents a resolution | ||
| made with the fallback enabled from answering a lookup made with it disabled. | ||
| - **No back door around subprocess isolation.** Explicit `Command::env` | ||
| composition is auditable per test and cannot race with parallel test | ||
| execution the way a shared-process mutation could. | ||
|
|
||
| ## Consequences | ||
|
|
||
| - A contributor introducing a new environment-dependent boundary chooses | ||
| among the three seam shapes by call-site count and `Send + Sync` | ||
| requirements, rather than inventing a fourth shape or reaching for the | ||
| heaviest option by default. | ||
| - `docs/developers-guide.md`'s "Environment and template ports", "Environment | ||
| lookup seams", and "Manifest `env()` reader" sections, and this ADR must stay | ||
| consistent; widen one only alongside the others when a boundary's shape | ||
| changes. | ||
| - Reviewers can reject a new `mockable::Env`-shaped boundary for a | ||
| single-variable, single-caller site, and a new closure-shaped boundary for a | ||
| site that clearly needs `Send + Sync` registration or broad mocking. | ||
| - Any future ambient input added to the which resolver's boundary (a new | ||
| environment variable, for example) must be folded into `EnvSnapshot` and into | ||
| `env_fingerprint`, not read independently downstream, to preserve the | ||
| no-cycle and cache-correctness properties this ADR records. | ||
|
|
||
| ## Alternatives considered | ||
|
|
||
| - **A single shared `Env` trait for every boundary.** Rejected: forcing | ||
| `mockable::Env` (or an equivalent trait object) on single-variable, | ||
| single-caller sites such as `output_mode.rs`'s `resolve_with` would add | ||
| indirection with no matching test-surface benefit, and would blur the "one | ||
| variable or one precedence ladder" ownership rule this ADR reaffirms. | ||
| - **Reading the parent process's environment for child-process tests.** | ||
| Rejected: mutating the test process to influence a spawned `netsuke` binary | ||
| reintroduces the shared-mutable-state races that injected readers and | ||
| child-process configuration exist to avoid, and it is exactly the pattern | ||
| #493 removed from the BDD and integration test helpers. | ||
| `.config/nextest.toml` runs no serialized environment group precisely because | ||
| no sanctioned test still mutates the harness environment; `EnvLock` and | ||
| `CwdGuard` remain only for the few tests that exercise process | ||
| working-directory behaviour. | ||
|
|
||
| ## Implementation references | ||
|
|
||
| - Workspace switch state: | ||
| [`src/stdlib/which/workspace_switch.rs`](../src/stdlib/which/workspace_switch.rs) | ||
| - `EnvSnapshot`: [`src/stdlib/which/env.rs`](../src/stdlib/which/env.rs) | ||
| - Cache fingerprint: [`src/stdlib/which/cache.rs`](../src/stdlib/which/cache.rs) | ||
| - `mockable::Env` seam: | ||
| [`src/runner/process/ninja_program.rs`](../src/runner/process/ninja_program.rs); | ||
| `runner::run_with_ninja_program` in | ||
| [`src/runner/mod.rs`](../src/runner/mod.rs) is the companion injected seam | ||
| that lets callers select the resolved Ninja executable directly, without | ||
| going through `NETSUKE_NINJA` resolution at all | ||
| - `EnvReader`: [`src/manifest/env_reader.rs`](../src/manifest/env_reader.rs) | ||
| (manifest `env()` Jinja helper) | ||
| - Child-environment composition: | ||
| [`test_support/src/netsuke.rs`](../test_support/src/netsuke.rs) | ||
| (`run_netsuke_in_with_env`) and `tests/bdd/steps/manifest_command_helpers.rs` | ||
| (`build_netsuke_command`) | ||
| - Policy narrative: "Environment and template ports" and "Injected and | ||
| child-process environments" in | ||
| [`docs/developers-guide.md`](developers-guide.md) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.