Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
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")]
131 changes: 131 additions & 0 deletions tests/locale_stub_strictness_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
//! Tests for `StubEnv`'s strictness about undeclared reads.
//!
//! Without these, relaxing or deleting the assertion would leave every other
//! test passing while restoring exactly the permissive behaviour the stub was
//! made strict to remove.

use netsuke::locale_resolution::LocaleEnvProvider;
use test_support::locale_stubs::StubEnv;

#[test]
#[should_panic(expected = "which the test did not declare")]
fn undeclared_read_panics() {
StubEnv::strict().var("SOME_UNDECLARED_VARIABLE");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

#[test]
#[should_panic(expected = "SOME_OTHER_VARIABLE")]
fn the_panic_names_the_offending_key() {
StubEnv::with_locale("es-ES").var("SOME_OTHER_VARIABLE");
}

#[test]
fn declared_reads_do_not_panic() {
assert_eq!(
StubEnv::with_locale("es-ES")
.var("NETSUKE_LOCALE")
.as_deref(),
Some("es-ES")
);
assert_eq!(StubEnv::without_locale().var("NETSUKE_LOCALE"), None);
}

/// The most recent declaration for a key wins, in either order.
///
/// Were `allowing` merely to append to the permitted list, the second case
/// would read as declaring the key unset while still answering `Some("set")`.
#[test]
fn the_last_declaration_for_a_key_wins() {
let value_then_unset = StubEnv::strict().with_var("X", "set").allowing("X");
assert_eq!(value_then_unset.var("X"), None, "allowing should clear");

let unset_then_value = StubEnv::strict().allowing("X").with_var("X", "set");
assert_eq!(
unset_then_value.var("X").as_deref(),
Some("set"),
"with_var should override"
);
}

/// Declaring a key twice must not make the stub answer differently.
#[test]
fn repeated_declaration_is_idempotent() {
let env = StubEnv::strict()
.with_var("X", "first")
.with_var("X", "second");
assert_eq!(env.var("X").as_deref(), Some("second"));
}

mod properties {
//! Property coverage for the builder's declaration semantics.
//!
//! The fixed cases above check single interleavings of `with_var` and
//! `allowing`; this states the invariant they are instances of — the last
//! declaration for a key wins — over arbitrary declaration sequences.

use super::{LocaleEnvProvider, StubEnv};
use proptest::collection::vec;
use proptest::prelude::*;
use std::collections::HashMap;
use std::panic::{AssertUnwindSafe, catch_unwind};

#[derive(Debug, Clone)]
enum Declaration {
Allow(String),
Set(String, String),
}

/// Three keys only, so generated sequences redeclare the same key often
/// enough for ordering to matter; a wide key space would almost never
/// produce the collisions the invariant is about.
fn declaration() -> impl Strategy<Value = Declaration> {
prop_oneof![
"[ABC]".prop_map(Declaration::Allow),
("[ABC]", "[a-z]{1,4}").prop_map(|(key, value)| Declaration::Set(key, value)),
]
}

proptest! {
/// Every key answers per its last declaration; undeclared keys panic.
///
/// The model is a plain last-write-wins map, independent of the
/// stub's split `values`/`allowed` representation, so a bookkeeping
/// slip between the two collections fails here rather than agreeing
/// with itself.
#[test]
fn the_last_declaration_wins_over_any_sequence(
declarations in vec(declaration(), 0..8)
) {
let mut model: HashMap<String, Option<String>> = HashMap::new();
let mut stub = StubEnv::strict();
for declaration in &declarations {
match declaration {
Declaration::Allow(key) => {
model.insert(key.clone(), None);
stub = stub.allowing(key.clone());
}
Declaration::Set(key, value) => {
model.insert(key.clone(), Some(value.clone()));
stub = stub.with_var(key.clone(), value.clone());
}
}
}
for key in ["A", "B", "C"] {
if let Some(expected) = model.get(key) {
prop_assert_eq!(stub.var(key), expected.clone());
} else {
// Silence the default panic hook around the probe: each
// undeclared read otherwise prints its full panic message,
// and 256 cases times three keys of that buries any
// genuine failure output. The hook is process-wide, so it
// is restored immediately rather than left installed.
let prior = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let read = catch_unwind(AssertUnwindSafe(|| stub.var(key)));
std::panic::set_hook(prior);

@coderabbitai coderabbitai Bot Aug 5, 2026

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- target file ---'
sed -n '1,180p' tests/locale_stub_strictness_tests.rs

printf '%s\n' '--- panic-hook usage ---'
rg -n --glob '*.rs' 'take_hook|set_hook|catch_unwind|parallel|serial' .

Repository: leynos/netsuke

Length of output: 21423


🏁 Script executed:

#!/bin/bash
set -eu
sed -n '1,180p' tests/locale_stub_strictness_tests.rs
rg -n --glob '*.rs' 'take_hook|set_hook|catch_unwind|parallel|serial' .

Repository: leynos/netsuke

Length of output: 21378


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- test execution configuration ---'
fd -HI -t f '(^|/)(nextest\.toml|Makefile|Cargo\.toml|.*\.toml)$' . | sort | head -80
rg -n -C 4 'nextest|cargo test|test-groups|threads|locale_stub_strictness' Makefile .config .github Cargo.toml 2>/dev/null || true

printf '%s\n' '--- repository guidance on panic hooks and test isolation ---'
sed -n '80,115p' test_support/src/localizer.rs
sed -n '150,180p' test_support/src/env_lock.rs
sed -n '1,110p' test_support/src/http_tests.rs

Repository: leynos/netsuke

Length of output: 14784


🏁 Script executed:

#!/bin/bash
set -eu
fd -HI -t f '(^|/)(nextest\.toml|Makefile|Cargo\.toml|.*\.toml)$' . | sort | head -80
rg -n -C 4 'nextest|cargo test|test-groups|threads|locale_stub_strictness' Makefile .config .github Cargo.toml 2>/dev/null || true
sed -n '80,115p' test_support/src/localizer.rs
sed -n '150,180p' test_support/src/env_lock.rs
sed -n '1,110p' test_support/src/http_tests.rs

Repository: leynos/netsuke

Length of output: 14685


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path
import re

target = Path("tests/locale_stub_strictness_tests.rs").read_text()
nextest = Path(".config/nextest.toml").read_text()

tests = re.findall(r'(?m)^\s*fn\s+([A-Za-z0-9_]+)\s*\(', target)
hooks = re.findall(r'(?m)^\s*let\s+(prior|read)\b.*|std::panic::(?:take_hook|set_hook)\b', target)

print("target_tests:", tests)
print("target_hook_operations:", len(re.findall(r'std::panic::(?:take_hook|set_hook)', target)))
print("target_uses_catch_unwind:", "catch_unwind" in target)
print("nextest_process_isolation_claim:", "nextest runs each test" in nextest and "own process" in nextest)
print("nextest_serial_group_binaries:", re.findall(r"binary\(([^)]+)\)", nextest))
print("locale_binary_in_serial_group:", "locale_stub_strictness_tests" in nextest)
PY

Repository: leynos/netsuke

Length of output: 623


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re

target = Path("tests/locale_stub_strictness_tests.rs").read_text()
nextest = Path(".config/nextest.toml").read_text()

print("target_tests:", re.findall(r'(?m)^\s*fn\s+([A-Za-z0-9_]+)\s*\(', target))
print("target_hook_operations:", len(re.findall(r'std::panic::(?:take_hook|set_hook)', target)))
print("target_uses_catch_unwind:", "catch_unwind" in target)
print("nextest_process_isolation_claim:", "nextest runs each test" in nextest and "own process" in nextest)
print("nextest_serial_group_binaries:", re.findall(r"binary\(([^)]+)\)", nextest))
print("locale_binary_in_serial_group:", "locale_stub_strictness_tests" in nextest)
PY

Repository: leynos/netsuke

Length of output: 623


Remove the process-wide panic-hook replacement.

std::panic::set_hook affects all threads in the test process. A parallel panic can lose its diagnostics, and restoring prior can overwrite a concurrent hook change. Keep the default hook or run the probe in an isolated process.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/locale_stub_strictness_tests.rs` around lines 122 - 125, Remove the
process-wide panic-hook replacement around the catch_unwind probe in the
stub.var(key) test; eliminate the take_hook/set_hook/restore sequence while
preserving the existing panic capture and result assertions. Keep the default
hook active, or isolate the probe in a separate process if suppressing panic
output is required.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 6fa22ce, with a different remedy than suggested: rather than keeping the default hook (which buries genuine failures under 256 cases × 3 keys of panic output) or forking a process per probe (disproportionate for a property test), the hook is now installed exactly once as a wrapper that delegates to the prior hook unless a thread-local flag marks the current thread as inside a probe. That removes both hazards you identified: other threads' panics always reach the original hook (nothing is process-wide silenced), and nothing is ever restored, so there is no take/set window to race a concurrent hook change.

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 --short HEAD

printf '%s\n' '--- target test structure ---'
ast-grep outline tests/locale_stub_strictness_tests.rs --items all --view expanded || true

printf '%s\n' '--- relevant target section ---'
rg -n -C 12 'thread_local|take_hook|set_hook|catch_unwind|panic_hook|Hook' tests/locale_stub_strictness_tests.rs

printf '%s\n' '--- repository hook and synchronization patterns ---'
rg -n -C 5 --glob '*.rs' 'thread_local!|OnceLock|Once::|take_hook|set_hook|catch_unwind' tests test_support src

Length of output: 17798


@leynos, keep this finding unresolved.

6fa22ce still mutates the process-wide panic hook in install_gated_hook().

Once prevents repeated installation in this module. It does not coordinate with other tests that call std::panic::set_hook. A concurrent test can replace the gated hook after installation. This test can also replace a hook that another test installed.

The thread-local gate prevents suppression of panics from other threads after the wrapper is active. It does not remove global hook ownership or hook-installation conflicts.

Remove the hook replacement and retain the default hook. The existing project guidance in test_support/src/localizer.rs makes the same trade-off: accept deliberate-panic output rather than mutate the process-wide hook.


🧠 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.

prop_assert!(read.is_err(), "undeclared {} should panic", key);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}
Loading
Loading