structured-email-address 0.0.22

RFC 5321/5322/6531 email address parser, validator, and normalizer. Subaddress extraction, provider-aware normalization, PSL domain validation, anti-homoglyph protection.
Documentation
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  CARGO_TERM_COLOR: always

# Every job below builds Cargo-controlled code — dependency build scripts and
# proc macros — and none of them push, so each checkout sets
# `persist-credentials: false`: the token has no use after that step and is not
# left in .git/config for that code to read.
jobs:
  lint:
    timeout-minutes: 10
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: rustfmt, clippy
      - uses: Swatinem/rust-cache@v2
      - name: Format
        run: cargo fmt --all -- --check
      - name: Clippy (all features)
        run: cargo clippy --all-features -- -D warnings
      - name: Clippy (no default features)
        run: cargo clippy --no-default-features -- -D warnings

  test:
    needs: lint
    timeout-minutes: 15
    strategy:
      fail-fast: true
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - uses: dtolnay/rust-toolchain@stable
      - uses: taiki-e/install-action@nextest
      - uses: Swatinem/rust-cache@v2
        with:
          prefix-key: ${{ runner.os }}-cargo
      - name: Resolve the newest published PSL data
        # The crate accepts any structured-public-domains 0.x, so the committed
        # lockfile is a starting point rather than a contract, and resolving it
        # fresh tests what a consumer building today actually gets rather than
        # what was pinned months ago. This job runs on pushes and pull requests,
        # so a data release published between two runs reaches consumers before
        # it is seen here; both crates are maintained together, and the parser
        # holds no opinion about which suffixes the list contains, so that gap is
        # accepted rather than papered over with a scheduled run.
        # The msrv job deliberately keeps the lockfile, so the toolchain floor
        # stays a property of this repository and not of the newest upstream
        # release.
        run: cargo update -p structured-public-domains
      - name: Test (all features)
        run: cargo nextest run --profile ci --all-features
      - name: Test (no default features)
        run: cargo nextest run --profile ci --no-default-features
      - name: Doc tests
        run: cargo test --doc --all-features

  no-std:
    needs: lint
    timeout-minutes: 10
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - uses: dtolnay/rust-toolchain@stable
        with:
          # Bare-metal targets, which is the point: a host check cannot fail on
          # a target constraint the host does not have. `std` is present there,
          # so code that reaches for it still compiles; thumbv6m additionally
          # has no compare-and-swap, which catches a dependency reaching for
          # pointer-width atomics.
          targets: thumbv7em-none-eabihf, thumbv6m-none-eabi
      - uses: Swatinem/rust-cache@v2
        with:
          prefix-key: no-std
      - name: Check (no_std + alloc)
        run: cargo check --target thumbv7em-none-eabihf --no-default-features --features alloc
      # Every advertised combination, not just the smallest one: a feature that
      # compiles alone can still fail beside another (serde needs its own `alloc`
      # to have `String` impls at all).
      - name: Check (no_std + alloc + serde)
        run: cargo check --target thumbv7em-none-eabihf --no-default-features --features alloc,serde
      - name: Check (no_std + alloc, no compare-and-swap)
        run: cargo check --target thumbv6m-none-eabi --no-default-features --features alloc
      - name: Clippy (no_std + alloc)
        run: cargo clippy --target thumbv7em-none-eabihf --no-default-features --features alloc -- -D warnings

  cross-i686:
    needs: lint
    timeout-minutes: 15
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: i686-unknown-linux-gnu
      - uses: taiki-e/install-action@nextest
      - name: Install 32-bit libs
        run: sudo apt-get update && sudo apt-get install -y gcc-multilib
      - uses: Swatinem/rust-cache@v2
        with:
          prefix-key: cross-i686
      - name: Test (i686)
        run: cargo nextest run --profile ci --all-features --target i686-unknown-linux-gnu

  msrv:
    needs: lint
    timeout-minutes: 15
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - uses: dtolnay/rust-toolchain@1.92
      - uses: taiki-e/install-action@nextest
      - uses: Swatinem/rust-cache@v2
      - name: Test (all features)
        run: cargo nextest run --profile ci --all-features
      - name: Test (no default features)
        run: cargo nextest run --profile ci --no-default-features

  codecov:
    needs: lint
    timeout-minutes: 15
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - uses: dtolnay/rust-toolchain@nightly
        with:
          components: llvm-tools-preview
      - uses: Swatinem/rust-cache@v2
      - uses: taiki-e/install-action@cargo-llvm-cov
      - run: cargo llvm-cov --all-features --lcov --output-path lcov.info
      - uses: codecov/codecov-action@v7
        with:
          files: lcov.info
        env:
          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

  npm:
    needs: lint
    timeout-minutes: 20
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
        with:
          toolchain: stable
          targets: wasm32-unknown-unknown
      - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
        with:
          prefix-key: npm
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: "22"
      - name: Install the WebAssembly toolchain
        run: ./npm/scripts/install-wasm-tools.sh
      - name: Install deps
        working-directory: npm
        run: npm ci
      # Built first: the default package imports both backends by name, and
      # their exports resolve to dist. Each build generates what it reads (the
      # WebAssembly module, the confusables table) from the Rust sources;
      # nothing generated is committed.
      - name: Build (ESM + CJS + dts)
        working-directory: npm
        run: npm run build
      - name: Typecheck
        working-directory: npm
        run: npm run typecheck --workspaces
      - name: Unit tests
        working-directory: npm
        run: npm run test --workspaces
      - name: Smoke-test both module formats
        working-directory: npm
        run: |
          node --input-type=module -e "import { init, parse } from './default/dist/index.js'; if (await init() !== 'wasm' || parse('a@Example.COM').domain !== 'example.com') process.exit(1)"
          node --input-type=commonjs -e "const { init, parse } = require('./default/dist/index.cjs'); init({ backend: 'ts' }).then((b) => { if (b !== 'ts' || parse('a@Example.COM').domain !== 'example.com') process.exit(1) })"
      # A consumer may install one backend only; the default package's
      # declarations must then still resolve, so they may name neither.
      - name: Declarations name no optional backend
        working-directory: npm
        run: |
          for f in default/dist/index.d.ts default/dist/index.d.cts; do
            if grep -q 'structured-email-address-\(ts\|wasm\)' "$f"; then echo "$f imports an optional backend"; exit 1; fi
          done
      # Node consumers often have no DOM library and check every declaration.
      - name: Declarations compile without the DOM library
        working-directory: npm
        run: npx tsc -p consumer-check
      - name: Parity with the Rust crate
        run: |
          cargo build --release --locked -p structured-email-address-wasm --example parity
          node npm/scripts/parity-cases.mjs > "$RUNNER_TEMP/cases.jsonl"
          ./target/release/examples/parity < "$RUNNER_TEMP/cases.jsonl" > "$RUNNER_TEMP/rust.out"
          node npm/scripts/parity.mjs wasm < "$RUNNER_TEMP/cases.jsonl" > "$RUNNER_TEMP/wasm.out"
          node npm/scripts/parity.mjs ts < "$RUNNER_TEMP/cases.jsonl" > "$RUNNER_TEMP/ts.out"
          diff "$RUNNER_TEMP/rust.out" "$RUNNER_TEMP/wasm.out"
          diff "$RUNNER_TEMP/rust.out" "$RUNNER_TEMP/ts.out"

  bench:
    needs: lint
    timeout-minutes: 15
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
        with:
          prefix-key: bench
      - name: Run benchmarks
        run: cargo bench --all-features -- --output-format bencher | tee bench-output.txt
      - name: Upload benchmark results
        uses: actions/upload-artifact@v7
        with:
          name: benchmark-results
          path: |
            bench-output.txt
            target/criterion/