rusty-sponge 0.2.0

Soak up stdin and write it atomically to a file — a Rust port of moreutils `sponge` with strict-compat mode, configurable spill-to-tempfile, and a typed library API.
Documentation
# rusty-sponge CI workflow.
#
# v0.2.0 (E011 Phase 4) expands the v0.1.0 pipeline with the portfolio-wide
# Cargo Features Convention 5-tier matrix per spec 00011 §Per-Port v0.2.0
# CI Matrix + FR-010..FR-014:
#
#   Tier 1 — test-default                (full DDR-003 cross-compile matrix)
#   Tier 2 — test-no-default             (Linux x86_64 only + dep-tree audit)
#   Tier 3 — test-<bundle>               (per preset, Linux only)
#   Tier 4 — check-leaf-<leaf>           (SKIPPED — zero leaves, see below)
#   Tier 5 — lint-convention             (vendored feature-lint, Linux only)
#
# rusty-sponge is a single-capability port per spec 00011 §Scope Edge Cases —
# minimum-convention surface with zero carved leaves. Tier 4 is intentionally
# empty (no `check-leaf-<leaf>` jobs) because there are no leaves to check
# per docs/feature-layout.md §v0.2.0 Carved Leaves.
#
# v0.2.0 is a PURELY ADDITIVE release. All v0.1.x feature names and
# compositions are preserved verbatim; this CI matrix layers the new tiered
# coverage on top without removing any pre-existing gates.
#
# Pre-existing v0.1.0 gates (rustfmt, clippy, audit, MSRV, full cross-compile
# matrix, library-without-binary, publish-dry-run) are preserved unchanged.

name: CI

on:
  push:
    branches: [main]
    tags: ["v[0-9]+.[0-9]+.[0-9]+"]
  pull_request:
    branches: [main]

env:
  CARGO_TERM_COLOR: always
  RUST_BACKTRACE: 1
  # Pinned locale for byte-equality fixture assertions (see
  # tests/common/mod.rs::assert_pinned_env and fixtures/README.md). MUST
  # be inherited by every job that runs `cargo test`.
  LC_ALL: C.UTF-8

permissions:
  contents: read

jobs:
  fmt:
    name: rustfmt
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: rustfmt
      - run: cargo fmt --all -- --check

  clippy:
    name: clippy (deny warnings)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy
      - uses: Swatinem/rust-cache@v2
      - run: cargo clippy --all-targets --all-features -- -D warnings

  audit:
    name: cargo audit
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # Install a stable toolchain so any rustup-aware tool that runs outside
      # the workspace gets a workable Rust. We do NOT cd into the repo before
      # invoking cargo-audit so the repo's `rust-toolchain.toml` (1.85 pin)
      # doesn't engage during the install step.
      - uses: dtolnay/rust-toolchain@stable
      # Prebuilt cargo-audit binary — avoids compiling from source, which
      # would fail under 1.85 due to icu_*/home/time crates bumping MSRV
      # to 1.86+.
      - uses: taiki-e/install-action@v2
        with:
          tool: cargo-audit
      - run: cargo audit
        env:
          # Force stable for this step in case `cargo` is wrapped by rustup
          # and detects the workspace's rust-toolchain.toml.
          RUSTUP_TOOLCHAIN: stable

  msrv:
    name: MSRV (1.85)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@1.85
      - uses: Swatinem/rust-cache@v2
      - run: cargo build --all-features
      - run: cargo test --all-features

  # -----------------------------------------------------------------------------
  # Tier 1 — test-default: full DDR-003 cross-compile matrix on `default`
  # features. `default = ["full"]` post-v0.2.0; for rusty-sponge (single-cap
  # port) `full` resolves to `cli` alone, so the kitchen-sink test covers the
  # full documented surface and matches v0.1.x default coverage byte-for-byte.
  # -----------------------------------------------------------------------------
  test-default:
    name: test-default ${{ matrix.target }}
    needs: [fmt, clippy]
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: ubuntu-latest, target: x86_64-unknown-linux-gnu }
          - { os: ubuntu-latest, target: aarch64-unknown-linux-gnu, cross: true }
          - { os: macos-latest, target: x86_64-apple-darwin }
          - { os: macos-latest, target: aarch64-apple-darwin }
          - { os: windows-latest, target: x86_64-pc-windows-msvc }
    env:
      LC_ALL: C.UTF-8
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}
      # The workspace pins to 1.85 via rust-toolchain.toml. dtolnay added the
      # target to the stable channel, but cargo will switch to 1.85 when run
      # from the workspace — and 1.85 doesn't have the cross-compile target
      # installed. Add it explicitly here.
      - name: Add cross-compile target to 1.85 toolchain
        if: '!matrix.cross'
        run: rustup target add ${{ matrix.target }} --toolchain 1.85
        shell: bash
      - uses: Swatinem/rust-cache@v2
        with:
          key: ${{ matrix.target }}
          # The Windows runner fails the cache-save post-step (tar/zstd over
          # the target dir); build and test themselves pass. Skip saving the
          # cache on Windows so the post-step can't fail the job. Restore is
          # unaffected; non-Windows targets still save normally.
          save-if: ${{ runner.os != 'Windows' }}
      - name: Install cross
        if: matrix.cross
        run: cargo install cross --locked
      - name: Build
        run: |
          if [ "${{ matrix.cross }}" = "true" ]; then
            cross build --release --target ${{ matrix.target }} --all-features
          else
            cargo build --release --target ${{ matrix.target }} --all-features
          fi
        shell: bash
      - name: Test
        if: matrix.cross != true
        run: cargo test --release --target ${{ matrix.target }} --all-features
      - name: Library-without-binary build
        if: matrix.target == 'x86_64-unknown-linux-gnu'
        run: cargo build --no-default-features
      # SC-002 default-install smoke (Linux x86_64 only — the binary is the
      # same on every target). Runs the binary against representative input
      # and asserts the documented capability emits the expected output.
      - name: Default install + capability smoke (SC-002)
        if: matrix.target == 'x86_64-unknown-linux-gnu' && matrix.cross != true
        run: |
          cargo install --path . --no-track --force --locked
          smoke_dir=$(mktemp -d)
          target="$smoke_dir/out.txt"
          printf 'hello\nworld\n' | rusty-sponge "$target"
          if [ ! -f "$target" ]; then
            echo "::error::SC-002 smoke failed — target file not created"
            exit 1
          fi
          if ! diff -u <(printf 'hello\nworld\n') "$target"; then
            echo "::error::SC-002 smoke failed — target content mismatch"
            exit 1
          fi
          echo "SC-002 default-install smoke: PASS"
        shell: bash

  # -----------------------------------------------------------------------------
  # Tier 2 — test-no-default: bare library on Linux x86_64. Verifies CLI-only
  # deps are absent from the library build (SC-001 evidence).
  # -----------------------------------------------------------------------------
  test-no-default:
    name: test-no-default (linux-x86_64)
    needs: [fmt, clippy]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - run: cargo build --no-default-features
      - run: cargo test --no-default-features --lib
      - name: Dep-tree audit (SC-001)
        run: |
          # Assert no CLI-only deps leak into the bare-library build.
          if cargo tree --no-default-features --prefix none --edges normal --no-dedupe \
              | grep -E '^(clap|clap_complete|anyhow|signal-hook)\b'; then
            echo "::error::CLI-only dep leaked into --no-default-features build"
            exit 1
          fi
          echo "SC-001 dep-tree audit: PASS"

  # -----------------------------------------------------------------------------
  # Tier 3 — test-<bundle>: one job per preset bundle. Linux x86_64 only.
  # rusty-sponge declares 2 preset bundles per FR-007 (single-cap minimum).
  # -----------------------------------------------------------------------------
  test-sponge-classic:
    name: test-sponge-classic (linux-x86_64)
    needs: [fmt, clippy]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - run: cargo build --no-default-features --features sponge-classic
      - run: cargo test --no-default-features --features sponge-classic --lib
      # SC-003 per-preset install + size check.
      - name: Per-preset install + size check (SC-003)
        run: |
          cargo install --path . --no-track --force --locked \
            --no-default-features --features sponge-classic
          bundle_size=$(stat -c%s "$(which rusty-sponge)")
          echo "sponge-classic binary size: $bundle_size bytes"
          echo "$bundle_size" > /tmp/sponge-classic-size.txt
          # Baseline sanity: assert the binary is not zero-sized.
          if [ "$bundle_size" -le "0" ]; then
            echo "::error::SC-003 preset install produced empty binary"
            exit 1
          fi
          echo "SC-003 sponge-classic install: PASS"
        shell: bash

  test-sponge-minimal:
    name: test-sponge-minimal (linux-x86_64)
    needs: [fmt, clippy]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - run: cargo build --no-default-features --features sponge-minimal
      - run: cargo test --no-default-features --features sponge-minimal --lib
      # SC-003 per-preset install + size check.
      - name: Per-preset install + size check (SC-003)
        run: |
          cargo install --path . --no-track --force --locked \
            --no-default-features --features sponge-minimal
          bundle_size=$(stat -c%s "$(which rusty-sponge)")
          echo "sponge-minimal binary size: $bundle_size bytes"
          if [ "$bundle_size" -le "0" ]; then
            echo "::error::SC-003 preset install produced empty binary"
            exit 1
          fi
          echo "SC-003 sponge-minimal install: PASS"
        shell: bash

  # -----------------------------------------------------------------------------
  # Tier 4 — check-leaf-<leaf>: SKIPPED for rusty-sponge.
  #
  # rusty-sponge is a single-capability port per spec 00011 §Scope Edge Cases.
  # No leaves are carved beyond the required umbrellas (see
  # docs/feature-layout.md §v0.2.0 Carved Leaves — explicit rejection
  # rationale for each candidate leaf). The feature-lint script's
  # leaf-CI-matrix sub-check passes vacuously (no leaves to enumerate).
  # The `bench` dev-tooling feature is in the lint allowlist and does not
  # require a Tier-4 entry.
  # -----------------------------------------------------------------------------

  # -----------------------------------------------------------------------------
  # SC-004 — keep-list workaround smoke. Single-capability port has a
  # trivial keep-list (just `cli` plus optional `sponge-alias`), but the job
  # exists to validate the documented invocation path.
  # -----------------------------------------------------------------------------
  test-keeplist:
    name: test-keeplist (linux-x86_64)
    needs: [fmt, clippy]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - name: Install with explicit keep-list
        run: |
          cargo install --path . --no-track --force --locked \
            --no-default-features --features cli
          smoke_dir=$(mktemp -d)
          target="$smoke_dir/out.txt"
          printf 'hello\n' | rusty-sponge "$target"
          if [ ! -f "$target" ]; then
            echo "::error::SC-004 keep-list install: target file not created"
            exit 1
          fi
          if ! grep -q '^hello$' "$target"; then
            echo "::error::SC-004 keep-list install: target content unexpected"
            cat "$target"
            exit 1
          fi
          echo "SC-004 keep-list (--features cli) install: PASS"
        shell: bash

  # -----------------------------------------------------------------------------
  # Tier 5 — lint-convention: feature-lint script is vendored into the port
  # (tools/feature-lint/) so the CI does not depend on cross-repo checkout
  # of the (private) umbrella. Each port keeps its own copy in sync with
  # the umbrella source of truth.
  # -----------------------------------------------------------------------------
  lint-convention:
    name: lint-convention (linux-x86_64)
    needs: [fmt, clippy]
    runs-on: ubuntu-latest
    steps:
      - name: Checkout port
        uses: actions/checkout@v4

      - name: Run feature-lint
        env:
          UMBRELLA_PATH: ${{ github.workspace }}
          PORT_PATH: ${{ github.workspace }}
        run: bash tools/feature-lint/run.sh

  # -----------------------------------------------------------------------------
  # Convention-violation self-test (SC-007 evidence). Manually triggered;
  # creates a temp Cargo.toml without `sponge-classic` and asserts the lint
  # script catches the violation. Spec 00011 T056.
  # -----------------------------------------------------------------------------
  convention-lint-self-test:
    name: convention-lint-self-test
    if: github.event_name == 'workflow_dispatch'
    runs-on: ubuntu-latest
    steps:
      - name: Checkout port
        uses: actions/checkout@v4

      - name: Remove sponge-classic umbrella from Cargo.toml
        run: |
          sed -i '/^sponge-classic\s*=/d' Cargo.toml
          # Verify removal
          if grep -E '^sponge-classic\s*=' Cargo.toml; then
            echo "::error::sponge-classic removal failed"
            exit 1
          fi

      - name: Invoke feature-lint and expect non-zero exit
        env:
          UMBRELLA_PATH: ${{ github.workspace }}
          PORT_PATH: ${{ github.workspace }}
        run: |
          set +e
          bash tools/feature-lint/run.sh 2>stderr.log
          rc=$?
          set -e
          cat stderr.log
          if [ "$rc" = "0" ]; then
            echo "::error::feature-lint should have failed with exit 2 but returned 0"
            exit 1
          fi
          if ! grep -q "required umbrella 'sponge-classic' missing" stderr.log; then
            echo "::error::expected diagnostic 'required umbrella sponge-classic missing' not found"
            exit 1
          fi
          echo "convention-lint-self-test: PASS"

  # -----------------------------------------------------------------------------
  # Library-without-binary build (v0.1.x parity gate). Retained verbatim;
  # the Tier 2 test-no-default job covers a superset of this check, but
  # this job runs in parallel + isolation for the publish-dry-run gate.
  # -----------------------------------------------------------------------------
  library-no-default-features:
    name: library-without-binary
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - run: cargo build --no-default-features
      - run: cargo test --no-default-features --lib

  # -----------------------------------------------------------------------------
  # Publish-dry-run + final gate. Runs after every tier above.
  # -----------------------------------------------------------------------------
  publish-dry-run:
    name: cargo publish --dry-run
    needs:
      - fmt
      - clippy
      - audit
      - msrv
      - test-default
      - test-no-default
      - test-sponge-classic
      - test-sponge-minimal
      - test-keeplist
      - lint-convention
      - library-no-default-features
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - run: cargo publish --dry-run --all-features