Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1691,6 +1691,45 @@ lock is still held; `try_lock` from the owning thread returns `WouldBlock`, so
"blocked" means the bundle still holds it. Reverting the field order turns that
assertion red deterministically.

### `StubEnv` strictness

`test_support::locale_stubs::StubEnv` is the environment-variable test double
used by locale-resolution tests. It answers only the keys a test declares, and
**panics**, naming the key, on any other read. The permissive alternative —
returning `None` for anything unrecognized — hides exactly the regression a
test double should catch: if the code under test starts reading a differently
named variable, through a rename, a typo, or a new precedence rung, a
permissive stub answers `None` and the test still passes, asserting nothing
about the new read. Recognize the panic message, `"which the test did not
declare"`, when a test starts failing after a rename; it means the test's
declarations need updating, not that the stub is broken.

Three distinct states are representable for a key: **declared with a value**
(`with_var`), **declared but unset** (`allowing`, which reports `None`), and
**undeclared** (any other key, which panics). The middle case matters because
an unset variable is a legitimate scenario to exercise, and it must be
distinguishable from a variable the test never expected to be read at all.
`StubEnv::with_locale` and `StubEnv::without_locale` are the common
constructors for `NETSUKE_LOCALE`; `strict()` starts from nothing declared.

Declaring the same key twice is well-defined: the most recent declaration
wins, in either order. `allowing` after `with_var` clears the value; `with_var`
after `allowing` restores one. Were `allowing` merely to append to the
permitted-keys list rather than clearing the stored value, it would read as
declaring the key unset while still answering with the earlier value.

`Default` is deliberately **not** implemented for `StubEnv`. On a strict stub,
"default" would have to mean "deny every read", so `StubEnv::default()` would
compile and then panic at run time for the common "no locale set" case;
requiring `StubEnv::without_locale()` instead makes that intent explicit at
compile time. This refusal is itself a tested contract:
`tests/locale_stub_ui_tests.rs` compiles a fixture calling
`StubEnv::default()` directly with `rustc` and asserts the compile fails with
`E0599` naming the missing `default` item, guarding against the constraint
regressing to a doc-comment promise. `tests/locale_stub_strictness_tests.rs`
covers the panic, the trichotomy, and the last-declaration-wins rule with
both example-based and property tests.

### Manifest `env()` reader

The `env()` Jinja helper reads through an injected [`EnvReader`], a shared
Expand Down
142 changes: 132 additions & 10 deletions test_support/src/locale_stubs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,151 @@
//! deterministic environment and system locales.

use netsuke::locale_resolution::{self, LocaleEnvProvider, SystemLocale};
use std::collections::HashMap;

/// Stub environment provider for locale resolution.
#[derive(Debug, Default, Clone)]
///
/// Answers only the variables it was given, and **panics** on any other key.
///
/// The permissive alternative — returning `None` for anything unrecognized —
/// hides exactly the change a test double should catch. Were the code under
/// test altered to read a differently-named variable, through a rename, a typo,
/// or a new precedence rung, a permissive stub would quietly answer `None` and
/// the test would still pass while asserting nothing about the new read. The
/// panic converts that silent pass into a failure naming the unexpected key.
///
/// `Default` is deliberately **not** implemented. On a strict stub it would
/// mean "deny every read", so `StubEnv::default()` would compile and then
/// panic at run time for the common "no locale set" case. Requiring
/// [`StubEnv::without_locale`] makes that intent explicit at compile time.
#[derive(Debug, Clone)]
pub struct StubEnv {
/// Optional locale value to return for `NETSUKE_LOCALE`.
pub locale: Option<String>,
values: HashMap<String, String>,
allowed: Vec<String>,
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

impl StubEnv {
/// Create a stub environment with the provided locale.
pub fn with_locale(locale: impl Into<String>) -> Self {
/// Create a stub declaring nothing; every read panics until one is added.
///
/// # Examples
///
/// ```rust,should_panic
/// use netsuke::locale_resolution::LocaleEnvProvider;
/// use test_support::locale_stubs::StubEnv;
///
/// // Nothing is declared, so any read is a programming error.
/// StubEnv::strict().var("ANYTHING");
/// ```
#[must_use]
pub fn strict() -> Self {
Comment on lines +31 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add examples for the new StubEnv builders

The new public strict, without_locale, with_var, and allowing methods have only prose descriptions, leaving their composition and outcomes undocumented despite the repository requirement that function documentation include clear usage and outcome examples. Add Rustdoc examples for the newly introduced builders.

AGENTS.md reference: AGENTS.md:L27-L30

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai Has this now been resolved in the latest commit?

Use codegraph analysis to determine your answer.

If this comment is now resolved, please reply confirming this. Otherwise, please provide a follow-up recommendation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline test_support/src/locale_stubs.rs --items all --type function,struct,impl --match 'StubEnv|EnvProvider' --view expanded
printf '\n--- StubEnv implementation and EnvProvider contract ---\n'
sed -n '20,155p' test_support/src/locale_stubs.rs

Length of output: 662


🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline test_support/src/locale_stubs.rs --items all --type struct,function --match 'StubEnv|strict|without_locale|with_var|allowing|var' --view expanded
printf '\n--- StubEnv implementation and EnvProvider contract ---\n'
sed -n '20,155p' test_support/src/locale_stubs.rs

Length of output: 5549


@pandalump, this is resolved.

The current test_support/src/locale_stubs.rs adds Rustdoc examples for all requested builders:

  • StubEnv::strict() shows that an undeclared read panics.
  • StubEnv::without_locale() shows an allowed unset locale returns None.
  • StubEnv::with_var() shows that it overrides a prior allowing() call.
  • StubEnv::allowing() shows that it clears a prior value and returns None.

You are interacting with an AI system.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai Has this now been resolved in the latest commit?

Use codegraph analysis to determine your answer.

If this comment is now resolved, please reply confirming this. Otherwise, please provide a follow-up recommendation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- codegraph: StubEnv public builder map ---'
ast-grep outline test_support/src/locale_stubs.rs \
  --items all \
  --type struct,function,impl \
  --match 'StubEnv|strict|with_locale|without_locale|with_var|allowing' \
  --view expanded

printf '%s\n' '--- source: Rustdoc and implementations for all requested builders ---'
sed -n '24,140p' test_support/src/locale_stubs.rs

Length of output: 845


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- codegraph: StubEnv public builder map ---'
ast-grep outline test_support/src/locale_stubs.rs \
  --items all \
  --type struct,function \
  --match 'StubEnv|strict|with_locale|without_locale|with_var|allowing' \
  --view expanded

printf '%s\n' '--- source: Rustdoc and implementations for all requested builders ---'
sed -n '24,140p' test_support/src/locale_stubs.rs

Length of output: 4946


@pandalump, this is resolved.

Codegraph analysis confirms that test_support/src/locale_stubs.rs contains Rustdoc examples for every requested builder:

  • StubEnv::strict() shows an undeclared read that panics.
  • StubEnv::without_locale() shows an allowed unset locale that returns None.
  • StubEnv::with_var() shows that it overrides allowing() for the same key.
  • StubEnv::allowing() shows that it clears a previously configured value.

You are interacting with an AI system.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai Has this now been resolved in the latest commit?

Use codegraph analysis to determine your answer.

If this comment is now resolved, please reply confirming this. Otherwise, please provide a follow-up recommendation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Codegraph: StubEnv public builder methods ---'
ast-grep outline test_support/src/locale_stubs.rs \
  --items all \
  --type struct,impl,function \
  --match 'StubEnv|strict|without_locale|with_var|allowing' \
  --view expanded

printf '%s\n' '--- Rustdoc and implementations ---'
sed -n '24,140p' test_support/src/locale_stubs.rs

Length of output: 806


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Codegraph: StubEnv declarations ---'
ast-grep outline test_support/src/locale_stubs.rs \
  --items all \
  --type struct,function \
  --match 'StubEnv|strict|without_locale|with_var|allowing' \
  --view expanded

printf '%s\n' '--- Rustdoc and builder implementations ---'
sed -n '24,140p' test_support/src/locale_stubs.rs

Length of output: 4794


@pandalump, this is resolved.

Codegraph analysis confirms that test_support/src/locale_stubs.rs defines all four requested builders and gives each one a Rustdoc example:

  • StubEnv::strict() shows an undeclared read that panics.
  • StubEnv::without_locale() shows an allowed unset locale that returns None.
  • StubEnv::with_var() shows that the later value overrides allowing().
  • StubEnv::allowing() shows that it clears a previous value and returns None.

You are interacting with an AI system.

Self {
locale: Some(locale.into()),
values: HashMap::new(),
allowed: Vec::new(),
}
}

/// Create a stub answering `NETSUKE_LOCALE` with `locale`.
///
/// # Examples
///
/// ```rust
/// use netsuke::locale_resolution::LocaleEnvProvider;
/// use test_support::locale_stubs::StubEnv;
///
/// let env = StubEnv::with_locale("es-ES");
/// assert_eq!(env.var("NETSUKE_LOCALE").as_deref(), Some("es-ES"));
/// ```
#[must_use]
pub fn with_locale(locale: impl Into<String>) -> Self {
Self::strict().with_var(locale_resolution::NETSUKE_LOCALE_ENV, locale)
}

/// Create a stub in which `NETSUKE_LOCALE` is unset but may be read.
///
/// Distinct from a stub that never expected the read at all: an unset
/// variable is a legitimate case to exercise.
///
/// # Examples
///
/// ```rust
/// use netsuke::locale_resolution::LocaleEnvProvider;
/// use test_support::locale_stubs::StubEnv;
///
/// // Declared, so the read is permitted; unset, so it reports `None`.
/// assert_eq!(StubEnv::without_locale().var("NETSUKE_LOCALE"), None);
/// ```
#[must_use]
pub fn without_locale() -> Self {
Self::strict().allowing(locale_resolution::NETSUKE_LOCALE_ENV)
}

/// Answer `key` with `value`.
///
/// The most recent declaration for a key wins, so this overrides an earlier
/// [`StubEnv::allowing`] for the same key.
///
/// # Examples
///
/// ```rust
/// use netsuke::locale_resolution::LocaleEnvProvider;
/// use test_support::locale_stubs::StubEnv;
///
/// let env = StubEnv::strict().allowing("X").with_var("X", "set");
/// assert_eq!(env.var("X").as_deref(), Some("set"));
/// ```
#[must_use]
pub fn with_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
let name = key.into();
if !self.allowed.iter().any(|allowed| allowed == &name) {
self.allowed.push(name.clone());
}
self.values.insert(name, value.into());
self
}

/// Permit `key` to be read, reporting it as unset.
///
/// Needed because an unset variable is a legitimate case to test, and must
/// be distinguishable from a variable the test never expected to be read.
///
/// The most recent declaration for a key wins, so this clears a value set
/// by an earlier [`StubEnv::with_var`]. Were it merely to append to the
/// permitted list, the builder would read as declaring the key unset while
/// still answering with the old value.
///
/// # Examples
///
/// ```rust
/// use netsuke::locale_resolution::LocaleEnvProvider;
/// use test_support::locale_stubs::StubEnv;
///
/// let env = StubEnv::strict().with_var("X", "set").allowing("X");
/// assert_eq!(env.var("X"), None);
/// ```
#[must_use]
pub fn allowing(mut self, key: impl Into<String>) -> Self {
let name = key.into();
self.values.remove(&name);
if !self.allowed.iter().any(|allowed| allowed == &name) {
self.allowed.push(name);
}
self
}
}

impl LocaleEnvProvider for StubEnv {
fn var(&self, key: &str) -> Option<String> {
if key == locale_resolution::NETSUKE_LOCALE_ENV {
return self.locale.clone();
}
None
assert!(
self.allowed.iter().any(|allowed| allowed == key),
concat!(
"StubEnv was asked for {:?}, which the test did not declare. ",
"Declare it with `.with_var(..)` or `.allowing(..)` if the read ",
"is intended; otherwise the code under test is reading a ",
"variable the test does not know about."
),
key
);
Comment on lines +141 to +150

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add a regression test for undeclared reads

No test introduced by this change calls var with an undeclared key and verifies that it panics; the existing locale-resolution tests exercise only declared reads. Consequently, relaxing or accidentally removing this assertion would leave every test passing while restoring the exact permissive behaviour this commit is intended to fix. Add a focused unit test for the unexpected-read path.

AGENTS.md reference: AGENTS.md:L64-L70

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai Has this now been resolved in the latest commit?

Use codegraph analysis to determine your answer.

If this comment is now resolved, please reply confirming this. Otherwise, please provide a follow-up recommendation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Current commit =='
git rev-parse HEAD

printf '%s\n' '== Rust source map for StubEnv module =='
ast-grep outline test_support/src/locale_stubs.rs --items all

printf '%s\n' '== Test-support source map =='
ast-grep outline test_support --items all --type function

printf '%s\n' '== StubEnv construction and undeclared-read test candidates =='
rg -n --glob '*.rs' -C 4 \
  '(StubEnv::(strict|with_locale|without_locale)|\.var\(".*"\)|should_panic|assert_panics|did not declare|unexpected.*read)' \
  test_support tests

printf '%s\n' '== Attribute-to-function codegraph candidates =='
ast-grep run --lang rust --pattern '#[should_panic]
fn $NAME($$$) { $$$ }' test_support tests || true

Length of output: 17935


@pandalump, treat this as resolved.

The latest commit, 69364c6cb4f31b23de42505c393ac41d1577d710, adds tests/locale_stub_strictness_tests.rs.

undeclared_read_panics calls StubEnv::strict().var("SOME_UNDECLARED_VARIABLE") and requires the undeclared-read panic. the_panic_names_the_offending_key also verifies that the panic includes the unexpected variable name. This covers the regression path from the original finding.

You are interacting with an AI system.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai Has this now been resolved in the latest commit?

Use codegraph analysis to determine your answer.

If this comment is now resolved, please reply confirming this. Otherwise, please provide a follow-up recommendation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Current revision =='
git rev-parse HEAD

printf '%s\n' '== Relevant Rust symbol map =='
ast-grep outline test_support/src/locale_stubs.rs --items all
ast-grep outline tests/locale_stub_strictness_tests.rs --items all

printf '%s\n' '== Undeclared-read test functions =='
ast-grep run --lang rust \
  --pattern '#[should_panic]
fn $NAME($$$) {
  $$$
}' \
  tests/locale_stub_strictness_tests.rs || true

printf '%s\n' '== Focused codegraph edges =='
rg -n -C 5 --glob '*.rs' \
  'undeclared_read_panics|the_panic_names_the_offending_key|StubEnv::strict\(\)\.var\(|fn var\(&self, key: &str\)|did not declare' \
  test_support/src/locale_stubs.rs tests/locale_stub_strictness_tests.rs

Length of output: 5274


@pandalump, mark this finding as resolved.

Codegraph analysis of revision 467df257caef2b0a7165910c7f9522c42def3006 confirms the required regression coverage.

tests/locale_stub_strictness_tests.rs contains undeclared_read_panics. The test calls StubEnv::strict().var("SOME_UNDECLARED_VARIABLE") and requires a panic containing "which the test did not declare".

The call reaches StubEnv’s EnvProvider::var implementation in test_support/src/locale_stubs.rs. That implementation asserts that the key was declared before it returns a value.

the_panic_names_the_offending_key also verifies that the panic contains the undeclared key. This covers both the unexpected-read path and its diagnostic.


🧠 Learnings used
Learnt from: leynos
Repo: leynos/netsuke PR: 502
File: tests/locale_stub_strictness_tests.rs:13-13
Timestamp: 2026-08-04T13:12:33.692Z
Learning: In leynos/netsuke Rust code, do not require wrapping a bare `Option<T>` expression statement in `drop(...)`; `Option<T>` is not `#[must_use]` on the project toolchain, while `Result<T, E>` is. Expression statements still evaluate their calls, including calls in `#[should_panic]` tests. Use `drop(...)` only when the discarded value's type is actually `#[must_use]` and the value is intentionally ignored.

You are interacting with an AI system.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai Has this now been resolved in the latest commit?

Use codegraph analysis to determine your answer.

If this comment is now resolved, please reply confirming this. Otherwise, please provide a follow-up recommendation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Current revision =='
git rev-parse HEAD

printf '%s\n' '== Relevant symbol maps =='
ast-grep outline test_support/src/locale_stubs.rs --items all
ast-grep outline tests/locale_stub_strictness_tests.rs --items all

printf '%s\n' '== Undeclared-read test and implementation edges =='
rg -n -C 6 --glob '*.rs' \
  'fn undeclared_read_panics\b|fn the_panic_names_the_offending_key\b|StubEnv::strict\(\)\.var\(|fn var\(&self, key: &str\)|which the test did not declare|self\.allowed\.iter\(\)\.any' \
  test_support/src/locale_stubs.rs tests/locale_stub_strictness_tests.rs

printf '%s\n' '== Structural test candidates =='
ast-grep run --lang rust \
  --pattern '#[should_panic(expected = $MESSAGE)]
fn $NAME($$$) {
  $$$
}' \
  tests/locale_stub_strictness_tests.rs || true

Length of output: 7382


@pandalump, mark this finding as resolved.

Codegraph analysis of revision efbc241c812ac8ef56c81c09aae68a1a182c770e confirms the regression coverage.

undeclared_read_panics calls StubEnv::strict().var("SOME_UNDECLARED_VARIABLE"). It requires a panic containing "which the test did not declare".

That call reaches LocaleEnvProvider for StubEnv::var. The implementation asserts that the key is declared before it returns a value.

the_panic_names_the_offending_key also verifies that the panic contains the undeclared key.


🧠 Learnings used
Learnt from: leynos
Repo: leynos/netsuke PR: 502
File: tests/locale_stub_strictness_tests.rs:13-13
Timestamp: 2026-08-04T13:12:33.692Z
Learning: In leynos/netsuke Rust code, do not require wrapping a bare `Option<T>` expression statement in `drop(...)`; `Option<T>` is not `#[must_use]` on the project toolchain, while `Result<T, E>` is. Expression statements still evaluate their calls, including calls in `#[should_panic]` tests. Use `drop(...)` only when the discarded value's type is actually `#[must_use]` and the value is intentionally ignored.

You are interacting with an AI system.

self.values.get(key).cloned()
}
}

Expand Down
7 changes: 4 additions & 3 deletions tests/bdd/steps/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ use test_support::locale_stubs::{StubEnv, StubSystemLocale};
/// Tests that do not explicitly set up configuration or environment variables
/// may be affected by ambient host configuration.
pub(super) fn apply_cli(world: &TestWorld, args: &CliArgs) {
let env = StubEnv {
locale: world.locale_env.get(),
};
let env = world
.locale_env
.get()
.map_or_else(StubEnv::without_locale, StubEnv::with_locale);
let system = StubSystemLocale {
locale: world.locale_system.get(),
};
Expand Down
7 changes: 4 additions & 3 deletions tests/bdd/steps/locale_resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,10 @@ fn set_cli_override(world: &TestWorld, locale: &str) {

#[when("the startup locale is resolved for {args:string}")]
fn resolve_startup_locale(world: &TestWorld, args: &str) {
let env = StubEnv {
locale: world.locale_env.get(),
};
let env = world
.locale_env
.get()
.map_or_else(StubEnv::without_locale, StubEnv::with_locale);
let system = StubSystemLocale {
locale: world.locale_system.get(),
};
Expand Down
2 changes: 1 addition & 1 deletion tests/locale_resolution_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ fn resolve_startup_locale_uses_env_then_system() -> Result<()> {
"expected env locale to win, got {resolved:?}"
);

let env_fallback = StubEnv::default();
let env_fallback = StubEnv::without_locale();
let resolved_fallback = resolve_startup_locale(&args, &env_fallback, &system);
ensure!(
resolved_fallback.as_deref() == Some("es-ES"),
Expand Down
9 changes: 9 additions & 0 deletions tests/locale_stub_strictness_tests.proptest-regressions
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
# Recorded while mutation-testing the property (allowing's value clear was
# disabled to prove the test detects it), not from a defect in StubEnv.
cc 04881a9256003f8215b73836a16209c1713861db70e59925b4d1ca3ea1cebec3 # shrinks to declarations = [Set("A", "a"), Allow("A")]
Loading
Loading