name: CI

# Reproduce every job locally: `cargo xtask ci`
# Job → command map: `.claude/rules/ci.md`
# Changing a `run:` here without updating those two is a bug.

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

# Cancel superseded runs on the same ref (rapid PR pushes / force-pushes).
# Always cancel here: CI never signs or publishes artifacts.
concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  CARGO_TERM_COLOR: always
  CARGO_INCREMENTAL: 0
  RUSTFLAGS: "-D warnings"
  RUSTC_WRAPPER: sccache
  SCCACHE_GHA_ENABLED: "true"
  # PR caches are isolated to refs/pull/*/merge and still consume the repo's
  # 10 GB allowance. Let PRs reuse master's compiler objects without filling
  # the cache with entries no other PR can read.
  SCCACHE_GHA_RW_MODE: ${{ (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') && 'READ_WRITE' || 'READ_ONLY' }}

# Cache policy:
# - rust-cache keeps Cargo's registry/git inputs only; caching target/ produced
#   0.8-1.4 GB archives whose transfer often cost more than the cache saved.
# - sccache stores compiler outputs individually, so jobs fetch only objects
#   they need and can share unchanged dependencies across Cargo commands.
# - only master/main writes CI caches; PR-scoped entries cannot warm other PRs.
jobs:
  fmt:
    name: rustfmt
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: rustfmt
      - run: cargo fmt --all -- --check

  typos:
    name: typos
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: crate-ci/typos@8a48f81b6c64dcfea44b3633223084c4be58ac5f # v1.49.0
        with:
          config: .config/typos.toml

  # The single-source-of-truth guards in .ast-grep/rules: each names the module
  # that owns a decision and fails on its ingredients anywhere else.
  ast-grep:
    name: ast-grep
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: ast-grep/action@370e627637505696e4365db4a149af9f2311afe8 # v1.4
        with:
          version: 0.45.1

  publish-closure:
    name: publish closure
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
        with:
          version: "v0.16.0"
      - uses: Swatinem/rust-cache@v2
        with:
          prefix-key: v2-cargo
          shared-key: linux-stable-debug
          cache-targets: false
          save-if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }}
      - run: cargo xtask release check-publish

  # The shell equivalent of `rustfmt` + `clippy`. The file set is every tracked
  # file shfmt recognises as a shell script, so a new script is covered the day
  # it lands — including the extensionless `.agents/*` ones, which it detects by
  # shebang. shfmt's formatting options come from `.editorconfig`; passing it a
  # printer flag here would silently discard that file.
  shell:
    name: shell
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: taiki-e/install-action@v2
        with:
          tool: shellcheck,shfmt
      - name: List tracked shell scripts
        run: git ls-files -z | xargs -0 shfmt -f | tee "${RUNNER_TEMP}/shell-scripts"
      - name: shellcheck
        run: xargs shellcheck < "${RUNNER_TEMP}/shell-scripts"
      - name: shfmt
        run: xargs shfmt -d < "${RUNNER_TEMP}/shell-scripts"

  clippy:
    name: clippy
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy
      - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
        with:
          version: "v0.16.0"
      - uses: Swatinem/rust-cache@v2
        with:
          prefix-key: v2-cargo
          shared-key: linux-stable-debug
          cache-targets: false
          save-if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }}
      - run: |
          sudo apt-get update
          sudo apt-get install -y \
            libudev-dev \
            gcc g++ clang libfontconfig-dev libwayland-dev \
            libxkbcommon-x11-dev libx11-xcb-dev \
            libssl-dev libzstd-dev pkg-config
      - run: cargo clippy --workspace --all-targets -- -D warnings

  # The floor tracks current stable (see `rust-version` in the root Cargo.toml),
  # so what this job buys is keeping that number honest: it fails the moment
  # someone reaches for an API newer than what `cargo install` users are
  # promised, which the floating-stable jobs above cannot notice. Both
  # first-class platforms, because macOS and Linux compile disjoint cfg-gated
  # code (evdev/zbus vs the ObjC surface) and a single runner would miss a
  # too-new stabilization on the other side.
  msrv:
    name: MSRV (cargo check, ${{ matrix.os }})
    runs-on: ${{ matrix.os }}
    env:
      # `rust-toolchain.toml` pins the channel to `stable`, and rustup honours
      # that file over whatever toolchain this job installs — so for its whole
      # life this check silently ran stable and never once verified the floor.
      # RUSTUP_TOOLCHAIN outranks the file; keep it in step with the version
      # installed above and with `rust-version`.
      RUSTUP_TOOLCHAIN: "1.98"
    strategy:
      fail-fast: false
      matrix:
        os: [macos-latest, ubuntu-latest]
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@1.98
      - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
        with:
          version: "v0.16.0"
      - uses: Swatinem/rust-cache@v2
        with:
          prefix-key: v2-cargo
          # Host OS splits Linux vs macOS; the rustc version is already in the key.
          shared-key: msrv-debug
          cache-targets: false
          save-if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }}
      - if: runner.os == 'Linux'
        run: |
          sudo apt-get update
          sudo apt-get install -y \
            libudev-dev \
            gcc g++ clang libfontconfig-dev libwayland-dev \
            libxkbcommon-x11-dev libx11-xcb-dev \
            libssl-dev libzstd-dev pkg-config
      - run: cargo check --workspace --all-targets

  # Stable Linux typecheck is covered by `clippy` above (clippy subsumes
  # `cargo check` for the same --workspace --all-targets set — same rationale
  # as clippy-windows). The declared floor stays on the `msrv` matrix.

  docs:
    name: rustdoc (non-GUI crates)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
        with:
          version: "v0.16.0"
      - uses: Swatinem/rust-cache@v2
        with:
          prefix-key: v2-cargo
          # Share debug deps with clippy/test-linux; RUSTDOCFLAGS is step-scoped
          # below so it does not fork the rust-cache env hash.
          shared-key: linux-stable-debug
          cache-targets: false
          save-if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }}
      - run: |
          sudo apt-get update
          sudo apt-get install -y \
            libudev-dev \
            pkg-config
      # Everything except the GPUI crates, which would drag the whole graphics
      # toolchain into this job. Excluding by name rather than listing the
      # covered crates keeps a new crate documented by default — the hid-only
      # version of this step let five broken links rot in `openlogi-core`.
      - name: cargo doc
        env:
          RUSTDOCFLAGS: "-D warnings"
        run: |
          cargo doc --workspace --no-deps --document-private-items \
            --exclude openlogi-ui \
            --exclude openlogi-desktop \
            --exclude openlogi-overlay \
            --exclude openlogi-agent

  # openlogi-core / openlogi-hid / openlogi-assets / openlogi / openlogi-hook
  # all compile on Linux today (the hook crate has stubs for non-macOS targets).
  # openlogi-desktop needs GPUI's wayland / x11 system libs; the same dependency set
  # the clippy job installs is sufficient. Test workspace there as well so the
  # config-roundtrip and binding suites run on a non-macOS host.
  test-linux:
    name: tests (linux)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
        with:
          version: "v0.16.0"
      - uses: Swatinem/rust-cache@v2
        with:
          prefix-key: v2-cargo
          shared-key: linux-stable-debug
          cache-targets: false
          save-if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }}
      - run: |
          sudo apt-get update
          sudo apt-get install -y \
            libudev-dev \
            gcc g++ clang libfontconfig-dev libwayland-dev \
            libxkbcommon-x11-dev libx11-xcb-dev \
            libssl-dev libzstd-dev pkg-config
      - run: cargo test --workspace --exclude openlogi-desktop

  test-macos:
    name: tests (macos, ${{ matrix.arch }})
    runs-on: ${{ matrix.runner }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - arch: arm64
            runner: macos-latest
          - arch: x86_64
            runner: macos-15-intel
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
        with:
          version: "v0.16.0"
      - uses: Swatinem/rust-cache@v2
        with:
          prefix-key: v2-cargo
          # Host triple already splits arm64 vs x86_64 in the final cache key.
          shared-key: macos-stable-debug
          cache-targets: false
          save-if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }}
      - name: Verify runner architecture
        run: test "$(uname -m)" = "${{ matrix.arch }}"
      - run: cargo test --workspace --all-targets

  # Runs the workspace's tests on Windows — including the `cfg(windows)` ones,
  # which `clippy (windows)` only compiles and no other host can execute. The
  # same shape as the Linux job: everything but the desktop crate.
  test-windows:
    name: tests (windows)
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
        with:
          version: "v0.16.0"
      - uses: Swatinem/rust-cache@v2
        with:
          prefix-key: v2-cargo
          shared-key: windows-stable-debug
          cache-targets: false
          save-if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }}
      - run: cargo test --workspace --exclude openlogi-desktop

  cargo-deny:
    name: cargo-deny
    runs-on: ubuntu-latest
    env:
      # cargo-deny calls `rustc -vV` through cargo metadata but does not compile.
      # This job deliberately skips the sccache setup used by compilation jobs.
      RUSTC_WRAPPER: ""
    steps:
      - uses: actions/checkout@v7
      # Prebuilt binary install is faster than cargo-deny-action's bootstrap path
      # (~100s observed); advisory DB is still fetched by `cargo deny check`.
      - uses: taiki-e/install-action@v2
        with:
          tool: cargo-deny
      # Global flags before subcommand (matches cargo-deny-action defaults).
      # The graph is rooted at the CLI — i.e. exactly the crates published to
      # crates.io. cargo-deny picks its roots from the manifest it is given, so
      # this was implicit while `openlogi` was the workspace root package; with a
      # virtual root it must be explicit, or the git-pinned gpui/zed tree enters
      # the graph and the policy in `.cargo/deny.toml` no longer holds. Widening
      # the scope to the app crates is tracked separately — see that file.
      - run: cargo deny --config .cargo/deny.toml --all-features --manifest-path crates/openlogi/Cargo.toml check

  clippy-windows:
    name: clippy (windows)
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy
      - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
        with:
          version: "v0.16.0"
      - uses: Swatinem/rust-cache@v2
        with:
          prefix-key: v2-cargo
          shared-key: windows-stable-debug
          cache-targets: false
          save-if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }}
      # Whole workspace, matching the Linux jobs — GPUI's DirectX backend
      # covers the GUI on Windows. clippy subsumes `cargo check` and
      # additionally lints the Windows-only code paths (the WH_MOUSE_LL hook,
      # SendInput synthesis, native HID writer, the composite HID++ channel,
      # and the agent's HKCU-Run autostart) that the Linux/macOS clippy jobs
      # never compile.
      - run: cargo clippy --workspace --all-targets -- -D warnings

  wasm:
    name: wasm (portable crates)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: wasm32-unknown-unknown
      - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
        with:
          version: "v0.16.0"
      - uses: Swatinem/rust-cache@v2
        with:
          prefix-key: v2-cargo
          # Only Cargo inputs are cached, so the host's stable registry/git
          # cache is reusable even though rustc emits wasm objects via sccache.
          shared-key: linux-stable-debug
          cache-targets: false
          save-if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }}
      # A portability gate, not a deliverable: nothing here is built for the
      # browser. `wasm32-unknown-unknown` has no OS under it, so a crate that
      # picks up a filesystem, a randomness source or a thread stops compiling
      # here and nowhere else. The crate list lives in xtask
      # (`WASM_PORTABLE_CRATES`) and the drift test keeps the two in step.
      - run: cargo check -p openlogi-device-registry -p openlogi-hidpp -p openlogi-device --target wasm32-unknown-unknown
      # `openlogi-core` earns its place only with `fs` off: that feature is
      # the config file, and a config file needs a filesystem.
      - run: cargo check -p openlogi-core --no-default-features --target wasm32-unknown-unknown