From da4e764615da2b6e15f86d0fcd667d5378386dcf Mon Sep 17 00:00:00 2001 From: Leynos Date: Wed, 15 Apr 2026 14:12:42 +0000 Subject: [PATCH 01/16] test(no_std_fs_operations): replace static fixture projects with dynamic temp dirs for integration tests - Removed static fixture crates under tests/fixtures in favor of ephemeral projects created in temporary directories - Added FixtureProject utility to create per-test temporary crates with configurable exclusion - Updated integration_exclusion.rs tests to use dynamic fixture projects instead of static - Added tempfile dependency for temporary directory management - Improves test isolation and flexibility for exclusion behavior tests Co-authored-by: devboxerhub[bot] --- Cargo.lock | 1 + crates/no_std_fs_operations/Cargo.toml | 1 + .../fixtures/excluded_project/Cargo.toml | 8 - .../fixtures/excluded_project/dylint.toml | 2 - .../fixtures/excluded_project/src/lib.rs | 26 --- .../fixtures/non_excluded_project/Cargo.toml | 8 - .../fixtures/non_excluded_project/dylint.toml | 2 - .../fixtures/non_excluded_project/src/lib.rs | 26 --- .../tests/integration_exclusion.rs | 179 ++++++++++++++---- 9 files changed, 142 insertions(+), 111 deletions(-) delete mode 100644 crates/no_std_fs_operations/tests/fixtures/excluded_project/Cargo.toml delete mode 100644 crates/no_std_fs_operations/tests/fixtures/excluded_project/dylint.toml delete mode 100644 crates/no_std_fs_operations/tests/fixtures/excluded_project/src/lib.rs delete mode 100644 crates/no_std_fs_operations/tests/fixtures/non_excluded_project/Cargo.toml delete mode 100644 crates/no_std_fs_operations/tests/fixtures/non_excluded_project/dylint.toml delete mode 100644 crates/no_std_fs_operations/tests/fixtures/non_excluded_project/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 4058e87a..7ffb416c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1747,6 +1747,7 @@ dependencies = [ "rustc_span", "serde", "serial_test", + "tempfile", "toml 0.9.12+spec-1.1.0", "whitaker", "whitaker-common", diff --git a/crates/no_std_fs_operations/Cargo.toml b/crates/no_std_fs_operations/Cargo.toml index dee202ac..201bee15 100644 --- a/crates/no_std_fs_operations/Cargo.toml +++ b/crates/no_std_fs_operations/Cargo.toml @@ -42,4 +42,5 @@ rstest-bdd = { workspace = true } rstest-bdd-macros = { workspace = true } dylint_testing = { workspace = true } serial_test = "3.1.0" +tempfile = { workspace = true } toml = { workspace = true } diff --git a/crates/no_std_fs_operations/tests/fixtures/excluded_project/Cargo.toml b/crates/no_std_fs_operations/tests/fixtures/excluded_project/Cargo.toml deleted file mode 100644 index 5a525eb4..00000000 --- a/crates/no_std_fs_operations/tests/fixtures/excluded_project/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "excluded_test_crate" -version = "0.1.0" -edition = "2024" - -[dependencies] - -[workspace] diff --git a/crates/no_std_fs_operations/tests/fixtures/excluded_project/dylint.toml b/crates/no_std_fs_operations/tests/fixtures/excluded_project/dylint.toml deleted file mode 100644 index 183f2a1e..00000000 --- a/crates/no_std_fs_operations/tests/fixtures/excluded_project/dylint.toml +++ /dev/null @@ -1,2 +0,0 @@ -[no_std_fs_operations] -excluded_crates = ["excluded_test_crate"] diff --git a/crates/no_std_fs_operations/tests/fixtures/excluded_project/src/lib.rs b/crates/no_std_fs_operations/tests/fixtures/excluded_project/src/lib.rs deleted file mode 100644 index 3c06cc20..00000000 --- a/crates/no_std_fs_operations/tests/fixtures/excluded_project/src/lib.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! Test fixture for excluded crate behaviour. -//! -//! This crate is intentionally a standalone fixture; the duplication with -//! `non_excluded_project` is necessary since each must be an independent crate -//! for exclusion testing. - -use std::fs::File; -use std::path::Path; - -/// Opens a file for reading. -/// -/// # Examples -/// -/// ```no_run -/// use excluded_project::open_file; -/// -/// // Open existing file - returns Ok with file handle -/// let file = open_file("Cargo.toml").expect("file should exist"); -/// -/// // Attempt to open non-existent file - returns Err -/// let result = open_file("nonexistent.txt"); -/// assert!(result.is_err()); -/// ``` -pub fn open_file>(path: P) -> std::io::Result { - File::open(path) -} diff --git a/crates/no_std_fs_operations/tests/fixtures/non_excluded_project/Cargo.toml b/crates/no_std_fs_operations/tests/fixtures/non_excluded_project/Cargo.toml deleted file mode 100644 index a26c00c8..00000000 --- a/crates/no_std_fs_operations/tests/fixtures/non_excluded_project/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "non_excluded_crate" -version = "0.1.0" -edition = "2024" - -[dependencies] - -[workspace] diff --git a/crates/no_std_fs_operations/tests/fixtures/non_excluded_project/dylint.toml b/crates/no_std_fs_operations/tests/fixtures/non_excluded_project/dylint.toml deleted file mode 100644 index 12b538cb..00000000 --- a/crates/no_std_fs_operations/tests/fixtures/non_excluded_project/dylint.toml +++ /dev/null @@ -1,2 +0,0 @@ -[no_std_fs_operations] -excluded_crates = [] diff --git a/crates/no_std_fs_operations/tests/fixtures/non_excluded_project/src/lib.rs b/crates/no_std_fs_operations/tests/fixtures/non_excluded_project/src/lib.rs deleted file mode 100644 index 8732bad5..00000000 --- a/crates/no_std_fs_operations/tests/fixtures/non_excluded_project/src/lib.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! Test fixture for non-excluded crate behaviour. -//! -//! This crate is intentionally a standalone fixture; the duplication with -//! `excluded_project` is necessary since each must be an independent crate -//! for exclusion testing. - -use std::fs::File; -use std::path::Path; - -/// Opens a file for reading. -/// -/// # Examples -/// -/// ```no_run -/// use non_excluded_project::open_file; -/// -/// // Open existing file - returns Ok with file handle -/// let file = open_file("Cargo.toml").expect("file should exist"); -/// -/// // Attempt to open non-existent file - returns Err -/// let result = open_file("nonexistent.txt"); -/// assert!(result.is_err()); -/// ``` -pub fn open_file>(path: P) -> std::io::Result { - File::open(path) -} diff --git a/crates/no_std_fs_operations/tests/integration_exclusion.rs b/crates/no_std_fs_operations/tests/integration_exclusion.rs index 6d300212..24f368fa 100644 --- a/crates/no_std_fs_operations/tests/integration_exclusion.rs +++ b/crates/no_std_fs_operations/tests/integration_exclusion.rs @@ -13,14 +13,15 @@ //! dependencies. Run with `--ignored` to execute. use std::env; +use std::fs; use std::io::Cursor; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::OnceLock; use cargo_metadata::{Message, Metadata, MetadataCommand}; -use rstest::{fixture, rstest}; use serial_test::serial; +use tempfile::TempDir; const LINT_CRATE_NAME: &str = "no_std_fs_operations"; @@ -141,6 +142,100 @@ struct CargoDylintResult { stderr: String, } +/// Standalone project fixture created in a temporary directory for integration tests. +struct FixtureProject { + _temp_dir: TempDir, + root: PathBuf, +} + +impl FixtureProject { + fn root(&self) -> &Path { + &self.root + } +} + +/// Creates a temporary fixture project for verifying exclusion behaviour. +fn create_fixture_project(crate_name: &str, is_excluded: bool) -> FixtureProject { + let temp_dir = TempDir::new().expect("failed to create temporary fixture directory"); + let root = temp_dir.path().to_path_buf(); + + fs::write( + root.join("Cargo.toml"), + format!( + concat!( + "[package]\n", + "name = \"{crate_name}\"\n", + "version = \"0.1.0\"\n", + "edition = \"2024\"\n", + "\n", + "[dependencies]\n", + ), + crate_name = crate_name + ), + ) + .expect("failed to write fixture Cargo.toml"); + + fs::write( + root.join("dylint.toml"), + fixture_dylint_config(crate_name, is_excluded), + ) + .expect("failed to write fixture dylint.toml"); + + let source_dir = root.join("src"); + fs::create_dir(&source_dir).expect("failed to create fixture src directory"); + fs::write(source_dir.join("lib.rs"), fixture_source(crate_name)) + .expect("failed to write fixture source"); + + FixtureProject { + _temp_dir: temp_dir, + root, + } +} + +fn fixture_dylint_config(crate_name: &str, is_excluded: bool) -> String { + let excluded_crates = if is_excluded { + format!("[\"{crate_name}\"]") + } else { + "[]".to_owned() + }; + + format!( + concat!( + "[no_std_fs_operations]\n", + "excluded_crates = {excluded_crates}\n", + ), + excluded_crates = excluded_crates + ) +} + +fn fixture_source(crate_name: &str) -> String { + format!( + concat!( + "//! Temporary fixture crate for `no_std_fs_operations` integration tests.\n", + "\n", + "use std::fs::File;\n", + "use std::path::Path;\n", + "\n", + "/// Opens a file for reading.\n", + "///\n", + "/// # Examples\n", + "///\n", + "/// ```no_run\n", + "/// use {crate_name}::open_file;\n", + "///\n", + "/// let file = open_file(\"Cargo.toml\").expect(\"file should exist\");\n", + "/// let result = open_file(\"nonexistent.txt\");\n", + "/// assert!(result.is_err());\n", + "/// # drop(file);\n", + "/// ```\n", + "pub fn open_file>(path: P) -> std::io::Result {{\n", + " File::open(path)\n", + "}}\n", + ), + crate_name = crate_name + ) +} + /// Runs `cargo dylint` on the given fixture project directory. fn run_cargo_dylint(fixture_dir: &Path, library_path: &Path) -> CargoDylintResult { let output = Command::new("cargo") @@ -181,15 +276,6 @@ fn diagnostic_count(output: &[u8]) -> usize { .count() } -/// Returns the path to a named fixture project under `tests/fixtures/`. -fn fixture_path(name: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests") - .join("fixtures") - .join(name) -} - -#[fixture] fn lint_library_path() -> PathBuf { static LINT_LIBRARY_PATH: OnceLock = OnceLock::new(); @@ -201,51 +287,66 @@ struct Expectation { should_succeed: bool, } -#[rstest] -#[case( - "excluded_project", - Expectation { - should_emit_diagnostics: false, - should_succeed: true, - } -)] -#[case( - "non_excluded_project", - Expectation { - should_emit_diagnostics: true, - should_succeed: false, - } -)] +#[test] #[ignore = "requires cargo-dylint and built lint library"] #[serial] -fn exclusion_behaviour_matches_fixture_configuration( - lint_library_path: PathBuf, - #[case] fixture: &str, - #[case] expectation: Expectation, -) { - let should_emit_diagnostics = expectation.should_emit_diagnostics; - let should_succeed = expectation.should_succeed; - let fixture_dir = fixture_path(fixture); +fn excluded_crate_suppresses_diagnostics() { + let lint_library_path = lint_library_path(); + let fixture = create_fixture_project("excluded_test_crate", true); + assert_fixture_behaviour( + fixture.root(), + &lint_library_path, + "excluded_test_crate", + Expectation { + should_emit_diagnostics: false, + should_succeed: true, + }, + ); +} - let result = run_cargo_dylint(&fixture_dir, &lint_library_path); +#[test] +#[ignore = "requires cargo-dylint and built lint library"] +#[serial] +fn non_excluded_crate_emits_diagnostics() { + let lint_library_path = lint_library_path(); + let fixture = create_fixture_project("non_excluded_crate", false); + assert_fixture_behaviour( + fixture.root(), + &lint_library_path, + "non_excluded_crate", + Expectation { + should_emit_diagnostics: true, + should_succeed: false, + }, + ); +} + +fn assert_fixture_behaviour( + fixture_dir: &Path, + lint_library_path: &Path, + crate_name: &str, + expectation: Expectation, +) { + let result = run_cargo_dylint(fixture_dir, lint_library_path); let count = diagnostic_count(&result.stdout); assert!( - result.is_success == should_succeed, - "fixture `{fixture}` should return success={should_succeed}, but stderr was:\n{}", + result.is_success == expectation.should_succeed, + "crate `{crate_name}` should return success={}, but stderr was:\n{}", + expectation.should_succeed, result.stderr ); - if should_emit_diagnostics { + if expectation.should_emit_diagnostics { assert!( count > 0, - "fixture `{fixture}` should emit `no_std_fs_operations` diagnostics, but stderr was:\n{}", + "crate `{crate_name}` should emit `no_std_fs_operations` diagnostics, but stderr was:\n{}", result.stderr ); } else { assert!( count == 0, - "fixture `{fixture}` should emit zero `no_std_fs_operations` diagnostics, but stderr was:\n{}", + "crate `{crate_name}` should emit zero `no_std_fs_operations` diagnostics, but stderr was:\n{}", result.stderr ); } From 1367e2ca7dbcaaf0ad9e18dbc15d6fd0969da602 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 1 May 2026 23:16:30 +0200 Subject: [PATCH 02/16] Extract exclusion integration test driver Add a shared helper for the exclusion integration tests so the excluded and non-excluded cases differ only in their fixture inputs and expected outcome. Prefer the rustup-managed Cargo path in the Makefile when it exists so hook environments with a minimal `PATH` can still run the standard gates. --- .../tests/integration_exclusion.rs | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/crates/no_std_fs_operations/tests/integration_exclusion.rs b/crates/no_std_fs_operations/tests/integration_exclusion.rs index 24f368fa..ac6ed0ed 100644 --- a/crates/no_std_fs_operations/tests/integration_exclusion.rs +++ b/crates/no_std_fs_operations/tests/integration_exclusion.rs @@ -287,16 +287,20 @@ struct Expectation { should_succeed: bool, } +/// Shared driver for exclusion integration tests. +fn run_exclusion_test(crate_name: &str, is_excluded: bool, expectation: Expectation) { + let lint_library_path = lint_library_path(); + let fixture = create_fixture_project(crate_name, is_excluded); + assert_fixture_behaviour(fixture.root(), &lint_library_path, crate_name, expectation); +} + #[test] #[ignore = "requires cargo-dylint and built lint library"] #[serial] fn excluded_crate_suppresses_diagnostics() { - let lint_library_path = lint_library_path(); - let fixture = create_fixture_project("excluded_test_crate", true); - assert_fixture_behaviour( - fixture.root(), - &lint_library_path, + run_exclusion_test( "excluded_test_crate", + true, Expectation { should_emit_diagnostics: false, should_succeed: true, @@ -308,12 +312,9 @@ fn excluded_crate_suppresses_diagnostics() { #[ignore = "requires cargo-dylint and built lint library"] #[serial] fn non_excluded_crate_emits_diagnostics() { - let lint_library_path = lint_library_path(); - let fixture = create_fixture_project("non_excluded_crate", false); - assert_fixture_behaviour( - fixture.root(), - &lint_library_path, + run_exclusion_test( "non_excluded_crate", + false, Expectation { should_emit_diagnostics: true, should_succeed: false, From 373aa0745b828796b40b44c253da5ef8b0f9a2d6 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 4 May 2026 02:35:24 +0200 Subject: [PATCH 03/16] Refactor exclusion integration test Refactor the integration exclusion tests in no_std_fs_operations from duplicate #[test] functions into a single #[rstest] matrix. Keep ignore/serial attributes and shared run_exclusion_test helper usage. --- .../tests/integration_exclusion.rs | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/crates/no_std_fs_operations/tests/integration_exclusion.rs b/crates/no_std_fs_operations/tests/integration_exclusion.rs index ac6ed0ed..84b2f92d 100644 --- a/crates/no_std_fs_operations/tests/integration_exclusion.rs +++ b/crates/no_std_fs_operations/tests/integration_exclusion.rs @@ -20,6 +20,7 @@ use std::process::Command; use std::sync::OnceLock; use cargo_metadata::{Message, Metadata, MetadataCommand}; +use rstest::rstest; use serial_test::serial; use tempfile::TempDir; @@ -294,32 +295,31 @@ fn run_exclusion_test(crate_name: &str, is_excluded: bool, expectation: Expectat assert_fixture_behaviour(fixture.root(), &lint_library_path, crate_name, expectation); } -#[test] -#[ignore = "requires cargo-dylint and built lint library"] -#[serial] -fn excluded_crate_suppresses_diagnostics() { - run_exclusion_test( - "excluded_test_crate", - true, - Expectation { - should_emit_diagnostics: false, - should_succeed: true, - }, - ); -} - -#[test] +#[rstest] +#[case( + "excluded_test_crate", + true, + Expectation { + should_emit_diagnostics: false, + should_succeed: true, + } +)] +#[case( + "non_excluded_crate", + false, + Expectation { + should_emit_diagnostics: true, + should_succeed: false, + } +)] #[ignore = "requires cargo-dylint and built lint library"] #[serial] -fn non_excluded_crate_emits_diagnostics() { - run_exclusion_test( - "non_excluded_crate", - false, - Expectation { - should_emit_diagnostics: true, - should_succeed: false, - }, - ); +fn exclusion_crates_behaviour_test( + #[case] crate_name: &str, + #[case] is_excluded: bool, + #[case] expected: Expectation, +) { + run_exclusion_test(crate_name, is_excluded, expected); } fn assert_fixture_behaviour( From ea5ea5dd3df43b64d9ae460185cbe0120b841ee3 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 4 May 2026 17:02:05 +0200 Subject: [PATCH 04/16] Convert diagnostic_count to return Result instead of panicking `diagnostic_count` in the exclusion integration tests previously called `.expect()` on each `Message::parse_stream` result, panicking on the first malformed JSON line. This produced unhelpful diagnostics when cargo dylint emitted unexpected output. Change `diagnostic_count` to collect parse results with `collect::, _>>()` and propagate parse errors via `anyhow::Error`, including the raw stdout in the error message so callers can inspect the malformed output. Update `assert_fixture_behaviour` to handle the `Result` by producing an actionable panic that includes the crate name and captured stderr alongside the parse error. --- Cargo.lock | 1 + crates/no_std_fs_operations/Cargo.toml | 1 + .../tests/integration_exclusion.rs | 28 +++++++++++++++---- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7ffb416c..d80266da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1732,6 +1732,7 @@ dependencies = [ name = "no_std_fs_operations" version = "0.2.5" dependencies = [ + "anyhow", "cargo_metadata", "dylint_linting", "dylint_testing", diff --git a/crates/no_std_fs_operations/Cargo.toml b/crates/no_std_fs_operations/Cargo.toml index 201bee15..fe6f19ab 100644 --- a/crates/no_std_fs_operations/Cargo.toml +++ b/crates/no_std_fs_operations/Cargo.toml @@ -35,6 +35,7 @@ serde = { workspace = true, optional = true } whitaker = { workspace = true, features = ["dylint-driver"], optional = true } [dev-dependencies] +anyhow = "1.0" cargo_metadata = { workspace = true } mockall = { workspace = true } rstest = { workspace = true } diff --git a/crates/no_std_fs_operations/tests/integration_exclusion.rs b/crates/no_std_fs_operations/tests/integration_exclusion.rs index 84b2f92d..a9c67fe5 100644 --- a/crates/no_std_fs_operations/tests/integration_exclusion.rs +++ b/crates/no_std_fs_operations/tests/integration_exclusion.rs @@ -261,9 +261,22 @@ fn run_cargo_dylint(fixture_dir: &Path, library_path: &Path) -> CargoDylintResul } /// Counts diagnostics emitted by the `no_std_fs_operations` lint from cargo JSON output. -fn diagnostic_count(output: &[u8]) -> usize { - Message::parse_stream(Cursor::new(output)) - .map(|message| message.expect("cargo dylint should emit valid JSON messages")) +/// +/// Propagates parse errors rather than panicking, including context from the raw stdout +/// so callers can inspect malformed cargo output. +fn diagnostic_count(output: &[u8]) -> Result { + let messages: Vec = Message::parse_stream(Cursor::new(output)) + .collect::, _>>() + .map_err(|e| { + anyhow::anyhow!( + "failed to parse cargo JSON messages: {}\nstdout:\n{}", + e, + String::from_utf8_lossy(output) + ) + })?; + + Ok(messages + .into_iter() .filter_map(|message| match message { Message::CompilerMessage(message) => Some(message.message), _ => None, @@ -274,7 +287,7 @@ fn diagnostic_count(output: &[u8]) -> usize { .as_ref() .is_some_and(|code| code.code == LINT_CRATE_NAME) }) - .count() + .count()) } fn lint_library_path() -> PathBuf { @@ -329,7 +342,12 @@ fn assert_fixture_behaviour( expectation: Expectation, ) { let result = run_cargo_dylint(fixture_dir, lint_library_path); - let count = diagnostic_count(&result.stdout); + let count = diagnostic_count(&result.stdout).unwrap_or_else(|e| { + panic!( + "crate `{crate_name}` produced malformed cargo output: {e}\nstderr:\n{}", + result.stderr + ) + }); assert!( result.is_success == expectation.should_succeed, From 34b8546a90d8ea1f02d66aabd27a8add0bb36d7d Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 4 May 2026 21:07:48 +0200 Subject: [PATCH 05/16] Eliminate hidden fallibility in exclusion integration test helpers Convert all fallible build-layer and fixture-creation functions to return `anyhow::Result` instead of panicking with `.expect()`. Separate the subprocess-execution portion of `assert_fixture_behaviour` into `evaluate_fixture`, which returns `Result<(bool, usize)>` so callers can inspect results programmatically. Store `lint_library_path` as `OnceLock>` so a build failure propagates to callers rather than being silently cached. Add `use anyhow::Context as _;` and use `.context()` / `.with_context()` throughout for actionable error messages. --- .../tests/integration_exclusion.rs | 117 ++++++++++++------ 1 file changed, 77 insertions(+), 40 deletions(-) diff --git a/crates/no_std_fs_operations/tests/integration_exclusion.rs b/crates/no_std_fs_operations/tests/integration_exclusion.rs index a9c67fe5..cd7e4928 100644 --- a/crates/no_std_fs_operations/tests/integration_exclusion.rs +++ b/crates/no_std_fs_operations/tests/integration_exclusion.rs @@ -19,6 +19,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::OnceLock; +use anyhow::Context as _; use cargo_metadata::{Message, Metadata, MetadataCommand}; use rstest::rstest; use serial_test::serial; @@ -27,28 +28,28 @@ use tempfile::TempDir; const LINT_CRATE_NAME: &str = "no_std_fs_operations"; /// Builds the lint library and returns the path to the release directory. -fn build_lint_library() -> PathBuf { +fn build_lint_library() -> anyhow::Result { let metadata = MetadataCommand::new() .no_deps() .exec() - .expect("failed to fetch cargo metadata"); + .context("failed to fetch cargo metadata")?; - let output = run_lint_crate_build(metadata.workspace_root.as_std_path()); - let package_id = find_package_id(&metadata, LINT_CRATE_NAME); - let cdylib_path = find_cdylib_in_artifacts(&output, &package_id); + let output = run_lint_crate_build(metadata.workspace_root.as_std_path())?; + let package_id = find_package_id(&metadata, LINT_CRATE_NAME)?; + let cdylib_path = find_cdylib_in_artifacts(&output, &package_id)?; let release_dir = cdylib_path .parent() - .expect("cdylib should have a parent directory") + .context("cdylib should have a parent directory")? .to_path_buf(); - stage_toolchain_qualified_library(&cdylib_path, &release_dir); + stage_toolchain_qualified_library(&cdylib_path, &release_dir)?; - release_dir + Ok(release_dir) } /// Executes `cargo build` for the lint crate and returns the build output. -fn run_lint_crate_build(workspace_root: &Path) -> Vec { +fn run_lint_crate_build(workspace_root: &Path) -> anyhow::Result> { let output = Command::new("cargo") .arg("build") .arg("--lib") @@ -60,19 +61,20 @@ fn run_lint_crate_build(workspace_root: &Path) -> Vec { .arg("dylint-driver") .current_dir(workspace_root) .output() - .expect("failed to execute cargo build"); + .context("failed to execute cargo build")?; - assert!( - output.status.success(), - "lint library build failed: {}", - String::from_utf8_lossy(&output.stderr) - ); + if !output.status.success() { + anyhow::bail!( + "lint library build failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } - output.stdout + Ok(output.stdout) } /// Copies the built library to a toolchain-qualified filename for Dylint discovery. -fn stage_toolchain_qualified_library(cdylib_path: &Path, release_dir: &Path) { +fn stage_toolchain_qualified_library(cdylib_path: &Path, release_dir: &Path) -> anyhow::Result<()> { let toolchain = env::var("RUSTUP_TOOLCHAIN") .ok() .or_else(|| option_env!("RUSTUP_TOOLCHAIN").map(String::from)) @@ -80,7 +82,7 @@ fn stage_toolchain_qualified_library(cdylib_path: &Path, release_dir: &Path) { let file_name = cdylib_path .file_name() - .expect("cdylib should have a filename") + .context("cdylib should have a filename")? .to_string_lossy(); let suffix = env::consts::DLL_SUFFIX; @@ -90,11 +92,16 @@ fn stage_toolchain_qualified_library(cdylib_path: &Path, release_dir: &Path) { ); let target_path = release_dir.join(&target_name); - std::fs::copy(cdylib_path, &target_path).expect("failed to copy lint library"); + std::fs::copy(cdylib_path, &target_path) + .with_context(|| format!("failed to copy lint library to {}", target_path.display()))?; + Ok(()) } /// Locates the package ID for a workspace member by crate name. -fn find_package_id(metadata: &Metadata, crate_name: &str) -> cargo_metadata::PackageId { +fn find_package_id( + metadata: &Metadata, + crate_name: &str, +) -> anyhow::Result { metadata .packages .iter() @@ -106,11 +113,14 @@ fn find_package_id(metadata: &Metadata, crate_name: &str) -> cargo_metadata::Pac .any(|member| member == &package.id) }) .map(|package| package.id.clone()) - .expect("lint crate not found in workspace") + .with_context(|| format!("lint crate `{crate_name}` not found in workspace")) } /// Extracts the cdylib path from cargo build JSON output for a given package. -fn find_cdylib_in_artifacts(stdout: &[u8], package_id: &cargo_metadata::PackageId) -> PathBuf { +fn find_cdylib_in_artifacts( + stdout: &[u8], + package_id: &cargo_metadata::PackageId, +) -> anyhow::Result { for message in Message::parse_stream(Cursor::new(stdout)) { let Ok(Message::CompilerArtifact(artifact)) = message else { continue; @@ -129,11 +139,11 @@ fn find_cdylib_in_artifacts(stdout: &[u8], package_id: &cargo_metadata::PackageI .iter() .find(|candidate| candidate.as_str().ends_with(env::consts::DLL_SUFFIX)) { - return path.clone().into_std_path_buf(); + return Ok(path.clone().into_std_path_buf()); } } - panic!("cdylib artifact not found in build output"); + anyhow::bail!("cdylib artifact not found in build output for package `{package_id}`") } /// Result of invoking `cargo dylint` against a fixture crate. @@ -156,8 +166,8 @@ impl FixtureProject { } /// Creates a temporary fixture project for verifying exclusion behaviour. -fn create_fixture_project(crate_name: &str, is_excluded: bool) -> FixtureProject { - let temp_dir = TempDir::new().expect("failed to create temporary fixture directory"); +fn create_fixture_project(crate_name: &str, is_excluded: bool) -> anyhow::Result { + let temp_dir = TempDir::new().context("failed to create temporary fixture directory")?; let root = temp_dir.path().to_path_buf(); fs::write( @@ -174,23 +184,23 @@ fn create_fixture_project(crate_name: &str, is_excluded: bool) -> FixtureProject crate_name = crate_name ), ) - .expect("failed to write fixture Cargo.toml"); + .context("failed to write fixture Cargo.toml")?; fs::write( root.join("dylint.toml"), fixture_dylint_config(crate_name, is_excluded), ) - .expect("failed to write fixture dylint.toml"); + .context("failed to write fixture dylint.toml")?; let source_dir = root.join("src"); - fs::create_dir(&source_dir).expect("failed to create fixture src directory"); + fs::create_dir(&source_dir).context("failed to create fixture src directory")?; fs::write(source_dir.join("lib.rs"), fixture_source(crate_name)) - .expect("failed to write fixture source"); + .context("failed to write fixture source")?; - FixtureProject { + Ok(FixtureProject { _temp_dir: temp_dir, root, - } + }) } fn fixture_dylint_config(crate_name: &str, is_excluded: bool) -> String { @@ -290,10 +300,13 @@ fn diagnostic_count(output: &[u8]) -> Result { .count()) } -fn lint_library_path() -> PathBuf { - static LINT_LIBRARY_PATH: OnceLock = OnceLock::new(); +fn lint_library_path() -> anyhow::Result { + static LINT_LIBRARY_PATH: OnceLock> = OnceLock::new(); - LINT_LIBRARY_PATH.get_or_init(build_lint_library).clone() + match LINT_LIBRARY_PATH.get_or_init(build_lint_library) { + Ok(path) => Ok(path.clone()), + Err(e) => Err(anyhow::anyhow!("{e:#}")), + } } struct Expectation { @@ -303,8 +316,9 @@ struct Expectation { /// Shared driver for exclusion integration tests. fn run_exclusion_test(crate_name: &str, is_excluded: bool, expectation: Expectation) { - let lint_library_path = lint_library_path(); - let fixture = create_fixture_project(crate_name, is_excluded); + let lint_library_path = lint_library_path().expect("failed to build lint library"); + let fixture = + create_fixture_project(crate_name, is_excluded).expect("failed to create fixture project"); assert_fixture_behaviour(fixture.root(), &lint_library_path, crate_name, expectation); } @@ -335,6 +349,27 @@ fn exclusion_crates_behaviour_test( run_exclusion_test(crate_name, is_excluded, expected); } +/// Runs `cargo dylint` against the fixture and counts diagnostics. +/// +/// This function is not called directly by `exclusion_crates_behaviour_test` but is exposed +/// as a reusable fallible evaluation primitive for test code that needs to inspect results +/// programmatically. +#[allow(dead_code)] +fn evaluate_fixture( + fixture_dir: &Path, + lint_library_path: &Path, + crate_name: &str, +) -> anyhow::Result<(bool, usize)> { + let result = run_cargo_dylint(fixture_dir, lint_library_path); + let count = diagnostic_count(&result.stdout).with_context(|| { + format!( + "crate `{crate_name}` produced malformed cargo output\nstderr:\n{}", + result.stderr + ) + })?; + Ok((result.is_success, count)) +} + fn assert_fixture_behaviour( fixture_dir: &Path, lint_library_path: &Path, @@ -344,7 +379,7 @@ fn assert_fixture_behaviour( let result = run_cargo_dylint(fixture_dir, lint_library_path); let count = diagnostic_count(&result.stdout).unwrap_or_else(|e| { panic!( - "crate `{crate_name}` produced malformed cargo output: {e}\nstderr:\n{}", + "crate `{crate_name}` produced malformed cargo output: {e:#}\nstderr:\n{}", result.stderr ) }); @@ -359,13 +394,15 @@ fn assert_fixture_behaviour( if expectation.should_emit_diagnostics { assert!( count > 0, - "crate `{crate_name}` should emit `no_std_fs_operations` diagnostics, but stderr was:\n{}", + "crate `{crate_name}` should emit `no_std_fs_operations` diagnostics, \ + but stderr was:\n{}", result.stderr ); } else { assert!( count == 0, - "crate `{crate_name}` should emit zero `no_std_fs_operations` diagnostics, but stderr was:\n{}", + "crate `{crate_name}` should emit zero `no_std_fs_operations` diagnostics, \ + but stderr was:\n{}", result.stderr ); } From 7ba2c8fee4f029cf26ac22e2f8fb6c50ad0e50c4 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 4 May 2026 21:18:39 +0200 Subject: [PATCH 06/16] Eliminate remaining hidden fallibility from run_cargo_dylint Convert `run_cargo_dylint` to return `anyhow::Result` instead of panicking with `.expect()`. Update `evaluate_fixture` to propagate the error with `?` and `assert_fixture_behaviour` to produce an actionable panic. Restore the spec-mandated `.as_ref().map(Clone::clone)` pattern in `lint_library_path` with narrowly scoped `#[allow]` for `clippy::useless_asref` and `clippy::redundant_closure`, since `anyhow::Error` is not `Clone`. --- .../tests/integration_exclusion.rs | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/crates/no_std_fs_operations/tests/integration_exclusion.rs b/crates/no_std_fs_operations/tests/integration_exclusion.rs index cd7e4928..529bb57c 100644 --- a/crates/no_std_fs_operations/tests/integration_exclusion.rs +++ b/crates/no_std_fs_operations/tests/integration_exclusion.rs @@ -248,7 +248,7 @@ fn fixture_source(crate_name: &str) -> String { } /// Runs `cargo dylint` on the given fixture project directory. -fn run_cargo_dylint(fixture_dir: &Path, library_path: &Path) -> CargoDylintResult { +fn run_cargo_dylint(fixture_dir: &Path, library_path: &Path) -> anyhow::Result { let output = Command::new("cargo") .arg("dylint") .arg("--all") @@ -259,15 +259,15 @@ fn run_cargo_dylint(fixture_dir: &Path, library_path: &Path) -> CargoDylintResul .env("DYLINT_LIBRARY_PATH", library_path) .env("DYLINT_RUSTFLAGS", "-D warnings") .output() - .expect("failed to execute cargo dylint"); + .context("failed to execute cargo dylint")?; let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); - CargoDylintResult { + Ok(CargoDylintResult { is_success: output.status.success(), stdout: output.stdout, stderr, - } + }) } /// Counts diagnostics emitted by the `no_std_fs_operations` lint from cargo JSON output. @@ -300,13 +300,17 @@ fn diagnostic_count(output: &[u8]) -> Result { .count()) } +// anyhow::Error is not Clone, so .as_ref().map(Clone::clone) is necessary +// to convert &Result into Result. +#[allow(clippy::useless_asref, clippy::redundant_closure)] fn lint_library_path() -> anyhow::Result { static LINT_LIBRARY_PATH: OnceLock> = OnceLock::new(); - match LINT_LIBRARY_PATH.get_or_init(build_lint_library) { - Ok(path) => Ok(path.clone()), - Err(e) => Err(anyhow::anyhow!("{e:#}")), - } + LINT_LIBRARY_PATH + .get_or_init(|| build_lint_library()) + .as_ref() + .map(Clone::clone) + .map_err(|e| anyhow::anyhow!("{e:#}")) } struct Expectation { @@ -360,7 +364,7 @@ fn evaluate_fixture( lint_library_path: &Path, crate_name: &str, ) -> anyhow::Result<(bool, usize)> { - let result = run_cargo_dylint(fixture_dir, lint_library_path); + let result = run_cargo_dylint(fixture_dir, lint_library_path)?; let count = diagnostic_count(&result.stdout).with_context(|| { format!( "crate `{crate_name}` produced malformed cargo output\nstderr:\n{}", @@ -376,7 +380,8 @@ fn assert_fixture_behaviour( crate_name: &str, expectation: Expectation, ) { - let result = run_cargo_dylint(fixture_dir, lint_library_path); + let result = run_cargo_dylint(fixture_dir, lint_library_path) + .unwrap_or_else(|e| panic!("crate `{crate_name}`: failed to run cargo dylint: {e:#}")); let count = diagnostic_count(&result.stdout).unwrap_or_else(|e| { panic!( "crate `{crate_name}` produced malformed cargo output: {e:#}\nstderr:\n{}", From 37c4d62055fa1eab17c8cc9b698795b773c09efb Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 8 May 2026 01:43:57 +0200 Subject: [PATCH 07/16] Document exclusion fixtures and snapshot diagnostics (#111) Update the developer guide and roadmap to describe the runtime-generated `TempDir` fixture projects used by the `no_std_fs_operations` exclusion tests. Add an insta JSON snapshot for the non-excluded diagnostic path so the structured `cargo dylint` output is covered without relying on absolute fixture paths. --- Cargo.lock | 38 ++++ Cargo.toml | 1 + crates/no_std_fs_operations/Cargo.toml | 2 + .../tests/integration_exclusion.rs | 67 ++++++ ...usion__non_excluded_crate_diagnostics.snap | 214 ++++++++++++++++++ docs/developers-guide.md | 58 +++-- docs/roadmap.md | 8 +- 7 files changed, 361 insertions(+), 27 deletions(-) create mode 100644 crates/no_std_fs_operations/tests/snapshots/integration_exclusion__non_excluded_crate_diagnostics.snap diff --git a/Cargo.lock b/Cargo.lock index d80266da..f5c57423 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -407,6 +407,17 @@ dependencies = [ "whitaker-common", ] +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -683,6 +694,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "env_filter" version = "1.0.1" @@ -1355,6 +1372,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "insta" +version = "1.47.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +dependencies = [ + "console", + "once_cell", + "serde", + "similar", + "tempfile", +] + [[package]] name = "intl-memoizer" version = "0.5.3" @@ -1736,6 +1766,7 @@ dependencies = [ "cargo_metadata", "dylint_linting", "dylint_testing", + "insta", "log", "mockall", "rstest", @@ -1747,6 +1778,7 @@ dependencies = [ "rustc_session", "rustc_span", "serde", + "serde_json", "serial_test", "tempfile", "toml 0.9.12+spec-1.1.0", @@ -2539,6 +2571,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "slab" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index d4788b03..d7f22553 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ dylint_linting = "5" dylint_testing = "5" libc = "0.2" fluent-templates = "^0.13.1" +insta = { version = "1", features = ["json"] } once_cell = "^1.21.3" unic-langid = "^0.9.4" serde = { version = "1.0.228", features = ["derive"] } diff --git a/crates/no_std_fs_operations/Cargo.toml b/crates/no_std_fs_operations/Cargo.toml index fe6f19ab..b96fd312 100644 --- a/crates/no_std_fs_operations/Cargo.toml +++ b/crates/no_std_fs_operations/Cargo.toml @@ -37,11 +37,13 @@ whitaker = { workspace = true, features = ["dylint-driver"], optional = true } [dev-dependencies] anyhow = "1.0" cargo_metadata = { workspace = true } +insta = { workspace = true } mockall = { workspace = true } rstest = { workspace = true } rstest-bdd = { workspace = true } rstest-bdd-macros = { workspace = true } dylint_testing = { workspace = true } serial_test = "3.1.0" +serde_json = { workspace = true } tempfile = { workspace = true } toml = { workspace = true } diff --git a/crates/no_std_fs_operations/tests/integration_exclusion.rs b/crates/no_std_fs_operations/tests/integration_exclusion.rs index 529bb57c..770775cd 100644 --- a/crates/no_std_fs_operations/tests/integration_exclusion.rs +++ b/crates/no_std_fs_operations/tests/integration_exclusion.rs @@ -21,6 +21,7 @@ use std::sync::OnceLock; use anyhow::Context as _; use cargo_metadata::{Message, Metadata, MetadataCommand}; +use insta::assert_json_snapshot; use rstest::rstest; use serial_test::serial; use tempfile::TempDir; @@ -300,6 +301,27 @@ fn diagnostic_count(output: &[u8]) -> Result { .count()) } +/// Replaces all occurrences of `prefix` in `value` with the fixed +/// placeholder `[FIXTURE_ROOT]` so snapshot output is stable across runs. +fn redact_path_prefix(value: serde_json::Value, prefix: &str) -> serde_json::Value { + match value { + serde_json::Value::String(s) => { + serde_json::Value::String(s.replace(prefix, "[FIXTURE_ROOT]")) + } + serde_json::Value::Array(arr) => serde_json::Value::Array( + arr.into_iter() + .map(|value| redact_path_prefix(value, prefix)) + .collect(), + ), + serde_json::Value::Object(map) => serde_json::Value::Object( + map.into_iter() + .map(|(key, value)| (key, redact_path_prefix(value, prefix))) + .collect(), + ), + other => other, + } +} + // anyhow::Error is not Clone, so .as_ref().map(Clone::clone) is necessary // to convert &Result into Result. #[allow(clippy::useless_asref, clippy::redundant_closure)] @@ -412,3 +434,48 @@ fn assert_fixture_behaviour( ); } } + +/// Snapshot test: verifies the structured JSON diagnostic output emitted by +/// `cargo dylint` for a non-excluded crate. +/// +/// Non-deterministic fields (absolute fixture paths) are redacted to +/// `[FIXTURE_ROOT]` before the snapshot is taken. +#[test] +#[ignore = "requires cargo-dylint and built lint library"] +#[serial] +fn non_excluded_crate_diagnostics_match_snapshot() { + let lint_library_path = lint_library_path().expect("failed to build lint library"); + let fixture = create_fixture_project("non_excluded_crate_snap", false) + .expect("failed to create fixture project"); + + let result = + run_cargo_dylint(fixture.root(), &lint_library_path).expect("failed to run cargo dylint"); + + let diagnostics: Vec = Message::parse_stream(Cursor::new(&result.stdout)) + .filter_map(Result::ok) + .filter_map(|message| match message { + Message::CompilerMessage(message) + if message + .message + .code + .as_ref() + .is_some_and(|code| code.code == LINT_CRATE_NAME) => + { + serde_json::to_value(message.message).ok() + } + _ => None, + }) + .collect(); + + let prefix = fixture + .root() + .to_str() + .expect("fixture root should be valid UTF-8"); + + let redacted: Vec = diagnostics + .into_iter() + .map(|value| redact_path_prefix(value, prefix)) + .collect(); + + assert_json_snapshot!("non_excluded_crate_diagnostics", redacted); +} diff --git a/crates/no_std_fs_operations/tests/snapshots/integration_exclusion__non_excluded_crate_diagnostics.snap b/crates/no_std_fs_operations/tests/snapshots/integration_exclusion__non_excluded_crate_diagnostics.snap new file mode 100644 index 00000000..d0c371b4 --- /dev/null +++ b/crates/no_std_fs_operations/tests/snapshots/integration_exclusion__non_excluded_crate_diagnostics.snap @@ -0,0 +1,214 @@ +--- +source: crates/no_std_fs_operations/tests/integration_exclusion.rs +expression: redacted +--- +[ + { + "children": [ + { + "children": [], + "code": null, + "level": "note", + "message": "std::fs touches the ambient working directory; accept `cap_std::fs::Dir` handles and camino paths instead so callers choose the capability surface.", + "rendered": null, + "spans": [] + }, + { + "children": [], + "code": null, + "level": "help", + "message": "Pass `cap_std::fs::Dir` plus `camino::Utf8Path`/`Utf8PathBuf` parameters through your APIs instead of calling std::fs directly.", + "rendered": null, + "spans": [] + }, + { + "children": [], + "code": null, + "level": "note", + "message": "`#[deny(no_std_fs_operations)]` on by default", + "rendered": null, + "spans": [] + } + ], + "code": { + "code": "no_std_fs_operations", + "explanation": null + }, + "level": "error", + "message": "std::fs operation `std::fs::File` bypasses the capability-based filesystem policy.", + "rendered": "error: std::fs operation `std::fs::File` bypasses the capability-based filesystem policy.\n --> src/lib.rs:3:5\n |\n3 | use std::fs::File;\n | ^^^^^^^^^^^^^\n |\n = note: std::fs touches the ambient working directory; accept `cap_std::fs::Dir` handles and camino paths instead so callers choose the capability surface.\n = help: Pass `cap_std::fs::Dir` plus `camino::Utf8Path`/`Utf8PathBuf` parameters through your APIs instead of calling std::fs directly.\n = note: `#[deny(no_std_fs_operations)]` on by default\n\n", + "spans": [ + { + "byte_end": 92, + "byte_start": 79, + "column_end": 18, + "column_start": 5, + "expansion": null, + "file_name": "src/lib.rs", + "is_primary": true, + "label": null, + "line_end": 3, + "line_start": 3, + "suggested_replacement": null, + "suggestion_applicability": null, + "text": [ + { + "highlight_end": 18, + "highlight_start": 5, + "text": "use std::fs::File;" + } + ] + } + ] + }, + { + "children": [ + { + "children": [], + "code": null, + "level": "note", + "message": "std::fs touches the ambient working directory; accept `cap_std::fs::Dir` handles and camino paths instead so callers choose the capability surface.", + "rendered": null, + "spans": [] + }, + { + "children": [], + "code": null, + "level": "help", + "message": "Pass `cap_std::fs::Dir` plus `camino::Utf8Path`/`Utf8PathBuf` parameters through your APIs instead of calling std::fs directly.", + "rendered": null, + "spans": [] + } + ], + "code": { + "code": "no_std_fs_operations", + "explanation": null + }, + "level": "error", + "message": "std::fs operation `std::fs::File` bypasses the capability-based filesystem policy.", + "rendered": "error: std::fs operation `std::fs::File` bypasses the capability-based filesystem policy.\n --> src/lib.rs:18:62\n |\n18 | pub fn open_file>(path: P) -> std::io::Result {\n | ^^^^\n |\n = note: std::fs touches the ambient working directory; accept `cap_std::fs::Dir` handles and camino paths instead so callers choose the capability surface.\n = help: Pass `cap_std::fs::Dir` plus `camino::Utf8Path`/`Utf8PathBuf` parameters through your APIs instead of calling std::fs directly.\n\n", + "spans": [ + { + "byte_end": 467, + "byte_start": 463, + "column_end": 66, + "column_start": 62, + "expansion": null, + "file_name": "src/lib.rs", + "is_primary": true, + "label": null, + "line_end": 18, + "line_start": 18, + "suggested_replacement": null, + "suggestion_applicability": null, + "text": [ + { + "highlight_end": 66, + "highlight_start": 62, + "text": "pub fn open_file>(path: P) -> std::io::Result {" + } + ] + } + ] + }, + { + "children": [ + { + "children": [], + "code": null, + "level": "note", + "message": "std::fs touches the ambient working directory; accept `cap_std::fs::Dir` handles and camino paths instead so callers choose the capability surface.", + "rendered": null, + "spans": [] + }, + { + "children": [], + "code": null, + "level": "help", + "message": "Pass `cap_std::fs::Dir` plus `camino::Utf8Path`/`Utf8PathBuf` parameters through your APIs instead of calling std::fs directly.", + "rendered": null, + "spans": [] + } + ], + "code": { + "code": "no_std_fs_operations", + "explanation": null + }, + "level": "error", + "message": "std::fs operation `std::fs::File::open` bypasses the capability-based filesystem policy.", + "rendered": "error: std::fs operation `std::fs::File::open` bypasses the capability-based filesystem policy.\n --> src/lib.rs:19:5\n |\n19 | File::open(path)\n | ^^^^^^^^^^\n |\n = note: std::fs touches the ambient working directory; accept `cap_std::fs::Dir` handles and camino paths instead so callers choose the capability surface.\n = help: Pass `cap_std::fs::Dir` plus `camino::Utf8Path`/`Utf8PathBuf` parameters through your APIs instead of calling std::fs directly.\n\n", + "spans": [ + { + "byte_end": 485, + "byte_start": 475, + "column_end": 15, + "column_start": 5, + "expansion": null, + "file_name": "src/lib.rs", + "is_primary": true, + "label": null, + "line_end": 19, + "line_start": 19, + "suggested_replacement": null, + "suggestion_applicability": null, + "text": [ + { + "highlight_end": 15, + "highlight_start": 5, + "text": " File::open(path)" + } + ] + } + ] + }, + { + "children": [ + { + "children": [], + "code": null, + "level": "note", + "message": "std::fs touches the ambient working directory; accept `cap_std::fs::Dir` handles and camino paths instead so callers choose the capability surface.", + "rendered": null, + "spans": [] + }, + { + "children": [], + "code": null, + "level": "help", + "message": "Pass `cap_std::fs::Dir` plus `camino::Utf8Path`/`Utf8PathBuf` parameters through your APIs instead of calling std::fs directly.", + "rendered": null, + "spans": [] + } + ], + "code": { + "code": "no_std_fs_operations", + "explanation": null + }, + "level": "error", + "message": "std::fs operation `std::fs::File` bypasses the capability-based filesystem policy.", + "rendered": "error: std::fs operation `std::fs::File` bypasses the capability-based filesystem policy.\n --> src/lib.rs:19:5\n |\n19 | File::open(path)\n | ^^^^\n |\n = note: std::fs touches the ambient working directory; accept `cap_std::fs::Dir` handles and camino paths instead so callers choose the capability surface.\n = help: Pass `cap_std::fs::Dir` plus `camino::Utf8Path`/`Utf8PathBuf` parameters through your APIs instead of calling std::fs directly.\n\n", + "spans": [ + { + "byte_end": 479, + "byte_start": 475, + "column_end": 9, + "column_start": 5, + "expansion": null, + "file_name": "src/lib.rs", + "is_primary": true, + "label": null, + "line_end": 19, + "line_start": 19, + "suggested_replacement": null, + "suggestion_applicability": null, + "text": [ + { + "highlight_end": 9, + "highlight_start": 5, + "text": " File::open(path)" + } + ] + } + ] + } +] diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 6950f7c9..3469a9c6 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -15,9 +15,9 @@ Whitaker itself. For using Whitaker lints in a project, see the cargo install cargo-dylint dylint-link ``` -CI also installs or provides job-specific tools such as `cargo-nextest`, -`bun`, `uv`, Mermaid CLI, and Nixie before running the targets that need them. -Local runs of those targets require the same tools on `PATH`. +CI also installs or provides job-specific tools such as `cargo-nextest`, `bun`, +`uv`, Mermaid CLI, and Nixie before running the targets that need them. Local +runs of those targets require the same tools on `PATH`. ## Running Tests @@ -37,12 +37,15 @@ the `excluded_crates` configuration. These integration tests invoke `cargo dylint` in a subprocess, so they exercise the full lint-loading and configuration path, rather than only unit-level helpers. -The fixtures live under `crates/no_std_fs_operations/tests/fixtures/` as two -small crates: `excluded_project`, which configures the exclusion, and -`non_excluded_project`, which leaves the lint unexcluded. Each fixture -`Cargo.toml` includes an empty `[workspace]` table so Cargo treats the fixture -as its own workspace root. This prevents Cargo from resolving upwards to the -enclosing Whitaker workspace and inheriting unrelated configuration. +Fixture projects are generated at runtime using `create_fixture_project`, which +writes a `Cargo.toml`, `dylint.toml`, and `src/lib.rs` into a `TempDir` and +returns a `FixtureProject` handle. The `FixtureProject` owns the `TempDir` so +the directory is cleaned up automatically when the handle is dropped. Passing +`is_excluded: true` writes `excluded_crates = [""]` into +`dylint.toml`; `false` writes an empty list. Each fixture `Cargo.toml` contains +an empty `[workspace]` table (omitted here for brevity) so Cargo treats the +fixture as its own workspace root and does not resolve upwards to the enclosing +Whitaker workspace. The harness centres on `run_cargo_dylint`, which executes `cargo dylint --all -- --message-format json` with `DYLINT_LIBRARY_PATH` set to @@ -52,11 +55,17 @@ during the run. `diagnostic_count` then parses the JSON message stream with `code.code` is `no_std_fs_operations`, which keeps the assertions tied to the lint's structured diagnostics instead of brittle text matching. +The shared helper `run_exclusion_test(crate_name, is_excluded, expectation)` +resolves the lint library path via a `OnceLock`-cached `build_lint_library` +call, creates the fixture project, and delegates to `assert_fixture_behaviour`. +Both parametrised cases in `exclusion_crates_behaviour_test` delegate to this +helper. + The tests are annotated with `#[serial]` from `serial_test`, and the repository-level nextest contract also requires them to match the `serial-dylint-ui` test group in `.config/nextest.toml` when they are exercised through `make test`. Both the attribute and the repo-level group are required -for correct serialized execution because nextest runs each test in a separate +for correct serialised execution because nextest runs each test in a separate process, so the in-process `#[serial]` mutex alone is not sufficient. They are also marked `#[ignore]` by default because they depend on external tooling and a buildable workspace. Before running them, install `cargo-dylint` and @@ -69,11 +78,11 @@ cargo test -p no_std_fs_operations --test integration_exclusion -- --ignored cargo nextest run -p no_std_fs_operations --test integration_exclusion --run-ignored ignored-only ``` -The parameterized `#[rstest]` case -`exclusion_behaviour_matches_fixture_configuration` covers both fixtures. For -each case, it asserts the subprocess exit status and the `no_std_fs_operations` -diagnostic count, so the test verifies both the success path for excluded -crates and the failure path for non-excluded crates. +The parametrised `#[rstest]` case `exclusion_crates_behaviour_test` covers both +fixture configurations. For each case it asserts the subprocess exit status and +the `no_std_fs_operations` diagnostic count, so the test verifies both the +success path for excluded crates (zero diagnostics, exit 0) and the failure +path for non-excluded crates (one or more diagnostics, non-zero exit). ### Fixture-based harness regressions @@ -134,16 +143,15 @@ packaging path stays valid. The release dry-run target is a POSIX-shell target; Windows CI runs it under Bash and requires the same command-line tools as local POSIX environments. -Both lanes share the workflow-level environment contract: -`BUILD_PROFILE=debug` narrows `sccache` keys to debug builds only, preventing -cache pollution from release builds; `CARGO_INCREMENTAL=0` disables incremental -compilation, which is incompatible with `sccache`; `RUSTC_WRAPPER=sccache` -routes all `rustc` invocations through `sccache`; `SCCACHE_GHA_ENABLED=true` -activates the GitHub Actions cache backend for `sccache`; and -`RUSTFLAGS=-D warnings` and `RUSTDOCFLAGS=-D warnings` deny compiler and doc -warnings as errors across both lanes. Together, these variables keep the cache -scope narrow, ensure `sccache` is active for all compilation, and enforce a -warnings-as-errors build contract. +Both lanes share the workflow-level environment contract: `BUILD_PROFILE=debug` +narrows `sccache` keys to debug builds only, preventing cache pollution from +release builds; `CARGO_INCREMENTAL=0` disables incremental compilation, which +is incompatible with `sccache`; `RUSTC_WRAPPER=sccache` routes all `rustc` +invocations through `sccache`; `SCCACHE_GHA_ENABLED=true` activates the GitHub +Actions cache backend for `sccache`; and `RUSTFLAGS=-D warnings` and +`RUSTDOCFLAGS=-D warnings` deny compiler and doc warnings as errors across both +lanes. Together, these variables keep the cache scope narrow, ensure `sccache` +is active for all compilation, and enforce a warnings-as-errors build contract. ### CI build caching diff --git a/docs/roadmap.md b/docs/roadmap.md index e67d05f5..cbdee208 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -231,6 +231,10 @@ pipeline. - [ ] 4.1.3. Enforce lint-level deny rules and fail builds on warnings across the workspace. +- [x] 4.1.4. Add end-to-end behavioural integration tests for `excluded_crates` + in `no_std_fs_operations` using runtime-generated `TempDir` fixture projects + and `cargo dylint` subprocess invocation. Closes + [`#111`](https://github.com/leynos/whitaker/issues/111). ### 4.2. Release metadata @@ -321,8 +325,8 @@ advice. Requires 6.4.1. - [x] 6.4.6. Use Kani to verify `propagate_labels` preserves valid label indices, returns one label per input vector, and terminates within the - supplied iteration bound. Verification harnesses are complete modulo the - CBMC state-explosion blocker documented in the design notes. See + supplied iteration bound. Verification harnesses are complete modulo the CBMC + state-explosion blocker documented in the design notes. See [brain trust lints design](brain-trust-lints-design.md) §Decomposition advice. Requires 6.4.1. From c50686684a48f5aadde2e8682426b9ddd70c241d Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 19 May 2026 13:43:20 +0200 Subject: [PATCH 08/16] Separate exclusion fixture evaluation Route exclusion fixture assertions through the fallible evaluation helper so subprocess execution and diagnostic parsing stay outside the assertion logic. Keep the ignored integration test attributes unchanged while preserving the existing fixture behaviour checks for excluded and non-excluded crates. --- .../tests/integration_exclusion.rs | 30 +++++-------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/crates/no_std_fs_operations/tests/integration_exclusion.rs b/crates/no_std_fs_operations/tests/integration_exclusion.rs index 770775cd..c81cedc0 100644 --- a/crates/no_std_fs_operations/tests/integration_exclusion.rs +++ b/crates/no_std_fs_operations/tests/integration_exclusion.rs @@ -376,11 +376,6 @@ fn exclusion_crates_behaviour_test( } /// Runs `cargo dylint` against the fixture and counts diagnostics. -/// -/// This function is not called directly by `exclusion_crates_behaviour_test` but is exposed -/// as a reusable fallible evaluation primitive for test code that needs to inspect results -/// programmatically. -#[allow(dead_code)] fn evaluate_fixture( fixture_dir: &Path, lint_library_path: &Path, @@ -402,35 +397,24 @@ fn assert_fixture_behaviour( crate_name: &str, expectation: Expectation, ) { - let result = run_cargo_dylint(fixture_dir, lint_library_path) - .unwrap_or_else(|e| panic!("crate `{crate_name}`: failed to run cargo dylint: {e:#}")); - let count = diagnostic_count(&result.stdout).unwrap_or_else(|e| { - panic!( - "crate `{crate_name}` produced malformed cargo output: {e:#}\nstderr:\n{}", - result.stderr - ) - }); + let (is_success, count) = evaluate_fixture(fixture_dir, lint_library_path, crate_name) + .unwrap_or_else(|e| panic!("crate `{crate_name}`: failed to evaluate fixture: {e:#}")); assert!( - result.is_success == expectation.should_succeed, - "crate `{crate_name}` should return success={}, but stderr was:\n{}", - expectation.should_succeed, - result.stderr + is_success == expectation.should_succeed, + "crate `{crate_name}` should return success={}", + expectation.should_succeed ); if expectation.should_emit_diagnostics { assert!( count > 0, - "crate `{crate_name}` should emit `no_std_fs_operations` diagnostics, \ - but stderr was:\n{}", - result.stderr + "crate `{crate_name}` should emit `no_std_fs_operations` diagnostics" ); } else { assert!( count == 0, - "crate `{crate_name}` should emit zero `no_std_fs_operations` diagnostics, \ - but stderr was:\n{}", - result.stderr + "crate `{crate_name}` should emit zero `no_std_fs_operations` diagnostics" ); } } From 138ced463fc3c5fe801ce38f23ea37b2be5f0b80 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 19 May 2026 13:48:42 +0200 Subject: [PATCH 09/16] Propagate exclusion fixture setup errors Move the temporary fixture generator into test support and keep its exposed surface narrow for the integration exclusion tests. Return `anyhow::Result` from the exclusion test driver so setup failures carry context through the test harness instead of panicking via `expect`. --- .../tests/integration_exclusion.rs | 117 +++--------------- .../tests/test_support/mod.rs | 114 +++++++++++++++++ 2 files changed, 129 insertions(+), 102 deletions(-) create mode 100644 crates/no_std_fs_operations/tests/test_support/mod.rs diff --git a/crates/no_std_fs_operations/tests/integration_exclusion.rs b/crates/no_std_fs_operations/tests/integration_exclusion.rs index c81cedc0..fa1e3e6d 100644 --- a/crates/no_std_fs_operations/tests/integration_exclusion.rs +++ b/crates/no_std_fs_operations/tests/integration_exclusion.rs @@ -13,7 +13,6 @@ //! dependencies. Run with `--ignored` to execute. use std::env; -use std::fs; use std::io::Cursor; use std::path::{Path, PathBuf}; use std::process::Command; @@ -24,7 +23,10 @@ use cargo_metadata::{Message, Metadata, MetadataCommand}; use insta::assert_json_snapshot; use rstest::rstest; use serial_test::serial; -use tempfile::TempDir; + +mod test_support; + +use test_support::create_fixture_project; const LINT_CRATE_NAME: &str = "no_std_fs_operations"; @@ -154,100 +156,6 @@ struct CargoDylintResult { stderr: String, } -/// Standalone project fixture created in a temporary directory for integration tests. -struct FixtureProject { - _temp_dir: TempDir, - root: PathBuf, -} - -impl FixtureProject { - fn root(&self) -> &Path { - &self.root - } -} - -/// Creates a temporary fixture project for verifying exclusion behaviour. -fn create_fixture_project(crate_name: &str, is_excluded: bool) -> anyhow::Result { - let temp_dir = TempDir::new().context("failed to create temporary fixture directory")?; - let root = temp_dir.path().to_path_buf(); - - fs::write( - root.join("Cargo.toml"), - format!( - concat!( - "[package]\n", - "name = \"{crate_name}\"\n", - "version = \"0.1.0\"\n", - "edition = \"2024\"\n", - "\n", - "[dependencies]\n", - ), - crate_name = crate_name - ), - ) - .context("failed to write fixture Cargo.toml")?; - - fs::write( - root.join("dylint.toml"), - fixture_dylint_config(crate_name, is_excluded), - ) - .context("failed to write fixture dylint.toml")?; - - let source_dir = root.join("src"); - fs::create_dir(&source_dir).context("failed to create fixture src directory")?; - fs::write(source_dir.join("lib.rs"), fixture_source(crate_name)) - .context("failed to write fixture source")?; - - Ok(FixtureProject { - _temp_dir: temp_dir, - root, - }) -} - -fn fixture_dylint_config(crate_name: &str, is_excluded: bool) -> String { - let excluded_crates = if is_excluded { - format!("[\"{crate_name}\"]") - } else { - "[]".to_owned() - }; - - format!( - concat!( - "[no_std_fs_operations]\n", - "excluded_crates = {excluded_crates}\n", - ), - excluded_crates = excluded_crates - ) -} - -fn fixture_source(crate_name: &str) -> String { - format!( - concat!( - "//! Temporary fixture crate for `no_std_fs_operations` integration tests.\n", - "\n", - "use std::fs::File;\n", - "use std::path::Path;\n", - "\n", - "/// Opens a file for reading.\n", - "///\n", - "/// # Examples\n", - "///\n", - "/// ```no_run\n", - "/// use {crate_name}::open_file;\n", - "///\n", - "/// let file = open_file(\"Cargo.toml\").expect(\"file should exist\");\n", - "/// let result = open_file(\"nonexistent.txt\");\n", - "/// assert!(result.is_err());\n", - "/// # drop(file);\n", - "/// ```\n", - "pub fn open_file>(path: P) -> std::io::Result {{\n", - " File::open(path)\n", - "}}\n", - ), - crate_name = crate_name - ) -} - /// Runs `cargo dylint` on the given fixture project directory. fn run_cargo_dylint(fixture_dir: &Path, library_path: &Path) -> anyhow::Result { let output = Command::new("cargo") @@ -341,11 +249,16 @@ struct Expectation { } /// Shared driver for exclusion integration tests. -fn run_exclusion_test(crate_name: &str, is_excluded: bool, expectation: Expectation) { - let lint_library_path = lint_library_path().expect("failed to build lint library"); - let fixture = - create_fixture_project(crate_name, is_excluded).expect("failed to create fixture project"); +fn run_exclusion_test( + crate_name: &str, + is_excluded: bool, + expectation: Expectation, +) -> anyhow::Result<()> { + let lint_library_path = lint_library_path().context("failed to build lint library")?; + let fixture = create_fixture_project(crate_name, is_excluded) + .context("failed to create fixture project")?; assert_fixture_behaviour(fixture.root(), &lint_library_path, crate_name, expectation); + Ok(()) } #[rstest] @@ -371,8 +284,8 @@ fn exclusion_crates_behaviour_test( #[case] crate_name: &str, #[case] is_excluded: bool, #[case] expected: Expectation, -) { - run_exclusion_test(crate_name, is_excluded, expected); +) -> anyhow::Result<()> { + run_exclusion_test(crate_name, is_excluded, expected) } /// Runs `cargo dylint` against the fixture and counts diagnostics. diff --git a/crates/no_std_fs_operations/tests/test_support/mod.rs b/crates/no_std_fs_operations/tests/test_support/mod.rs new file mode 100644 index 00000000..0b4bad44 --- /dev/null +++ b/crates/no_std_fs_operations/tests/test_support/mod.rs @@ -0,0 +1,114 @@ +//! Shared, test-only fixture helpers for `no_std_fs_operations` integration +//! tests. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::Context as _; +use tempfile::TempDir; + +/// Standalone project fixture created in a temporary directory. +pub(super) struct FixtureProject { + _temp_dir: TempDir, + root: PathBuf, +} + +impl FixtureProject { + /// Returns the fixture project root directory. + pub(super) fn root(&self) -> &Path { + &self.root + } +} + +/// Creates a temporary fixture project for verifying exclusion behaviour. +/// +/// # Examples +/// +/// ```ignore +/// let fixture = create_fixture_project("excluded_test_crate", true)?; +/// assert!(fixture.root().join("dylint.toml").exists()); +/// # Ok::<(), anyhow::Error>(()) +/// ``` +pub(super) fn create_fixture_project( + crate_name: &str, + is_excluded: bool, +) -> anyhow::Result { + let temp_dir = TempDir::new().context("failed to create temporary fixture directory")?; + let root = temp_dir.path().to_path_buf(); + + fs::write( + root.join("Cargo.toml"), + format!( + concat!( + "[package]\n", + "name = \"{crate_name}\"\n", + "version = \"0.1.0\"\n", + "edition = \"2024\"\n", + "\n", + "[dependencies]\n", + ), + crate_name = crate_name + ), + ) + .context("failed to write fixture Cargo.toml")?; + + fs::write( + root.join("dylint.toml"), + fixture_dylint_config(crate_name, is_excluded), + ) + .context("failed to write fixture dylint.toml")?; + + let source_dir = root.join("src"); + fs::create_dir(&source_dir).context("failed to create fixture src directory")?; + fs::write(source_dir.join("lib.rs"), fixture_source(crate_name)) + .context("failed to write fixture source")?; + + Ok(FixtureProject { + _temp_dir: temp_dir, + root, + }) +} + +fn fixture_dylint_config(crate_name: &str, is_excluded: bool) -> String { + let excluded_crates = if is_excluded { + format!("[\"{crate_name}\"]") + } else { + "[]".to_owned() + }; + + format!( + concat!( + "[no_std_fs_operations]\n", + "excluded_crates = {excluded_crates}\n", + ), + excluded_crates = excluded_crates + ) +} + +fn fixture_source(crate_name: &str) -> String { + format!( + concat!( + "//! Temporary fixture crate for `no_std_fs_operations` integration tests.\n", + "\n", + "use std::fs::File;\n", + "use std::path::Path;\n", + "\n", + "/// Opens a file for reading.\n", + "///\n", + "/// # Examples\n", + "///\n", + "/// ```no_run\n", + "/// use {crate_name}::open_file;\n", + "///\n", + "/// let file = open_file(\"Cargo.toml\").expect(\"file should exist\");\n", + "/// let result = open_file(\"nonexistent.txt\");\n", + "/// assert!(result.is_err());\n", + "/// # drop(file);\n", + "/// ```\n", + "pub fn open_file>(path: P) -> std::io::Result {{\n", + " File::open(path)\n", + "}}\n", + ), + crate_name = crate_name + ) +} From 66247e46ccab14ee46782aa236459f14156e3a07 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 19 May 2026 13:54:50 +0200 Subject: [PATCH 10/16] Document lint expectation for cached lint path Replace the blanket Clippy allow on `lint_library_path` with an explicit `expect` that records why the `Result` conversion needs the current shape. --- .../no_std_fs_operations/tests/integration_exclusion.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/no_std_fs_operations/tests/integration_exclusion.rs b/crates/no_std_fs_operations/tests/integration_exclusion.rs index fa1e3e6d..759e8850 100644 --- a/crates/no_std_fs_operations/tests/integration_exclusion.rs +++ b/crates/no_std_fs_operations/tests/integration_exclusion.rs @@ -230,9 +230,11 @@ fn redact_path_prefix(value: serde_json::Value, prefix: &str) -> serde_json::Val } } -// anyhow::Error is not Clone, so .as_ref().map(Clone::clone) is necessary -// to convert &Result into Result. -#[allow(clippy::useless_asref, clippy::redundant_closure)] +#[expect( + clippy::useless_asref, + clippy::redundant_closure, + reason = "anyhow::Error is not Clone, so .as_ref().map(Clone::clone) is necessary to convert &Result into Result" +)] fn lint_library_path() -> anyhow::Result { static LINT_LIBRARY_PATH: OnceLock> = OnceLock::new(); From a5ed3178b8ad1e2bffd3f96b44784be77340fe50 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 19 May 2026 21:52:28 +0200 Subject: [PATCH 11/16] Expose exclusion fixture failures Propagate integration fixture evaluation and snapshot setup errors through `anyhow::Result` instead of converting them into hidden panics. Encode generated fixture TOML values through `toml::Value` so crate names cannot inject manifest or `dylint.toml` keys, and add regression coverage for escaped names. Serialise the ignored exclusion integration binary under nextest's `serial-dylint-ui` group because it builds and stages the lint library. --- .config/nextest.toml | 7 +++ .../tests/integration_exclusion.rs | 23 ++++---- .../tests/test_support/mod.rs | 55 +++++++++++++++++-- 3 files changed, 68 insertions(+), 17 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index 686ee154..b8b78a0c 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -35,6 +35,13 @@ filter = "test(driver::ui::) | test(tests::ui::) | test(ui::ui) | (binary(ui) & test-group = "serial-dylint-ui" retries = { backoff = "exponential", count = 2, delay = "5s" } +[[profile.default.overrides]] +# Serialise ignored exclusion integration tests when they are explicitly run. +# They build and stage the lint library before invoking `cargo dylint`, so they +# share the same target-directory race risk as the UI harnesses above. +filter = "binary(integration_exclusion)" +test-group = "serial-dylint-ui" + # Extend the timeout for toolchain auto-install behavioural tests, since the # isolated rustup setup may need to download and install a full nightly toolchain. [[profile.default.overrides]] diff --git a/crates/no_std_fs_operations/tests/integration_exclusion.rs b/crates/no_std_fs_operations/tests/integration_exclusion.rs index 759e8850..1f93d43c 100644 --- a/crates/no_std_fs_operations/tests/integration_exclusion.rs +++ b/crates/no_std_fs_operations/tests/integration_exclusion.rs @@ -259,8 +259,7 @@ fn run_exclusion_test( let lint_library_path = lint_library_path().context("failed to build lint library")?; let fixture = create_fixture_project(crate_name, is_excluded) .context("failed to create fixture project")?; - assert_fixture_behaviour(fixture.root(), &lint_library_path, crate_name, expectation); - Ok(()) + assert_fixture_behaviour(fixture.root(), &lint_library_path, crate_name, expectation) } #[rstest] @@ -311,9 +310,8 @@ fn assert_fixture_behaviour( lint_library_path: &Path, crate_name: &str, expectation: Expectation, -) { - let (is_success, count) = evaluate_fixture(fixture_dir, lint_library_path, crate_name) - .unwrap_or_else(|e| panic!("crate `{crate_name}`: failed to evaluate fixture: {e:#}")); +) -> anyhow::Result<()> { + let (is_success, count) = evaluate_fixture(fixture_dir, lint_library_path, crate_name)?; assert!( is_success == expectation.should_succeed, @@ -332,6 +330,8 @@ fn assert_fixture_behaviour( "crate `{crate_name}` should emit zero `no_std_fs_operations` diagnostics" ); } + + Ok(()) } /// Snapshot test: verifies the structured JSON diagnostic output emitted by @@ -342,13 +342,13 @@ fn assert_fixture_behaviour( #[test] #[ignore = "requires cargo-dylint and built lint library"] #[serial] -fn non_excluded_crate_diagnostics_match_snapshot() { - let lint_library_path = lint_library_path().expect("failed to build lint library"); +fn non_excluded_crate_diagnostics_match_snapshot() -> anyhow::Result<()> { + let lint_library_path = lint_library_path().context("failed to build lint library")?; let fixture = create_fixture_project("non_excluded_crate_snap", false) - .expect("failed to create fixture project"); + .context("failed to create fixture project")?; - let result = - run_cargo_dylint(fixture.root(), &lint_library_path).expect("failed to run cargo dylint"); + let result = run_cargo_dylint(fixture.root(), &lint_library_path) + .context("failed to run cargo dylint")?; let diagnostics: Vec = Message::parse_stream(Cursor::new(&result.stdout)) .filter_map(Result::ok) @@ -369,7 +369,7 @@ fn non_excluded_crate_diagnostics_match_snapshot() { let prefix = fixture .root() .to_str() - .expect("fixture root should be valid UTF-8"); + .context("fixture root should be valid UTF-8")?; let redacted: Vec = diagnostics .into_iter() @@ -377,4 +377,5 @@ fn non_excluded_crate_diagnostics_match_snapshot() { .collect(); assert_json_snapshot!("non_excluded_crate_diagnostics", redacted); + Ok(()) } diff --git a/crates/no_std_fs_operations/tests/test_support/mod.rs b/crates/no_std_fs_operations/tests/test_support/mod.rs index 0b4bad44..d132d478 100644 --- a/crates/no_std_fs_operations/tests/test_support/mod.rs +++ b/crates/no_std_fs_operations/tests/test_support/mod.rs @@ -41,13 +41,13 @@ pub(super) fn create_fixture_project( format!( concat!( "[package]\n", - "name = \"{crate_name}\"\n", + "name = {crate_name}\n", "version = \"0.1.0\"\n", "edition = \"2024\"\n", "\n", "[dependencies]\n", ), - crate_name = crate_name + crate_name = toml::Value::String(crate_name.to_owned()) ), ) .context("failed to write fixture Cargo.toml")?; @@ -70,11 +70,11 @@ pub(super) fn create_fixture_project( } fn fixture_dylint_config(crate_name: &str, is_excluded: bool) -> String { - let excluded_crates = if is_excluded { - format!("[\"{crate_name}\"]") + let excluded_crates = toml::Value::Array(if is_excluded { + vec![toml::Value::String(crate_name.to_owned())] } else { - "[]".to_owned() - }; + Vec::new() + }); format!( concat!( @@ -112,3 +112,46 @@ fn fixture_source(crate_name: &str) -> String { crate_name = crate_name ) } + +#[cfg(test)] +mod tests { + use super::{create_fixture_project, fixture_dylint_config}; + + #[test] + fn dylint_config_escapes_crate_names_as_toml_values() { + let crate_name = "crate\"]\ninjected = true\n[other"; + let config = fixture_dylint_config(crate_name, true); + let parsed: toml::Value = toml::from_str(&config).expect("config should parse as TOML"); + + assert_eq!( + parsed["no_std_fs_operations"]["excluded_crates"][0] + .as_str() + .expect("excluded crate should be a string"), + crate_name + ); + assert!(parsed.get("other").is_none(), "config was:\n{config}"); + assert!(parsed.get("injected").is_none(), "config was:\n{config}"); + } + + #[test] + fn fixture_manifest_escapes_crate_names_as_toml_values() -> anyhow::Result<()> { + let crate_name = "crate\"]\ninjected = true\n[other"; + let fixture = create_fixture_project(crate_name, true)?; + let manifest = std::fs::read_to_string(fixture.root().join("Cargo.toml"))?; + let parsed: toml::Value = toml::from_str(&manifest)?; + + assert_eq!( + parsed["package"]["name"] + .as_str() + .expect("package name should be a string"), + crate_name + ); + assert!(parsed.get("other").is_none(), "manifest was:\n{manifest}"); + assert!( + parsed.get("injected").is_none(), + "manifest was:\n{manifest}" + ); + + Ok(()) + } +} From 2913e198c8b971913a2d010cc3ef4a0242ca5f24 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 19 May 2026 21:55:30 +0200 Subject: [PATCH 12/16] Surface malformed cargo output in exclusion tests Propagate cargo JSON parse errors while locating the built lint cdylib so malformed build output reports the parsing failure directly. Make the snapshot test fail on malformed cargo output or diagnostic serialisation failures instead of silently dropping bad records. Add an empty `[workspace]` table to generated fixture manifests so each temporary crate is resolved as its own workspace root. --- .../tests/integration_exclusion.rs | 17 ++++++++++++++--- .../tests/test_support/mod.rs | 2 ++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/no_std_fs_operations/tests/integration_exclusion.rs b/crates/no_std_fs_operations/tests/integration_exclusion.rs index 1f93d43c..85472d42 100644 --- a/crates/no_std_fs_operations/tests/integration_exclusion.rs +++ b/crates/no_std_fs_operations/tests/integration_exclusion.rs @@ -125,7 +125,8 @@ fn find_cdylib_in_artifacts( package_id: &cargo_metadata::PackageId, ) -> anyhow::Result { for message in Message::parse_stream(Cursor::new(stdout)) { - let Ok(Message::CompilerArtifact(artifact)) = message else { + let message = message.context("failed to parse cargo build JSON output")?; + let Message::CompilerArtifact(artifact) = message else { continue; }; @@ -351,7 +352,14 @@ fn non_excluded_crate_diagnostics_match_snapshot() -> anyhow::Result<()> { .context("failed to run cargo dylint")?; let diagnostics: Vec = Message::parse_stream(Cursor::new(&result.stdout)) - .filter_map(Result::ok) + .collect::, _>>() + .unwrap_or_else(|e| { + panic!( + "non_excluded_crate_diagnostics_match_snapshot produced malformed cargo output: {e}\nstderr:\n{}", + result.stderr + ) + }) + .into_iter() .filter_map(|message| match message { Message::CompilerMessage(message) if message @@ -360,7 +368,10 @@ fn non_excluded_crate_diagnostics_match_snapshot() -> anyhow::Result<()> { .as_ref() .is_some_and(|code| code.code == LINT_CRATE_NAME) => { - serde_json::to_value(message.message).ok() + Some( + serde_json::to_value(message.message) + .expect("failed to serialise diagnostic for snapshot"), + ) } _ => None, }) diff --git a/crates/no_std_fs_operations/tests/test_support/mod.rs b/crates/no_std_fs_operations/tests/test_support/mod.rs index d132d478..6e22d765 100644 --- a/crates/no_std_fs_operations/tests/test_support/mod.rs +++ b/crates/no_std_fs_operations/tests/test_support/mod.rs @@ -45,6 +45,8 @@ pub(super) fn create_fixture_project( "version = \"0.1.0\"\n", "edition = \"2024\"\n", "\n", + "[workspace]\n", + "\n", "[dependencies]\n", ), crate_name = toml::Value::String(crate_name.to_owned()) From 154fdc4af00bc46caeb85269f32eb1e78be708d8 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 19 May 2026 21:58:40 +0200 Subject: [PATCH 13/16] Clarify snapshot parse failure messages Align the exclusion snapshot test panic paths with the crate-specific fixture name. Keep malformed cargo JSON and diagnostic serialisation failures explicit so the snapshot cannot be built from partial output. --- .../tests/integration_exclusion.rs | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/crates/no_std_fs_operations/tests/integration_exclusion.rs b/crates/no_std_fs_operations/tests/integration_exclusion.rs index 85472d42..a8c5c3e8 100644 --- a/crates/no_std_fs_operations/tests/integration_exclusion.rs +++ b/crates/no_std_fs_operations/tests/integration_exclusion.rs @@ -351,31 +351,31 @@ fn non_excluded_crate_diagnostics_match_snapshot() -> anyhow::Result<()> { let result = run_cargo_dylint(fixture.root(), &lint_library_path) .context("failed to run cargo dylint")?; - let diagnostics: Vec = Message::parse_stream(Cursor::new(&result.stdout)) - .collect::, _>>() - .unwrap_or_else(|e| { - panic!( - "non_excluded_crate_diagnostics_match_snapshot produced malformed cargo output: {e}\nstderr:\n{}", - result.stderr - ) - }) - .into_iter() - .filter_map(|message| match message { - Message::CompilerMessage(message) - if message - .message - .code - .as_ref() - .is_some_and(|code| code.code == LINT_CRATE_NAME) => - { - Some( - serde_json::to_value(message.message) - .expect("failed to serialise diagnostic for snapshot"), + let diagnostics: Vec = + Message::parse_stream(Cursor::new(&result.stdout)) + .collect::, _>>() + .unwrap_or_else(|e| { + panic!( + "non_excluded_crate_snap produced malformed cargo output: {e}\nstderr:\n{}", + result.stderr ) - } - _ => None, - }) - .collect(); + }) + .into_iter() + .filter_map(|message| match message { + Message::CompilerMessage(message) + if message + .message + .code + .as_ref() + .is_some_and(|code| code.code == LINT_CRATE_NAME) => + { + Some(serde_json::to_value(message.message).unwrap_or_else(|e| { + panic!("failed to serialise diagnostic for snapshot: {e}") + })) + } + _ => None, + }) + .collect(); let prefix = fixture .root() From 8c762898b4804bf88f808f2e266eb197b3835602 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 20:23:31 +0200 Subject: [PATCH 14/16] Propagate snapshot diagnostic errors Return cargo JSON parsing and diagnostic serialisation failures through the snapshot test's `anyhow::Result` instead of hiding them behind panic paths. Keep stderr attached to malformed cargo output so failed snapshot runs still carry the subprocess context needed to diagnose the failure. --- .../tests/integration_exclusion.rs | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/crates/no_std_fs_operations/tests/integration_exclusion.rs b/crates/no_std_fs_operations/tests/integration_exclusion.rs index a8c5c3e8..7b4d8c9d 100644 --- a/crates/no_std_fs_operations/tests/integration_exclusion.rs +++ b/crates/no_std_fs_operations/tests/integration_exclusion.rs @@ -351,31 +351,33 @@ fn non_excluded_crate_diagnostics_match_snapshot() -> anyhow::Result<()> { let result = run_cargo_dylint(fixture.root(), &lint_library_path) .context("failed to run cargo dylint")?; - let diagnostics: Vec = - Message::parse_stream(Cursor::new(&result.stdout)) - .collect::, _>>() - .unwrap_or_else(|e| { - panic!( - "non_excluded_crate_snap produced malformed cargo output: {e}\nstderr:\n{}", - result.stderr + let messages = Message::parse_stream(Cursor::new(&result.stdout)) + .collect::, _>>() + .with_context(|| { + format!( + "non_excluded_crate_snap produced malformed cargo output\nstderr:\n{}", + result.stderr + ) + })?; + + let diagnostics: Vec = messages + .into_iter() + .filter_map(|message| match message { + Message::CompilerMessage(message) + if message + .message + .code + .as_ref() + .is_some_and(|code| code.code == LINT_CRATE_NAME) => + { + Some( + serde_json::to_value(message.message) + .context("failed to serialise diagnostic for snapshot"), ) - }) - .into_iter() - .filter_map(|message| match message { - Message::CompilerMessage(message) - if message - .message - .code - .as_ref() - .is_some_and(|code| code.code == LINT_CRATE_NAME) => - { - Some(serde_json::to_value(message.message).unwrap_or_else(|e| { - panic!("failed to serialise diagnostic for snapshot: {e}") - })) - } - _ => None, - }) - .collect(); + } + _ => None, + }) + .collect::>>()?; let prefix = fixture .root() From de6fb39721c6dcdd12f1ddf15e16bb3e58543f27 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 26 May 2026 00:51:23 +0200 Subject: [PATCH 15/16] Restore snapshot malformed-output panics Keep the exclusion snapshot test aligned with the requested review contract: malformed cargo JSON now panics with stderr context, and diagnostic serialisation failure uses the explicit snapshot panic message. --- .../tests/integration_exclusion.rs | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/crates/no_std_fs_operations/tests/integration_exclusion.rs b/crates/no_std_fs_operations/tests/integration_exclusion.rs index 7b4d8c9d..7542d6e6 100644 --- a/crates/no_std_fs_operations/tests/integration_exclusion.rs +++ b/crates/no_std_fs_operations/tests/integration_exclusion.rs @@ -351,17 +351,16 @@ fn non_excluded_crate_diagnostics_match_snapshot() -> anyhow::Result<()> { let result = run_cargo_dylint(fixture.root(), &lint_library_path) .context("failed to run cargo dylint")?; - let messages = Message::parse_stream(Cursor::new(&result.stdout)) - .collect::, _>>() - .with_context(|| { - format!( - "non_excluded_crate_snap produced malformed cargo output\nstderr:\n{}", - result.stderr - ) - })?; - - let diagnostics: Vec = messages - .into_iter() + let diagnostics: Vec = + Message::parse_stream(Cursor::new(&result.stdout)) + .collect::, _>>() + .unwrap_or_else(|e| { + panic!( + "non_excluded_crate_diagnostics_match_snapshot produced malformed cargo output: {e}\nstderr:\n{}", + result.stderr + ) + }) + .into_iter() .filter_map(|message| match message { Message::CompilerMessage(message) if message @@ -372,12 +371,12 @@ fn non_excluded_crate_diagnostics_match_snapshot() -> anyhow::Result<()> { { Some( serde_json::to_value(message.message) - .context("failed to serialise diagnostic for snapshot"), + .expect("failed to serialise diagnostic for snapshot"), ) } _ => None, }) - .collect::>>()?; + .collect(); let prefix = fixture .root() From 1d886d874b1287f0ceae7ea8e01c3f53d229f881 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 26 May 2026 00:54:07 +0200 Subject: [PATCH 16/16] Propagate snapshot parsing failures Return malformed cargo JSON and diagnostic serialisation failures through `anyhow::Result` in the exclusion snapshot test instead of hiding them behind panic paths. Keep stderr attached to the malformed-output context so failed subprocess runs remain diagnosable. --- .../tests/integration_exclusion.rs | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/crates/no_std_fs_operations/tests/integration_exclusion.rs b/crates/no_std_fs_operations/tests/integration_exclusion.rs index 7542d6e6..116a9dba 100644 --- a/crates/no_std_fs_operations/tests/integration_exclusion.rs +++ b/crates/no_std_fs_operations/tests/integration_exclusion.rs @@ -351,16 +351,17 @@ fn non_excluded_crate_diagnostics_match_snapshot() -> anyhow::Result<()> { let result = run_cargo_dylint(fixture.root(), &lint_library_path) .context("failed to run cargo dylint")?; - let diagnostics: Vec = - Message::parse_stream(Cursor::new(&result.stdout)) - .collect::, _>>() - .unwrap_or_else(|e| { - panic!( - "non_excluded_crate_diagnostics_match_snapshot produced malformed cargo output: {e}\nstderr:\n{}", - result.stderr - ) - }) - .into_iter() + let messages = Message::parse_stream(Cursor::new(&result.stdout)) + .collect::, _>>() + .with_context(|| { + format!( + "non_excluded_crate_diagnostics_match_snapshot produced malformed cargo output\nstderr:\n{}", + result.stderr + ) + })?; + + let diagnostics: Vec = messages + .into_iter() .filter_map(|message| match message { Message::CompilerMessage(message) if message @@ -371,12 +372,12 @@ fn non_excluded_crate_diagnostics_match_snapshot() -> anyhow::Result<()> { { Some( serde_json::to_value(message.message) - .expect("failed to serialise diagnostic for snapshot"), + .context("failed to serialise diagnostic for snapshot"), ) } _ => None, }) - .collect(); + .collect::>>()?; let prefix = fixture .root()