name: '🚴 Build and Test BikeControl'

on:
  push:
    branches:
      - master
    paths-ignore:
      - '**/*.md'
  pull_request:
    branches:
      - master
    paths-ignore:
      - '**/*.md'
  workflow_dispatch:

env:
  RUST_BACKTRACE: 1
  CARGO_TERM_COLOR: always
  # Incremental state is never reused between CI runs -- pure disk cost.
  CARGO_INCREMENTAL: 0

jobs:
  test:
    name: Workspace tests
    runs-on: linux/amd64
    container:
      image: gitea.tourolle.paris/dtourolle/bikecontrol-builder:latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Cache Rust dependencies
        uses: actions/cache@v3
        with:
          # Registry only -- never `target`. That directory is ~30 GB locally
          # (25G debug, 3.1G android, 2.9G release) and was cached under two
          # keys, on a runner that JellyTau's builds share -- the same leak that
          # filled its disk in August 2026. registry/src is left out too: cargo
          # re-extracts it for free from registry/cache (155 MB of .crate
          # tarballs vs 1.1 GB extracted).
          path: |
            ~/.cargo/registry/index
            ~/.cargo/registry/cache
            ~/.cargo/git/db
          # One shared key across every job. The host/android split existed to
          # stop the two `target` dirs clobbering each other; with target no
          # longer cached, registry contents are target-independent and every
          # job wants the same crates. First job to finish saves; the rest
          # restore.
          key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: |
            ${{ runner.os }}-cargo-registry-

      - name: Cache npm downloads
        uses: actions/cache@v3
        with:
          # npm's download cache, not ui/node_modules: `npm ci` deletes
          # node_modules before it installs, so the old cache was restored and
          # then immediately thrown away. ~/.npm is what actually makes the
          # reinstall fast.
          path: ~/.npm
          key: ${{ runner.os }}-npm-${{ hashFiles('ui/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-npm-

      - name: Install frontend dependencies
        run: npm --prefix ui ci

      # Advisory, not a gate. The tree predates this workflow and `cargo fmt
      # --all` currently rewrites ~2000 lines across 28 files; making that a
      # blocking check would mean landing a repo-wide reformat as a side effect
      # of adding CI. Run `cargo fmt --all` once, in its own commit, then drop
      # the `continue-on-error` below and this becomes a real gate.
      - name: Check formatting (advisory)
        run: cargo fmt --all --check
        continue-on-error: true

      - name: Clippy
        run: cargo clippy --workspace --all-targets -- -D warnings

      - name: Run workspace tests
        run: cargo test --workspace --locked

      - name: Type-check the frontend
        run: npm --prefix ui run check

      - name: Frontend tests
        run: npm --prefix ui test

      - name: Build the frontend
        run: npm --prefix ui run build

  # Per-commit Android compile check.
  #
  # This deliberately does NOT build an APK. The full signed build runs only on
  # tags (build-release.yml) and takes ~15 min; `cargo check` for the Android
  # target is ~1 min and catches everything that actually breaks here — the JNI
  # shim in src-tauri/src/android.rs, btleplug's droidplug backend, and any
  # desktop-only API that has crept into a shared crate (NFR-5).
  android-check:
    name: Android compile check
    runs-on: linux/amd64
    needs: test
    container:
      image: gitea.tourolle.paris/dtourolle/bikecontrol-builder:latest
    env:
      ANDROID_HOME: /opt/android-sdk
      ANDROID_SDK_ROOT: /opt/android-sdk
      NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
      ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Cache Rust dependencies
        uses: actions/cache@v3
        with:
          # Registry only -- never `target`. That directory is ~30 GB locally
          # (25G debug, 3.1G android, 2.9G release) and was cached under two
          # keys, on a runner that JellyTau's builds share -- the same leak that
          # filled its disk in August 2026. registry/src is left out too: cargo
          # re-extracts it for free from registry/cache (155 MB of .crate
          # tarballs vs 1.1 GB extracted).
          path: |
            ~/.cargo/registry/index
            ~/.cargo/registry/cache
            ~/.cargo/git/db
          # One shared key across every job. The host/android split existed to
          # stop the two `target` dirs clobbering each other; with target no
          # longer cached, registry contents are target-independent and every
          # job wants the same crates. First job to finish saves; the rest
          # restore.
          key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: |
            ${{ runner.os }}-cargo-registry-

      # Cheap invariants that a compiler cannot see. Both failures are silent:
      # the app builds, installs, launches, and then finds no trainer.
      - name: Check the Android source layout and the JNI symbols
        run: |
          set -e
          # gen/ is generated and must stay untracked, or a `tauri android init`
          # turns into a confusing diff and the sync script becomes optional.
          if git ls-files --error-unmatch src-tauri/gen >/dev/null 2>&1; then
            echo "❌ src-tauri/gen is tracked. It is generated — untrack it and"
            echo "   keep hand-written sources in src-tauri/android/."
            exit 1
          fi

          KT=src-tauri/android/src/main/java/paris/tourolle/bikecontrol/MainActivity.kt
          [ -f "$KT" ] || { echo "❌ $KT is missing"; exit 1; }

          # JNI symbols are matched by the *runtime*, by name. Rename the
          # Kotlin package, add a native without its Rust half, or drop a Rust
          # export, and nothing fails to compile — the method simply is not
          # there when Java calls it. So compare the two sets, both ways.
          #
          # Sets, not a single pair: there are three natives now, and matching
          # `external fun name()` on empty parens missed the two that take
          # arguments. Parameters do not appear in the symbol name, so stop at
          # the opening paren.
          TMP="${RUNNER_TEMP:-/tmp}"
          PKG=$(sed -n 's/^package \(.*\)$/\1/p' "$KT" | tr -d '\r')
          PREFIX="Java_$(echo "$PKG" | tr '.' '_')_MainActivity_"

          sed -n 's/.*pub extern "system" fn \(Java_[A-Za-z0-9_]*\).*/\1/p' \
            src-tauri/src/android.rs | sort -u > "$TMP/jni-rust"
          sed -n "s/.*external fun \([A-Za-z0-9_]*\)(.*/$PREFIX\1/p" \
            "$KT" | sort -u > "$TMP/jni-kotlin"

          [ -s "$TMP/jni-rust" ] || { echo "❌ No JNI exports found in android.rs"; exit 1; }

          if ! diff -u "$TMP/jni-kotlin" "$TMP/jni-rust" > "$TMP/jni-diff"; then
            echo "❌ JNI symbols differ between MainActivity.kt and android.rs:"
            echo "     -  declared in MainActivity.kt, not exported by android.rs"
            echo "     +  exported by android.rs, not declared in MainActivity.kt"
            tail -n +4 "$TMP/jni-diff"
            exit 1
          fi
          echo "✅ $(wc -l < "$TMP/jni-rust") JNI symbols match $PKG.MainActivity:"
          sed 's/^/     /' "$TMP/jni-rust"

      - name: Cargo check (aarch64-linux-android)
        run: |
          TC="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
          export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TC/aarch64-linux-android24-clang"
          export CC_aarch64_linux_android="$TC/aarch64-linux-android24-clang"
          export AR_aarch64_linux_android="$TC/llvm-ar"
          cargo check -p bikecontrol-app --lib --target aarch64-linux-android --locked

      # btleplug's Android backend is half Java. That half is copied out of the
      # crate sources at the version Cargo.lock pins, so a bump to btleplug that
      # moved or renamed those sources must fail here — loudly, in a one-minute
      # job — rather than in a fifteen-minute release build, or at the first
      # scan on a phone.
      - name: Verify the BLE Java backend can be sourced
        run: |
          set -e
          cargo fetch --target aarch64-linux-android
          for crate in btleplug jni-utils; do
            VER=$(awk -v pkg="name = \"$crate\"" \
              '$0 == pkg { f = 1; next } f && /^version = / { gsub(/[",]/, "", $3); print $3; exit }' Cargo.lock)
            DIR=$(ls -d "${CARGO_HOME:-$HOME/.cargo}"/registry/src/*/"$crate-$VER" 2>/dev/null | head -1)
            [ -n "$DIR" ] || { echo "❌ $crate $VER sources not in the registry"; exit 1; }
            echo "✅ $crate $VER at $DIR"
          done
          test -d "$(ls -d "${CARGO_HOME:-$HOME/.cargo}"/registry/src/*/btleplug-*/src/droidplug/java/src/main/java/com | head -1)"
          echo "✅ droidplug Java sources present"
