diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ab69f2c3..6ad28003d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,13 @@ on: types: [opened, synchronize, reopened] workflow_dispatch: +env: + # Single source of truth for the cargo-nextest pin. `make test` runs the + # non-doctest suite through nextest, so every job that installs it reads + # this value. Declared once at workflow scope so the documented + # AGENTS.md `sed` extraction yields exactly one version. + NEXTEST_VERSION: '0.9.133' + jobs: build-test: runs-on: ubuntu-latest @@ -18,9 +25,6 @@ jobs: # the dated nightly pinned in rust-toolchain.toml. NETSUKE_RUST_TOOLCHAIN: nightly-2026-06-25 WHITAKER_INSTALLER_VERSION: '0.2.7' - # Single source of truth for the cargo-nextest pin. `make test` runs the - # non-doctest suite through nextest, so the job installs it up front. - NEXTEST_VERSION: '0.9.133' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -48,7 +52,6 @@ jobs: uses: leynos/shared-actions/.github/actions/setup-rust@8add2d99854a5b77548eae98cca59202e68fefc8 with: toolchain: ${{ env.NETSUKE_RUST_TOOLCHAIN }} - components: rustfmt, clippy # Preserve warnings-as-errors and Polonius through toolchain setup. rustflags: -D warnings -Zpolonius=next - name: Install cargo-nextest @@ -124,6 +127,98 @@ jobs: access-token: ${{ env.CS_ACCESS_TOKEN }} installer-checksum: ${{ vars.CODESCENE_CLI_SHA256 }} + build-test-windows: + # Gates merges: the `#[cfg(windows)]` tree is compiled, linted, and tested + # under `-D warnings` on this platform (see #518). + runs-on: windows-latest + permissions: + contents: read + env: + CARGO_TERM_COLOR: always + BUILD_PROFILE: debug + # The tree requires -Zpolonius=next (see + # docs/adr-006-adopt-polonius-nightly-toolchain.md), so CI builds with + # the dated nightly pinned in rust-toolchain.toml. + NETSUKE_RUST_TOOLCHAIN: nightly-2026-06-25 + WHITAKER_INSTALLER_VERSION: '0.2.7' + defaults: + run: + # The Makefile uses POSIX shell constructs throughout; Git Bash is + # preinstalled on windows-latest. GNU Make's default recipe shell on + # Windows is cmd.exe, so every make invocation overrides SHELL to bash. + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install GNU Make + run: choco install make --yes --no-progress + - name: Setup Rust + uses: leynos/shared-actions/.github/actions/setup-rust@8add2d99854a5b77548eae98cca59202e68fefc8 + with: + toolchain: ${{ env.NETSUKE_RUST_TOOLCHAIN }} + # Preserve warnings-as-errors and Polonius through toolchain setup. + rustflags: -D warnings -Zpolonius=next + - name: Install Ninja + uses: seanmiddleditch/gha-setup-ninja@3b1f8f94a2f8254bd26914c4ab9474d4f0015f67 # v6 + - name: Install cargo-nextest + uses: taiki-e/install-action@18b1216eba7f8039b0f8d131d5473787f0edce68 # v2.85.3 + with: + tool: nextest@${{ env.NEXTEST_VERSION }} + - name: Show rustc version + run: | + rustup show + rustc --version + cargo --version + - name: Show Ninja version + run: ninja --version + - name: Format + run: make SHELL=bash check-fmt + - name: Lint (Clippy) + # Clippy and `cargo doc` over the whole workspace under `-D warnings`, + # including the `#[cfg(windows)]` arms. + run: make SHELL=bash lint-clippy + - name: Cache Whitaker installer + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/bin/whitaker-installer + ~/.cache/cargo-binstall + key: whitaker-installer-${{ runner.os }}-${{ runner.arch }}-${{ env.WHITAKER_INSTALLER_VERSION }} + - name: Install Whitaker + # Installs and runs on windows-latest (verified in #562); the same + # binstall-with-cargo-install-fallback path as the Linux job. On + # Windows the installer ships `whitaker` as a PowerShell wrapper + # (.ps1), which Git Bash cannot execute, so shim `whitaker` in the + # cargo bin directory (already on PATH) to run the wrapper through + # PowerShell. A failure here blocks the merge. + run: | + if ! command -v whitaker-installer >/dev/null 2>&1; then + if cargo binstall --version >/dev/null 2>&1; then + cargo binstall --no-confirm --locked "whitaker-installer@${WHITAKER_INSTALLER_VERSION}" + else + echo "cargo-binstall unavailable; building whitaker-installer from crates.io" + cargo install --locked whitaker-installer --version "${WHITAKER_INSTALLER_VERSION}" + fi + fi + whitaker-installer + if [ -f "${HOME}/.local/bin/whitaker.ps1" ]; then + printf '%s\n' \ + '#!/bin/bash' \ + 'exec powershell -NoProfile -ExecutionPolicy Bypass -File "${HOME}/.local/bin/whitaker.ps1" "$@"' \ + > "${CARGO_HOME:-$HOME/.cargo}/bin/whitaker" + chmod +x "${CARGO_HOME:-$HOME/.cargo}/bin/whitaker" + fi + - name: Lint (Whitaker) + # Whitaker/Dylint over the workspace under `-D warnings`, including + # the `#[cfg(windows)]` arms. A failure blocks the merge. + run: make SHELL=bash lint-whitaker + - name: Test + # cargo-nextest plus doctests under `-D warnings -Zpolonius=next`, + # compiling and running the `#[cfg(windows)]` test tree. A failure + # blocks the merge. + run: make SHELL=bash test + kani-smoke: if: github.event_name == 'pull_request' runs-on: ubuntu-latest diff --git a/.github/workflows/coverage-main.yml b/.github/workflows/coverage-main.yml index 2e3be88b6..8a74de08b 100644 --- a/.github/workflows/coverage-main.yml +++ b/.github/workflows/coverage-main.yml @@ -29,7 +29,6 @@ jobs: # Match rust-toolchain.toml: the tree needs -Zpolonius=next (see # docs/adr-006-adopt-polonius-nightly-toolchain.md). toolchain: nightly-2026-06-25 - components: rustfmt, clippy # Preserve warnings-as-errors and Polonius through toolchain setup; # cargo-llvm-cov appends its instrumentation flags to this value. rustflags: -D warnings -Zpolonius=next diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index deee43f97..dc9abc6c3 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -17,6 +17,7 @@ "**/target/**", ".terraform/**", ".uv-cache/**", + ".vtcode/**", "CRUSH.md" ] } diff --git a/Cargo.lock b/Cargo.lock index 3f9249bb2..4ebd3bee7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1483,6 +1483,7 @@ dependencies = [ "clap", "clap_mangen", "digest 0.11.3", + "dunce", "fluent-bundle", "glob", "hashbrown 0.17.1", diff --git a/Cargo.toml b/Cargo.toml index f327091b3..b2275dd9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,6 +100,7 @@ minijinja = { version = "2.12.0", features = ["loader"] } cap-primitives = "3.4.4" cap-std = { version = "3.4.4", features = ["fs_utf8"] } camino = "1.2.0" +dunce = "1.0.5" semver = { version = "1", features = ["serde"] } anyhow = "1" indicatif = "0.18.4" @@ -136,6 +137,7 @@ sys-locale = "0.3.2" [build-dependencies] clap = { version = "4.5.0", features = ["derive"] } clap_mangen = "0.3.0" +dunce = "1.0.5" ortho_config = { version = "0.8.0", features = ["serde_json"] } serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 2da36302e..b16eae519 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -368,6 +368,7 @@ Four workflows carry the contract: | Workflow | Job | Shared action | `with.rustflags` | | --- | --- | --- | --- | | [`ci.yml`](../.github/workflows/ci.yml) | `build-test` | `setup-rust` | `-D warnings -Zpolonius=next` | +| [`ci.yml`](../.github/workflows/ci.yml) | `build-test-windows` | `setup-rust` | `-D warnings -Zpolonius=next` | | [`coverage-main.yml`](../.github/workflows/coverage-main.yml) | `coverage-upload` | `setup-rust` | `-D warnings -Zpolonius=next` | | [`netsukefile-test.yml`](../.github/workflows/netsukefile-test.yml) | `netsukefile` | `setup-rust` | `-Zpolonius=next` | | [`build-and-package.yml`](../.github/workflows/build-and-package.yml) | `build` | `rust-build-release` | `-Zpolonius=next` | @@ -2592,6 +2593,14 @@ rules — normalization, the fallback — in the `#[cfg(any(windows, test))]` un tests that the Linux suite executes, and reserve the Windows-gated suite for behaviour that genuinely cannot run elsewhere. +The `build-test-windows` job in `.github/workflows/ci.yml` is a merge gate: it +compiles, lints (Clippy and Whitaker), and tests the `#[cfg(windows)]` suite on +`windows-latest` under `-D warnings`, so a Windows-gated test or lint finding +blocks a merge. The split still stands: host-independent rules stay in the +`#[cfg(any(windows, test))]` unit tests so every host — including a developer +on Unix — exercises them, while the Windows-gated suite covers the behaviour +that only exists there. + #### `PATHEXT` normalization `stdlib::which::env::parse_pathext` turns a raw `PATHEXT` value into lowercase, @@ -2615,6 +2624,17 @@ Composition rules: empty result would mean Windows treats nothing as executable, so `which` would report every command missing. +The widening was reassessed when `build-test-windows` began compiling and +testing the `#[cfg(windows)]` arm directly (#518): the original motivation for +`#[cfg(any(windows, test))]` — reaching the pure string logic from a CI host +that never compiled Windows — is gone, but reverting to `#[cfg(windows)]` +would drop Unix-host coverage of `parse_pathext`'s normalization, +de-duplication, and fallback rules, which `src/stdlib/which/pathext_tests.rs` +pins on every host. There is no equivalent Unix-side test for a Windows-only +function, so the widening stays: the pure string logic is exercised on both +Linux and Windows, and a Windows-gated regression cannot hide from the Unix +suite. + The full normalization contract, which the property tests in `src/stdlib/which/pathext_tests.rs` pin: diff --git a/src/cli/discovery.rs b/src/cli/discovery.rs index 5455335e4..7d2af38f5 100644 --- a/src/cli/discovery.rs +++ b/src/cli/discovery.rs @@ -248,7 +248,7 @@ mod tests { use super::*; use crate::cli::test_support::TestEnv; - use anyhow::ensure; + use anyhow::{Context, ensure}; use cap_std::{ambient_authority, fs::Dir}; use rstest::rstest; use tempfile::tempdir; @@ -309,7 +309,15 @@ mod tests { let env = TestEnv::default().with_var(CONFIG_ENV_VAR, config_path.as_os_str()); let layers = collect_diag_file_layers_with_env(&Cli::default(), &env)?; - let expected_path = config_path.to_string_lossy().into_owned(); + // `load_config_file_as_chain` canonicalises the layer path through the + // same normalizer discovery uses, so compare the injected path in that + // canonical form. On Windows this folds short-name and UNC-prefixed + // spellings into the long-name form the layer records. + let expected_path = + paths::normalized_path_key(&paths::FsPathNormalizer, &config_path.to_string_lossy()) + .context("canonicalise injected config path")? + .to_string_lossy() + .into_owned(); ensure!( layers.iter().any(|layer| layer diff --git a/src/cli/discovery_layer_tests.rs b/src/cli/discovery_layer_tests.rs index e315ce44b..9642004d2 100644 --- a/src/cli/discovery_layer_tests.rs +++ b/src/cli/discovery_layer_tests.rs @@ -119,6 +119,49 @@ fn existing_project_scope_layer_is_not_appended_twice() -> Result<()> { Ok(()) } +/// A project-scope layer is not appended twice when the `--directory` alias +/// resolves to the same physical file through a different spelling. +/// +/// On Windows the same file can be reached through a short-name form +/// (`C:\Users\RUNNER~1\...`) and a long-name form (`C:\Users\runneradmin\...`), +/// and `ortho_config` records the long-name canonical form. A symlink alias on +/// Unix exercises the same shape: the layer path recorded by discovery and the +/// key derived from the alias both canonicalise to the same physical file, so +/// the project-scope pass must not append the layer twice. +#[cfg(unix)] +#[test] +fn project_scope_layer_is_not_appended_twice_via_symlink_alias() -> Result<()> { + let temp = tempdir().context("create temp dir")?; + let project_dir = temp.path().join("project"); + test_support::fs::create_dir(&project_dir).context("create project dir")?; + test_support::fs::write( + project_dir.join(".netsuke.toml"), + "default_targets = [\"alpha\"]\n", + ) + .context("write project config")?; + + // An alternate spelling of `project_dir` that resolves to the same file. + let alias = temp.path().join("project-alias"); + test_support::fs::symlink(&project_dir, &alias).context("create project alias")?; + + let (layers, events) = capture_events(|| collect_file_layers(Some(alias.as_path())))?; + + let project_layers = layers + .iter() + .filter(|layer| { + layer + .path() + .is_some_and(|path| path.as_str().ends_with(".netsuke.toml")) + }) + .count(); + ensure!( + project_layers == 1, + "project-scope layer should appear exactly once, found {project_layers}: {layers:?}" + ); + find_event(&events, "discovery included project-scope layers")?; + Ok(()) +} + /// Normalization failure must not fail configuration discovery. /// /// A missing project `.netsuke.toml` or an unreadable directory makes diff --git a/src/cli/discovery_paths.rs b/src/cli/discovery_paths.rs index dfc9a411d..34605696e 100644 --- a/src/cli/discovery_paths.rs +++ b/src/cli/discovery_paths.rs @@ -18,7 +18,13 @@ pub(super) struct FsPathNormalizer; impl PathNormalizer for FsPathNormalizer { fn normalize(&self, path: &Path) -> io::Result { - std::fs::canonicalize(path) + // `ortho_config` canonicalises layer paths with `dunce` on Windows so + // diagnostics and comparisons stay free of UNC prefixes and short-name + // forms; mirror that here so the project-scope dedup key and the + // injected explicit config path compare equal to the recorded layer + // path. On other platforms `dunce` is a thin wrapper over + // `std::fs::canonicalize`, so the behaviour is unchanged. + dunce::canonicalize(path) } } diff --git a/src/manifest/glob/tests/capability.rs b/src/manifest/glob/tests/capability.rs index 7d808365d..c8ecaa5aa 100644 --- a/src/manifest/glob/tests/capability.rs +++ b/src/manifest/glob/tests/capability.rs @@ -1,8 +1,11 @@ //! Tests for the capability handle the glob metadata checks run through. -use super::super::walk::{literal_dir_prefix, open_root_dir}; +#[cfg(unix)] +use super::super::walk::literal_dir_prefix; +use super::super::walk::open_root_dir; use super::super::{GlobPattern, glob_paths}; use anyhow::{Context, Result, anyhow, ensure}; use camino::{Utf8Path, Utf8PathBuf}; +#[cfg(unix)] use minijinja::ErrorKind; use rstest::{fixture, rstest}; use tempfile::{TempDir, tempdir}; diff --git a/src/manifest/glob/tests/diagnostics.rs b/src/manifest/glob/tests/diagnostics.rs index c95234f1b..1191dbc8f 100644 --- a/src/manifest/glob/tests/diagnostics.rs +++ b/src/manifest/glob/tests/diagnostics.rs @@ -4,7 +4,9 @@ //! subscriber scoped to the call. The recorder and subscriber are both //! thread-local, so no test-wide lock is needed. -use super::super::{MAX_UNREACHABLE_SYMLINK_SAMPLES, expand_glob, glob_paths, record_expansion}; +#[cfg(unix)] +use super::super::MAX_UNREACHABLE_SYMLINK_SAMPLES; +use super::super::{expand_glob, glob_paths, record_expansion}; use anyhow::{Context, Result, ensure}; use metrics::SharedString; use metrics_util::{ diff --git a/src/manifest/glob/tests/expansion.rs b/src/manifest/glob/tests/expansion.rs index bbe5235cd..34309ded4 100644 --- a/src/manifest/glob/tests/expansion.rs +++ b/src/manifest/glob/tests/expansion.rs @@ -1,7 +1,13 @@ //! Tests for the match set [`glob_paths`] returns. +#[cfg(unix)] +use super::super::GlobPattern; +use super::super::glob_paths; +#[cfg(unix)] use super::super::walk::{GlobRoot, process_glob_entry}; -use super::super::{GlobPattern, glob_paths}; -use anyhow::{Context, Result, anyhow, ensure}; +#[cfg(unix)] +use anyhow::{Context, anyhow}; +use anyhow::{Result, ensure}; +#[cfg(unix)] use cap_std::{ambient_authority, fs::Dir}; use minijinja::ErrorKind; use rstest::rstest; diff --git a/src/manifest/glob/validate.rs b/src/manifest/glob/validate.rs index db3b28fec..c66c2794e 100644 --- a/src/manifest/glob/validate.rs +++ b/src/manifest/glob/validate.rs @@ -39,7 +39,11 @@ impl ValidationState { } #[cfg(not(unix))] - fn process_escape(&mut self, _ch: char) -> bool { + #[expect( + clippy::unused_self, + reason = "signature must mirror the Unix arm, which reads self.escaped" + )] + const fn process_escape(&mut self, _ch: char) -> bool { false } diff --git a/src/manifest/glob/walk.rs b/src/manifest/glob/walk.rs index 23d4b41c2..1d7af78dd 100644 --- a/src/manifest/glob/walk.rs +++ b/src/manifest/glob/walk.rs @@ -349,7 +349,7 @@ fn prefix_is_unopenable(err: &io::Error) -> bool { { /// `ERROR_DIRECTORY`: the path is not a directory. const ERROR_DIRECTORY: i32 = 267; - return err.raw_os_error() == Some(ERROR_DIRECTORY); + err.raw_os_error() == Some(ERROR_DIRECTORY) } #[cfg(not(windows))] false diff --git a/src/stdlib/command/quote.rs b/src/stdlib/command/quote.rs index 923770eb9..59f9725bb 100644 --- a/src/stdlib/command/quote.rs +++ b/src/stdlib/command/quote.rs @@ -26,6 +26,10 @@ impl fmt::Display for QuoteError { } } +/// `QuoteError` crosses into `anyhow::Result` in the Windows quoting tests, +/// which requires the `std::error::Error` trait. +impl std::error::Error for QuoteError {} + #[cfg(windows)] pub(super) fn quote(arg: &str) -> Result { if arg.chars().any(|ch| matches!(ch, '\n' | '\r')) { diff --git a/src/stdlib/register.rs b/src/stdlib/register.rs index 588dd1987..887be825c 100644 --- a/src/stdlib/register.rs +++ b/src/stdlib/register.rs @@ -174,7 +174,7 @@ fn is_fifo(ft: fs::FileType) -> bool { } #[cfg(not(unix))] -fn is_fifo(_ft: fs::FileType) -> bool { +const fn is_fifo(_ft: fs::FileType) -> bool { false } @@ -184,7 +184,7 @@ fn is_block_device(ft: fs::FileType) -> bool { } #[cfg(not(unix))] -fn is_block_device(_ft: fs::FileType) -> bool { +const fn is_block_device(_ft: fs::FileType) -> bool { false } @@ -194,7 +194,7 @@ fn is_char_device(ft: fs::FileType) -> bool { } #[cfg(not(unix))] -fn is_char_device(_ft: fs::FileType) -> bool { +const fn is_char_device(_ft: fs::FileType) -> bool { false } @@ -204,6 +204,6 @@ fn is_device(ft: fs::FileType) -> bool { } #[cfg(not(unix))] -fn is_device(_ft: fs::FileType) -> bool { +const fn is_device(_ft: fs::FileType) -> bool { false } diff --git a/src/stdlib/which/env.rs b/src/stdlib/which/env.rs index 6018eac66..36b034c9b 100644 --- a/src/stdlib/which/env.rs +++ b/src/stdlib/which/env.rs @@ -67,6 +67,13 @@ pub(super) struct EnvSnapshot { } impl EnvSnapshot { + /// Capture a snapshot without a `PATHEXT` override. + /// + /// This is the production capture entry on platforms without `PATHEXT` + /// semantics, where `capture_with_pathext` delegates to it. On Windows the + /// production entry is `capture_with_pathext` itself, so here the function + /// survives only for tests, which is why the gate admits `test`. + #[cfg(any(not(windows), test))] pub(super) fn capture( cwd_override: Option<&Utf8Path>, path_override: Option<&OsStr>, @@ -74,6 +81,12 @@ impl EnvSnapshot { Self::capture_with_env(cwd_override, path_override, &DefaultEnv) } + /// Capture with an injected environment provider. + /// + /// See [`Self::capture`] for why this is gated to non-Windows production + /// plus tests: on Windows the production path threads a `PATHEXT` override + /// and reaches `capture_impl` directly, so this chain is test-only there. + #[cfg(any(not(windows), test))] pub(super) fn capture_with_env( cwd_override: Option<&Utf8Path>, path_override: Option<&OsStr>, @@ -88,7 +101,11 @@ impl EnvSnapshot { /// concept of, so the two `capture_impl` arities diverge. Isolating the /// divergence in a pair of wrappers keeps `capture_with_env` free of a /// `cfg`-gated bare `return`, which reads as dead code on either target. - #[cfg(windows)] + /// + /// Reachable only from `capture_with_env`, which is itself test-only on + /// Windows (production enters through `capture_with_pathext`), so this + /// arm is compiled only under `test`. + #[cfg(all(windows, test))] fn capture_for_platform( cwd_override: Option<&Utf8Path>, path_override: Option<&OsStr>, diff --git a/src/stdlib/which/lookup/tests.rs b/src/stdlib/which/lookup/tests.rs index 49c01ea77..1bb2737a9 100644 --- a/src/stdlib/which/lookup/tests.rs +++ b/src/stdlib/which/lookup/tests.rs @@ -230,7 +230,7 @@ fn pathext_without_leading_dots_is_normalised_and_deduplicated( Some(std::ffi::OsStr::new("COM;EXE;EXE; .BAT ;bat")), )?; let mut pathexts = snapshot.pathext().to_vec(); - pathexts.sort_unstable_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); + pathexts.sort_unstable_by_key(|ext| ext.to_lowercase()); let contains_ci = |needle: &str| pathexts.iter().any(|ext| ext.eq_ignore_ascii_case(needle)); @@ -286,7 +286,6 @@ fn direct_path_not_executable_raises_direct_not_found( #[cfg(windows)] #[rstest] fn resolve_direct_appends_pathext(workspace: Result) -> Result<()> { - use crate::stdlib::which::workspace_switch::WorkspaceSwitch; use test_support::exec::make_executable; let env = workspace?; @@ -297,16 +296,14 @@ fn resolve_direct_appends_pathext(workspace: Result) -> Result<() test_fs::create_dir_all(tools_dir.as_std_path()).context("mkdir tools")?; let exe = base.with_extension("bat"); test_fs::write(exe.as_std_path(), b"@echo off\r\n").context("write stub")?; - make_executable(&exe)?; - - let snapshot = EnvSnapshot { - cwd: env.root.clone(), - raw_path: None, - raw_pathext: Some(".bat".into()), - entries: vec![], - pathext: vec![".bat".into()], - workspace_switch: WorkspaceSwitch::Absent, - }; + make_executable(exe.as_std_path())?; + + let snapshot = EnvSnapshot::capture_with_pathext( + Some(env.root()), + None, + Some(std::ffi::OsStr::new(".bat")), + ) + .context("capture env for direct PATHEXT resolution")?; let matches = resolve_direct(".\\tools\\gradlew", &snapshot, &WhichOptions::default())?; diff --git a/src/stdlib/which/lookup/workspace/windows.rs b/src/stdlib/which/lookup/workspace/windows.rs index 9e777e0b2..e26a0809c 100644 --- a/src/stdlib/which/lookup/workspace/windows.rs +++ b/src/stdlib/which/lookup/workspace/windows.rs @@ -23,7 +23,7 @@ struct CollectionState { } impl CollectionState { - fn new(collect_all: bool) -> Self { + const fn new(collect_all: bool) -> Self { Self { matches: Vec::new(), collect_all, @@ -119,11 +119,11 @@ impl WorkspaceMatchContext { if !command_has_ext { let candidates = env::candidate_paths(Utf8Path::new(""), &command_lower, env.pathext()); - for candidate in candidates { - if let Some(name) = Utf8Path::new(candidate.as_str()).file_name() { - basenames.insert(name.to_ascii_lowercase()); - } - } + basenames.extend(candidates.into_iter().filter_map(|candidate| { + Utf8Path::new(candidate.as_str()) + .file_name() + .map(str::to_ascii_lowercase) + })); } Self { diff --git a/test_support/src/check_ninja.rs b/test_support/src/check_ninja.rs index 571a201f1..e4999abdc 100644 --- a/test_support/src/check_ninja.rs +++ b/test_support/src/check_ninja.rs @@ -316,12 +316,20 @@ pub fn fake_ninja_expect_tool_with_jobs( } /// Stub for non-Unix platforms that returns an error. +/// +/// # Errors +/// +/// Always returns an error: this factory is only supported on Unix platforms. #[cfg(not(unix))] pub fn fake_ninja_expect_tool(_expected_tool: ToolName) -> Result<(TempDir, PathBuf)> { anyhow::bail!("fake_ninja_expect_tool is only supported on Unix platforms") } /// Stub for non-Unix platforms that returns an error. +/// +/// # Errors +/// +/// Always returns an error: this factory is only supported on Unix platforms. #[cfg(not(unix))] pub fn fake_ninja_expect_tool_with_jobs( _expected_tool: ToolName, @@ -331,7 +339,7 @@ pub fn fake_ninja_expect_tool_with_jobs( anyhow::bail!("fake_ninja_expect_tool_with_jobs is only supported on Unix platforms") } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { //! Unit coverage for the fake-Ninja factories in this module: verifies the //! generated shell scripts validate `-t`, `-f`, `-j`, and `-C` invocations diff --git a/test_support/src/command_helper.rs b/test_support/src/command_helper.rs index 906da45b5..43fe00adf 100644 --- a/test_support/src/command_helper.rs +++ b/test_support/src/command_helper.rs @@ -199,7 +199,9 @@ fn rust_compiler(env: &impl Env) -> OsString { mod tests { //! Unit tests for compiler selection and helper compilation. - use super::{RustHelperSource, compile_rust_helper_with_env, rust_compiler}; + use super::rust_compiler; + #[cfg(unix)] + use super::{RustHelperSource, compile_rust_helper_with_env}; #[cfg(unix)] use crate::exec::write_exec_with_content; #[cfg(unix)] diff --git a/test_support/src/exec.rs b/test_support/src/exec.rs index d10e2a69e..cc1393ef3 100644 --- a/test_support/src/exec.rs +++ b/test_support/src/exec.rs @@ -87,7 +87,7 @@ pub fn make_executable(path: &Path) -> Result<()> { /// Never returns an error; the fallible signature matches the Unix variant so /// callers need no platform-specific handling. #[cfg(not(unix))] -pub fn make_executable(_path: &Path) -> Result<()> { +pub const fn make_executable(_path: &Path) -> Result<()> { Ok(()) } diff --git a/test_support/src/lib.rs b/test_support/src/lib.rs index 0e82f9765..218d7481c 100644 --- a/test_support/src/lib.rs +++ b/test_support/src/lib.rs @@ -52,7 +52,7 @@ pub use manifest::ensure_manifest_exists; pub use exec::{make_executable, write_exec, write_exec_with_content}; mod error; -#[cfg(test)] +#[cfg(all(test, unix))] mod tracing_capture; use anyhow::{Context, Result}; /// Format an error and its sources (outermost → root) using `Display`, joined diff --git a/tests/bdd/steps/conditional_manifest.rs b/tests/bdd/steps/conditional_manifest.rs index 27e2d8851..8755c30be 100644 --- a/tests/bdd/steps/conditional_manifest.rs +++ b/tests/bdd/steps/conditional_manifest.rs @@ -103,7 +103,11 @@ fn mark_executable(path: &Path) -> Result<()> { } #[cfg(not(unix))] -fn mark_executable(_path: &Path) -> Result<()> { +#[expect( + clippy::unnecessary_wraps, + reason = "the fallible signature must match the Unix variant so the shared call site needs no platform-specific handling" +)] +const fn mark_executable(_path: &Path) -> Result<()> { Ok(()) } diff --git a/tests/bdd/steps/process.rs b/tests/bdd/steps/process.rs index cd0fbd945..18158849d 100644 --- a/tests/bdd/steps/process.rs +++ b/tests/bdd/steps/process.rs @@ -4,18 +4,16 @@ use crate::bdd::fixtures::{RefCellOptionExt, TestWorld}; use anyhow::{Context, Result, anyhow, ensure}; use camino::Utf8Path; use mockable::{DefaultEnv, Env}; +#[cfg(unix)] use netsuke::output_prefs; use netsuke::runner::{self, BuildTargets, CommandEnv, NINJA_PROGRAM}; use rstest_bdd_macros::{given, then, when}; use std::fs; use std::path::{Path, PathBuf}; use tempfile::TempDir; -use test_support::{ - check_ninja::{self, ToolName}, - ensure_manifest_exists, - env::prepend_path_value, - fake_ninja, -}; +#[cfg(unix)] +use test_support::check_ninja::ToolName; +use test_support::{check_ninja, ensure_manifest_exists, env::prepend_path_value, fake_ninja}; // --------------------------------------------------------------------------- // Helper functions @@ -84,6 +82,7 @@ fn prepare_cli_with_directory(world: &TestWorld) -> Result<()> { } /// Prepares the CLI for execution with an absolute file path. +#[cfg(unix)] fn prepare_cli_with_absolute_file(world: &TestWorld) -> Result<()> { prepare_cli_with_directory(world)?; world diff --git a/tests/bdd/steps/progress_output.rs b/tests/bdd/steps/progress_output.rs index c6abd7c21..2ef3317c0 100644 --- a/tests/bdd/steps/progress_output.rs +++ b/tests/bdd/steps/progress_output.rs @@ -26,7 +26,11 @@ fn make_script_executable(path: &Path) -> Result<()> { } #[cfg(not(unix))] -fn make_script_executable(_path: &Path) -> Result<()> { +#[expect( + clippy::unnecessary_wraps, + reason = "the fallible signature must match the Unix variant so the shared call site needs no platform-specific handling" +)] +const fn make_script_executable(_path: &Path) -> Result<()> { Ok(()) } diff --git a/tests/bdd/steps/stdlib/workspace.rs b/tests/bdd/steps/stdlib/workspace.rs index 4d3a4875e..db5a6cb9f 100644 --- a/tests/bdd/steps/stdlib/workspace.rs +++ b/tests/bdd/steps/stdlib/workspace.rs @@ -208,23 +208,25 @@ const fn executable_script() -> &'static [u8] { } } +#[cfg(unix)] fn mark_executable(path: &Utf8Path) -> Result<()> { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(path.as_std_path()) - .with_context(|| format!("stat stdlib executable {path}"))? - .permissions(); - perms.set_mode(0o755); - fs::set_permissions(path.as_std_path(), perms) - .with_context(|| format!("chmod stdlib executable {path}"))?; - Ok(()) - } - #[cfg(not(unix))] - { - let _ = path; - Ok(()) - } + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(path.as_std_path()) + .with_context(|| format!("stat stdlib executable {path}"))? + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(path.as_std_path(), perms) + .with_context(|| format!("chmod stdlib executable {path}"))?; + Ok(()) +} + +#[cfg(not(unix))] +#[expect( + clippy::unnecessary_wraps, + reason = "the fallible signature must match the Unix variant so the shared call site needs no platform-specific handling" +)] +const fn mark_executable(_path: &Utf8Path) -> Result<()> { + Ok(()) } // --------------------------------------------------------------------------- diff --git a/tests/env_path_tests.rs b/tests/env_path_tests.rs index d34d7c118..4a7e66869 100644 --- a/tests/env_path_tests.rs +++ b/tests/env_path_tests.rs @@ -11,7 +11,9 @@ use anyhow::{Context, Result, ensure}; use netsuke::runner::CommandEnv; use proptest::prelude::*; -use rstest::{fixture, rstest}; +#[cfg(unix)] +use rstest::fixture; +use rstest::rstest; use std::{ ffi::{OsStr, OsString}, path::PathBuf, diff --git a/tests/logging_stderr/support.rs b/tests/logging_stderr/support.rs index 84be3730d..6523af99a 100644 --- a/tests/logging_stderr/support.rs +++ b/tests/logging_stderr/support.rs @@ -23,7 +23,11 @@ fn make_script_executable(dir: &Dir, path: &Utf8Path) -> Result<()> { } #[cfg(not(unix))] -fn make_script_executable(_dir: &Dir, _path: &Utf8Path) -> Result<()> { +#[expect( + clippy::unnecessary_wraps, + reason = "the fallible signature must match the Unix variant so the shared call site needs no platform-specific handling" +)] +const fn make_script_executable(_dir: &Dir, _path: &Utf8Path) -> Result<()> { Ok(()) } diff --git a/tests/runner_cases/default_targets.rs b/tests/runner_cases/default_targets.rs index 6b604bc33..a858b8253 100644 --- a/tests/runner_cases/default_targets.rs +++ b/tests/runner_cases/default_targets.rs @@ -1,4 +1,9 @@ //! Unix-only runner tests covering CLI default-target execution. +//! +//! The whole crate is Unix-only: it drives a fake `ninja` shell script and +//! the Unix-only `FakeNinjaFixture`, neither of which exists on Windows. + +#![cfg(unix)] use anyhow::{Context, Result, ensure}; use netsuke::cli::{BuildArgs, Cli, Commands}; diff --git a/tests/runner_tool_subcommands_tests.rs b/tests/runner_tool_subcommands_tests.rs index 874c346ef..0f26327e1 100644 --- a/tests/runner_tool_subcommands_tests.rs +++ b/tests/runner_tool_subcommands_tests.rs @@ -3,6 +3,11 @@ //! Covers the `clean` subcommand which still invokes `ninja -t `. The //! `graph` subcommand renders in-process and is covered by //! `tests/runner_graph_tests.rs`. +//! +//! The whole crate is Unix-only: it drives a fake `ninja` shell script and the +//! Unix-only `check_ninja` factories, neither of which exists on Windows. + +#![cfg(unix)] use anyhow::{Context, Result, bail, ensure}; use netsuke::cli::{Cli, Commands}; diff --git a/tests/std_filter_tests/command_filters/grep_filter_tests.rs b/tests/std_filter_tests/command_filters/grep_filter_tests.rs index ba8c9fc03..618654b26 100644 --- a/tests/std_filter_tests/command_filters/grep_filter_tests.rs +++ b/tests/std_filter_tests/command_filters/grep_filter_tests.rs @@ -1,13 +1,18 @@ //! Grep filter behaviour tests. use anyhow::{Context, Result, bail, ensure}; +#[cfg(not(windows))] use cap_std::{ambient_authority, fs_utf8::Dir}; use minijinja::{ErrorKind, context}; use rstest::rstest; +#[cfg(not(windows))] use test_support::fluent::normalize_fluent_isolates; +#[cfg(not(windows))] use test_support::fs; -use super::{StdlibConfig, fallible, streaming_match_payload}; +use super::fallible; +#[cfg(not(windows))] +use super::{StdlibConfig, streaming_match_payload}; #[cfg(not(windows))] #[rstest] diff --git a/tests/std_filter_tests/command_filters/windows_filter_tests.rs b/tests/std_filter_tests/command_filters/windows_filter_tests.rs index d1159e427..5d716dd42 100644 --- a/tests/std_filter_tests/command_filters/windows_filter_tests.rs +++ b/tests/std_filter_tests/command_filters/windows_filter_tests.rs @@ -9,12 +9,12 @@ use camino::Utf8PathBuf; use cap_std::{ambient_authority, fs_utf8::Dir}; use minijinja::context; use mockable::{DefaultEnv, Env}; -use rstest::{fixture, rstest}; +use rstest::rstest; use std::ffi::OsString; -use std::fs; use tempfile::tempdir; use test_support::command_helper::compile_rust_helper; use test_support::env::prepend_path_value; +use test_support::fs as test_fs; use super::{StdlibConfig, fallible, streaming_match_payload}; @@ -59,6 +59,7 @@ const ARGS_STUB: &str = concat!( ); /// Error context messages for Windows command helper setup. +#[derive(Copy, Clone)] struct WindowsSetupContext { tempdir: &'static str, root: &'static str, @@ -115,14 +116,14 @@ fn grep_on_windows_bypasses_shell() -> Result<()> { )?; let config = StdlibConfig::from_current_dir()?.with_command_path_override(path); - let (mut env, mut state) = fallible::stdlib_env_with_config(config)?; + let (mut env, state) = fallible::stdlib_env_with_config(config)?; state.reset_impure(); fallible::register_template( &mut env, "grep_win", - r#"{{ 'line1 + r"{{ 'line1 line2 -' | grep('^line2') | trim }}"#, +' | grep('^line2') | trim }}", )?; let template = env .get_template("grep_win") @@ -155,7 +156,7 @@ fn grep_streams_large_output_on_windows() -> Result<()> { .with_command_max_output_bytes(512)? .with_command_max_stream_bytes(200_000)? .with_command_path_override(path); - let (mut env, mut state) = fallible::stdlib_env_with_config(config)?; + let (mut env, state) = fallible::stdlib_env_with_config(config)?; state.reset_impure(); fallible::register_template( &mut env, @@ -173,15 +174,15 @@ fn grep_streams_large_output_on_windows() -> Result<()> { state.is_impure(), "grep streaming should mark template impure" ); - let path = camino::Utf8Path::new(rendered.as_str()); - let metadata = fs::metadata(path.as_std_path()) - .with_context(|| format!("stat streamed windows grep output {}", path))?; + let rendered_path = camino::Utf8Path::new(rendered.as_str()); + let rendered_len = test_fs::file_len(rendered_path.as_std_path()) + .with_context(|| format!("stat streamed windows grep output {rendered_path}"))?; ensure!( - metadata.len() >= payload.len() as u64, + rendered_len >= payload.len() as u64, "streamed grep output should retain payload size" ); - let contents = fs::read_to_string(path.as_std_path()) - .with_context(|| format!("read streamed windows grep output {}", path))?; + let contents = test_fs::read_to_string(rendered_path.as_std_path()) + .with_context(|| format!("read streamed windows grep output {rendered_path}"))?; ensure!( contents == payload, "streamed grep file should contain the helper payload" @@ -202,9 +203,9 @@ fn shell_preserves_cmd_meta_characters() -> Result<()> { ARGS_STUB, )?; - let command = format!("\"{}\" \"literal %%^!\"", exe); + let command = format!("\"{exe}\" \"literal %%^!\""); let config = StdlibConfig::from_current_dir()?.with_command_path_override(path); - let (mut env, mut state) = fallible::stdlib_env_with_config(config)?; + let (mut env, state) = fallible::stdlib_env_with_config(config)?; state.reset_impure(); fallible::register_template(&mut env, "shell_meta", "{{ '' | shell(cmd) }}")?; let template = env diff --git a/tests/std_filter_tests/path_filters.rs b/tests/std_filter_tests/path_filters.rs index 73c7ff948..3b09dc525 100644 --- a/tests/std_filter_tests/path_filters.rs +++ b/tests/std_filter_tests/path_filters.rs @@ -4,7 +4,9 @@ //! `with_suffix`, `realpath`, and `expanduser`. Each test validates filter //! behaviour with various inputs and error conditions. -use anyhow::{Context, Result, anyhow, bail, ensure}; +#[cfg(unix)] +use anyhow::anyhow; +use anyhow::{Context, Result, bail, ensure}; use camino::Utf8Path; use cap_std::{ambient_authority, fs_utf8::Dir}; use minijinja::{Environment, ErrorKind}; diff --git a/tests/std_filter_tests/which_filter_common.rs b/tests/std_filter_tests/which_filter_common.rs index 7294176e6..82565d4fc 100644 --- a/tests/std_filter_tests/which_filter_common.rs +++ b/tests/std_filter_tests/which_filter_common.rs @@ -116,7 +116,11 @@ fn mark_executable(path: &Utf8Path) -> Result<()> { } #[cfg(not(unix))] -fn mark_executable(_path: &Utf8Path) -> Result<()> { +#[expect( + clippy::unnecessary_wraps, + reason = "the fallible signature must match the Unix variant so the shared call site needs no platform-specific handling" +)] +const fn mark_executable(_path: &Utf8Path) -> Result<()> { Ok(()) } diff --git a/tests/stdlib_which_pathext_tests.rs b/tests/stdlib_which_pathext_tests.rs index d9590ac96..7dc98fa15 100644 --- a/tests/stdlib_which_pathext_tests.rs +++ b/tests/stdlib_which_pathext_tests.rs @@ -93,10 +93,10 @@ fn assert_which_resolves_to( expected: &Utf8Path, ) -> Result<()> { let rendered = env.render_str(&format!("{{{{ which('{command}') }}}}"), context! {})?; - let expected = rendered_form(expected); + let expected_form = rendered_form(expected); ensure!( - rendered == expected, - "expected which('{command}') to render {expected}, got {rendered}" + rendered == expected_form, + "expected which('{command}') to render {expected_form}, got {rendered}" ); Ok(()) } diff --git a/tests/stdlib_which_tests.rs b/tests/stdlib_which_tests.rs index 2e545fb0e..58b96997e 100644 --- a/tests/stdlib_which_tests.rs +++ b/tests/stdlib_which_tests.rs @@ -98,7 +98,11 @@ fn mark_executable(path: &Utf8Path) -> Result<()> { } #[cfg(not(unix))] -fn mark_executable(_path: &Utf8Path) -> Result<()> { +#[expect( + clippy::unnecessary_wraps, + reason = "the fallible signature must match the Unix variant so the shared call site needs no platform-specific handling" +)] +const fn mark_executable(_path: &Utf8Path) -> Result<()> { Ok(()) } diff --git a/tests/which_diagnostic_snapshot_tests.rs b/tests/which_diagnostic_snapshot_tests.rs index 17866be9a..e7bc7315f 100644 --- a/tests/which_diagnostic_snapshot_tests.rs +++ b/tests/which_diagnostic_snapshot_tests.rs @@ -70,7 +70,11 @@ fn mark_executable(path: &Utf8Path) -> Result<()> { } #[cfg(not(unix))] -fn mark_executable(_path: &Utf8Path) -> Result<()> { +#[expect( + clippy::unnecessary_wraps, + reason = "the fallible signature must match the Unix variant so the shared write_tool call site needs no platform-specific handling" +)] +const fn mark_executable(_path: &Utf8Path) -> Result<()> { Ok(()) } diff --git a/tests/workflow_ci.rs b/tests/workflow_ci.rs index a73e07170..81b1e2cd6 100644 --- a/tests/workflow_ci.rs +++ b/tests/workflow_ci.rs @@ -45,6 +45,16 @@ fn job<'a>(workflow: &'a Value, name: &'static str) -> Result<&'a Mapping> { value_mapping(job_value, name) } +fn workflow_env(workflow: &Value, key: YamlKey) -> Result<&str> { + let root = value_mapping(workflow, "workflow")?; + let env = mapping_get(root, YamlKey("env")) + .context("workflow should define a workflow-level env") + .and_then(|value| value_mapping(value, "workflow-level env"))?; + mapping_get(env, key) + .and_then(Value::as_str) + .with_context(|| format!("workflow-level env should define {}", key.0)) +} + fn steps(job: &Mapping) -> Result<&Vec> { mapping_get(job, YamlKey("steps")) .context("job should define steps") @@ -161,32 +171,39 @@ fn unit_recognizes_exact_versions() { fn behavioural_ci_workflow_installs_pinned_cargo_nextest() -> Result<()> { let contents = workflow_contents("ci.yml").expect("CI workflow should be readable"); let workflow: Value = serde_yaml::from_str(&contents).context("parse CI workflow YAML")?; - let build_test = job(&workflow, "build-test")?; - let version = job_env(build_test, YamlKey("NEXTEST_VERSION")) - .context("build-test job should pin NEXTEST_VERSION")?; + let version = workflow_env(&workflow, YamlKey("NEXTEST_VERSION")) + .context("workflow-level env should pin NEXTEST_VERSION")?; ensure!( is_exact_version(version), "NEXTEST_VERSION should pin an exact version, found {version:?}" ); - let steps = steps(build_test)?; - let install = named_step(steps, "Install cargo-nextest")?; - let uses = mapping_get(install, YamlKey("uses")) - .and_then(Value::as_str) - .context("Install cargo-nextest step should reference an action")?; - ensure!( - is_pinned_action_ref(uses, "taiki-e/install-action"), - "cargo-nextest installer should be pinned to a full commit SHA, found {uses:?}" - ); - ensure!( - step_input(install, YamlKey("tool")) == Some("nextest@${{ env.NEXTEST_VERSION }}"), - "cargo-nextest installer should resolve its pin from NEXTEST_VERSION" - ); - ensure!( - !install.contains_key(Value::String("if".to_owned())), - "cargo-nextest should install on every matrix leg because every leg runs make test" - ); + for job_name in ["build-test", "build-test-windows"] { + let build_test = job(&workflow, job_name)?; + ensure!( + job_env(build_test, YamlKey("NEXTEST_VERSION")).is_none(), + "{job_name} should not duplicate NEXTEST_VERSION at job scope" + ); + + let steps = steps(build_test)?; + let install = named_step(steps, "Install cargo-nextest")?; + let uses = mapping_get(install, YamlKey("uses")) + .and_then(Value::as_str) + .context("Install cargo-nextest step should reference an action")?; + ensure!( + is_pinned_action_ref(uses, "taiki-e/install-action"), + "cargo-nextest installer should be pinned to a full commit SHA, found {uses:?}" + ); + ensure!( + step_input(install, YamlKey("tool")) == Some("nextest@${{ env.NEXTEST_VERSION }}"), + "cargo-nextest installer should resolve its pin from NEXTEST_VERSION" + ); + ensure!( + !install.contains_key(Value::String("if".to_owned())), + "cargo-nextest should install on every matrix leg because every leg runs make test" + ); + } Ok(()) } diff --git a/tests/workflow_contracts/ci_lint_test.py b/tests/workflow_contracts/ci_lint_test.py index 3bb07a873..c06e047bc 100644 --- a/tests/workflow_contracts/ci_lint_test.py +++ b/tests/workflow_contracts/ci_lint_test.py @@ -112,6 +112,43 @@ def _steps(workflow: dict[str, object]) -> list[dict[str, object]]: raise AssertionError("jobs.build-test.steps must be a list") +def _windows_job(workflow: dict[str, object]) -> dict[str, object]: + """Return the build-test-windows job.""" + match workflow.get("jobs"): + case dict() as jobs: + pass + case _: + raise AssertionError("the workflow must declare a jobs mapping") + match jobs.get("build-test-windows"): + case dict() as job: + return job + case _: + raise AssertionError( + "the workflow must declare a build-test-windows job" + ) + + +def _windows_steps(workflow: dict[str, object]) -> list[dict[str, object]]: + """Return the build-test-windows job's steps.""" + match _windows_job(workflow).get("steps"): + case list() as steps: + return steps + case _: + raise AssertionError("jobs.build-test-windows.steps must be a list") + + +def _windows_step(name: str) -> dict[str, object]: + """Return the uniquely named step from the build-test-windows job.""" + matches = [ + step for step in _windows_steps(_load()) if step.get("name") == name + ] + assert len(matches) == 1, ( + f"expected exactly one build-test-windows step named {name!r}, " + f"found {len(matches)}" + ) + return matches[0] + + def _step(name: str) -> dict[str, object]: """Return the uniquely named step from the build-test job.""" matches = [step for step in _steps(_load()) if step.get("name") == name] @@ -197,3 +234,286 @@ def test_makefile_clippy_flags_stay_workspace_wide() -> None: f"every workspace crate, target, and feature with warnings denied; " f"got {match.group(1).strip()!r}" ) + + +def test_nextest_version_declared_once_at_workflow_scope() -> None: + """NEXTEST_VERSION is declared once, at workflow scope. + + AGENTS.md documents a local-install recipe that extracts the pin with: + + sed -n "s/.*NEXTEST_VERSION: '\\(.*\\)'.*/\\1/p" \ + .github/workflows/ci.yml + + A job-scoped duplicate would make that command emit two newline-separated + values, which `cargo install --version "$NEXTEST_VERSION"` rejects. The pin + therefore lives in the workflow-level `env:` block — the only declaration + in the file — and both jobs read it via `${{ env.NEXTEST_VERSION }}`. + """ + text = WORKFLOW_PATH.read_text(encoding="utf-8") + declarations = re.findall(r"^\s*NEXTEST_VERSION:\s*'([^']+)'", text, re.MULTILINE) + assert declarations == ["0.9.133"], ( + "NEXTEST_VERSION must be declared exactly once at workflow scope " + f"with the pinned value, got {declarations!r}" + ) + + workflow = _load() + match workflow.get("env"): + case dict() as env: + pass + case _: + raise AssertionError( + "the workflow must declare a workflow-level env mapping" + ) + assert env.get("NEXTEST_VERSION") == "0.9.133", ( + "NEXTEST_VERSION must be pinned at workflow scope, " + f"got {env.get('NEXTEST_VERSION')!r}" + ) + + for job_name in ("build-test", "build-test-windows"): + match workflow.get("jobs"): + case dict() as jobs: + pass + case _: + raise AssertionError("the workflow must declare a jobs mapping") + match jobs.get(job_name): + case dict() as job: + pass + case _: + raise AssertionError(f"the workflow must declare a {job_name} job") + assert "NEXTEST_VERSION" not in job.get("env", {}), ( + f"{job_name} must not redeclare NEXTEST_VERSION at job scope" + ) + installs = [ + step.get("with", {}).get("tool") + for step in job.get("steps", []) + if "nextest" in str(step.get("with", {}).get("tool", "")) + ] + assert installs == ["nextest@${{ env.NEXTEST_VERSION }}"], ( + f"{job_name} must install nextest via the workflow-scoped " + f"${{{{ env.NEXTEST_VERSION }}}}, got {installs!r}" + ) + + +def test_windows_job_runs_on_windows_latest() -> None: + """The Windows job must actually run on a Windows runner.""" + job = _windows_job(_load()) + assert job.get("runs-on") == "windows-latest", ( + "build-test-windows must run on windows-latest so the " + f"#[cfg(windows)] tree is compiled, got {job.get('runs-on')!r}" + ) + + +def test_windows_job_uses_git_bash_for_recipes() -> None: + """The job runs recipes under Git Bash, not cmd.exe. + + The Makefile uses POSIX shell constructs throughout, and GNU Make's + default recipe shell on Windows is cmd.exe, so the job must default every + run step to bash. + """ + job = _windows_job(_load()) + match job.get("defaults"): + case dict() as defaults: + pass + case _: + raise AssertionError( + "build-test-windows must declare a defaults mapping" + ) + match defaults.get("run"): + case dict() as run: + pass + case _: + raise AssertionError( + "build-test-windows must declare a defaults.run mapping" + ) + assert run.get("shell") == "bash", ( + "build-test-windows must run recipes under Git Bash " + f"(defaults.run.shell: bash), got {run.get('shell')!r}" + ) + + +def test_windows_setup_rust_keeps_warnings_and_polonius() -> None: + """The Windows toolchain setup preserves -D warnings and -Zpolonius=next. + + The `#[cfg(windows)]` tree must be compiled under `-D warnings` to surface + findings, and the tree requires the Polonius analysis, so the shared + setup-rust action must receive both flags through its `rustflags` input. + """ + step = _windows_step("Setup Rust") + assert "setup-rust" in step.get("uses", ""), ( + f"Setup Rust must use the shared setup-rust action, got {step.get('uses')!r}" + ) + match step.get("with"): + case dict() as with_: + pass + case _: + raise AssertionError("Setup Rust must declare a with mapping") + assert with_.get("toolchain") == "${{ env.NETSUKE_RUST_TOOLCHAIN }}", ( + "Setup Rust must use the pinned NETSUKE_RUST_TOOLCHAIN, " + f"got {with_.get('toolchain')!r}" + ) + assert with_.get("rustflags") == "-D warnings -Zpolonius=next", ( + "Setup Rust must pass -D warnings -Zpolonius=next through rustflags " + f"so the #[cfg(windows)] tree compiles under warnings-as-errors, " + f"got {with_.get('rustflags')!r}" + ) + + +def test_setup_rust_does_not_pass_unsupported_components_input() -> None: + """No setup-rust invocation passes the unsupported `components` input. + + The shared `setup-rust` action installs `rustfmt` and `clippy` internally + through `actions-rust-lang/setup-rust-toolchain`; its declared inputs do not + include `components`, so passing one emits an "Unexpected input(s) + 'components'" warning on every run. The contract is that every `Setup Rust` + step uses the shared action and passes only supported inputs, so `check-fmt` + and `lint-clippy` still find the components the action installs. + """ + workflow = _load() + for job_name in ("build-test", "build-test-windows"): + match workflow.get("jobs"): + case dict() as jobs: + pass + case _: + raise AssertionError("the workflow must declare a jobs mapping") + match jobs.get(job_name): + case dict() as job: + pass + case _: + raise AssertionError(f"the workflow must declare a {job_name} job") + setup_steps = [ + step + for step in job.get("steps", []) + if "setup-rust" in str(step.get("uses", "")) + ] + assert setup_steps, f"{job_name} must use the shared setup-rust action" + for step in setup_steps: + match step.get("with"): + case dict() as with_: + pass + case _: + raise AssertionError( + f"{job_name} Setup Rust must declare a with mapping" + ) + assert "components" not in with_, ( + f"{job_name} Setup Rust must not pass the unsupported " + f"'components' input, got {sorted(with_.keys())!r}" + ) + + +def test_windows_job_runs_check_fmt_lint_and_test() -> None: + """The Windows job runs check-fmt, lint, and test as merge gates. + + Every quality gate must run through the Makefile with `SHELL=bash` so the + POSIX-shell recipes execute under Git Bash on the Windows runner. + """ + runs = [step.get("run") for step in _windows_steps(_load())] + expected = [ + "make SHELL=bash check-fmt", + "make SHELL=bash lint-clippy", + "make SHELL=bash lint-whitaker", + "make SHELL=bash test", + ] + for command in expected: + assert command in runs, ( + f"build-test-windows must run {command!r}, got run steps: {runs!r}" + ) + + +def test_windows_job_does_not_duplicate_doc_and_audit_gates() -> None: + """The Windows job excludes platform-independent doc and audit gates. + + `make spelling`, `make markdownlint`, `make nixie`, coverage generation, + the CodeScene gate, and `make test-workflow-contracts` are already covered + on Linux; duplicating them on Windows buys nothing. + """ + runs = [step.get("run") for step in _windows_steps(_load())] + excluded = [ + "make spelling", + "make markdownlint", + "make nixie", + "make test-workflow-contracts", + ] + for command in excluded: + assert command not in runs, ( + f"build-test-windows must not run the platform-independent " + f"{command!r}, got run steps: {runs!r}" + ) + + +def test_windows_job_is_a_blocking_merge_gate() -> None: + """No step in the Windows job is allowed to fail silently. + + A `continue-on-error: true` on the job or any step would let a Windows + lint or test failure pass the merge, defeating the gate. + """ + job = _windows_job(_load()) + assert job.get("continue-on-error") is not True, ( + "build-test-windows must not set continue-on-error on the job" + ) + for step in _windows_steps(_load()): + assert step.get("continue-on-error") is not True, ( + f"build-test-windows step {step.get('name')!r} must not set " + "continue-on-error" + ) + + +def test_coverage_report_is_produced_before_codescene_check() -> None: + """The CodeScene gate consumes the report the coverage step produces. + + `generate-coverage` writes the report to `output-path` (lcov.info) and the + `upload-codescene-coverage` check step defaults to that same file for lcov + format. If the report path, format, or step ordering drifts, CodeScene + reports "No valid coverage report found in the build pipeline". This pins + the wiring: the coverage step runs after `make test` and the CodeScene + check runs after the coverage step, both with `format: lcov`. + """ + workflow = _load() + steps = _steps(workflow) + names = [step.get("name") for step in steps] + + coverage_index = names.index("Test and Measure Coverage") + codescene_index = names.index("Check coverage against CodeScene gates") + test_index = names.index("Test") + assert test_index < coverage_index, ( + "coverage must be measured after the test run so the report reflects " + "the tested tree" + ) + assert coverage_index < codescene_index, ( + "the CodeScene check must run after the coverage step so the report " + "exists in the build pipeline" + ) + + coverage_step = steps[coverage_index] + match coverage_step.get("with"): + case dict() as with_: + pass + case _: + raise AssertionError( + "Test and Measure Coverage must declare a with mapping" + ) + assert with_.get("output-path") == "lcov.info", ( + "coverage must be written to lcov.info, " + f"got {with_.get('output-path')!r}" + ) + assert with_.get("format") == "lcov", ( + "coverage must be measured in lcov format, " + f"got {with_.get('format')!r}" + ) + + codescene_step = steps[codescene_index] + match codescene_step.get("with"): + case dict() as with_: + pass + case _: + raise AssertionError( + "Check coverage against CodeScene gates must declare a with " + "mapping" + ) + assert with_.get("format") == "lcov", ( + "the CodeScene check must consume lcov format, " + f"got {with_.get('format')!r}" + ) + assert with_.get("mode") == "check", ( + "the CodeScene check must run in check mode, " + f"got {with_.get('mode')!r}" + )