ascom-alpaca-core 0.2.3

Framework-agnostic ASCOM Alpaca protocol types and traits for Rust — all 10 device types, no HTTP framework required
Documentation
name: CI

on:
  push:
  pull_request:

# Cancel duplicate runs: when a PR exists, the push event is redundant.
# Use head_ref (branch name) for PRs, ref for pushes — same branch collapses to one run.
concurrency:
  group: ci-${{ github.head_ref || github.ref }}
  cancel-in-progress: true

env:
  CARGO_TERM_COLOR: always

jobs:
  pr-title:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: amannn/action-semantic-pull-request@v6
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - run: cargo test --all-features
      - run: cargo test --no-default-features
      - name: Verify publishable
        run: cargo publish --dry-run

  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy, rustfmt
      - uses: Swatinem/rust-cache@v2
      - run: cargo clippy --all-features -- -D warnings
      - run: cargo fmt --check

  check-features:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - run: cargo check --no-default-features
      - run: cargo check --no-default-features --features safety_monitor
      - run: cargo check --no-default-features --features camera
      - run: cargo check --no-default-features --features telescope
      - run: cargo check --no-default-features --features dome

  changes:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    outputs:
      rust: ${{ steps.filter.outputs.rust }}
    steps:
      - uses: actions/checkout@v6
      - uses: dorny/paths-filter@v4
        id: filter
        with:
          filters: |
            rust:
              - 'src/**'
              - 'Cargo.toml'
              - 'Cargo.lock'

  semver-check:
    needs: changes
    if: github.event_name == 'pull_request' && needs.changes.outputs.rust == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: obi1kenobi/cargo-semver-checks-action@v2

  validate-specs:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 20
      - run: npx @scalar/cli document validate tests/fixtures/AlpacaDeviceAPI_v1.yaml
      - run: npx @scalar/cli document validate tests/fixtures/AlpacaManagementAPI_v1.yaml

  # Determine which ConformU device tests to run based on changed files
  conformu-filter:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    outputs:
      devices: ${{ steps.filter.outputs.devices }}
      any_changed: ${{ steps.filter.outputs.any_changed }}
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0

      - name: Determine affected devices
        id: filter
        env:
          EVENT_NAME: ${{ github.event_name }}
          EVENT_BEFORE: ${{ github.event.before }}
          BASE_REF: ${{ github.base_ref }}
        run: |
          if [ "$EVENT_NAME" = "push" ]; then
            BASE="$EVENT_BEFORE"
          else
            # Ensure base ref is available for comparison
            git fetch origin "$BASE_REF" --depth=1 2>/dev/null || true
            BASE="origin/$BASE_REF"
          fi

          # Get changed files (fallback to full run if diff fails or returns empty)
          CHANGED=$(git diff --name-only "$BASE" HEAD 2>/dev/null) || CHANGED="FULL_RUN"
          if [ -z "$CHANGED" ]; then
            CHANGED="FULL_RUN"
          fi
          echo "Changed files: $CHANGED"

          # Shared files that affect all devices
          SHARED_PATTERNS="src/types/|src/device/|src/registry/|src/discovery/|src/management/|src/lib.rs|src/conformu/dispatch.rs|src/conformu/management.rs"

          # Each matrix entry: {"device": "type/num", "settings": "file", "name": "label"}
          # Telescope is split into core (slews, tracking) and pier (SideOfPier + 7-min meridian wait)
          ALL_ENTRIES='[{"device":"safetymonitor/0","settings":"base","name":"safetymonitor-0"},{"device":"camera/0","settings":"base","name":"camera-0"},{"device":"camera/1","settings":"base","name":"camera-1"},{"device":"switch/0","settings":"base","name":"switch-0"},{"device":"covercalibrator/0","settings":"base","name":"covercalibrator-0"},{"device":"dome/0","settings":"base","name":"dome-0"},{"device":"filterwheel/0","settings":"base","name":"filterwheel-0"},{"device":"focuser/0","settings":"base","name":"focuser-0"},{"device":"observingconditions/0","settings":"base","name":"observingconditions-0"},{"device":"rotator/0","settings":"base","name":"rotator-0"},{"device":"telescope/0","settings":"telescope-core","name":"telescope-core"},{"device":"telescope/0","settings":"telescope-pier","name":"telescope-pier"}]'

          if echo "$CHANGED" | grep -qE "$SHARED_PATTERNS" || echo "$CHANGED" | grep -q "FULL_RUN"; then
            echo "devices=$ALL_ENTRIES" >> "$GITHUB_OUTPUT"
            echo "any_changed=true" >> "$GITHUB_OUTPUT"
            echo "Shared files changed — testing all devices"
            exit 0
          fi

          # Map file paths to device entries
          ENTRIES=""
          SEEN=""

          add_entry() {
            local pattern="$1" device="$2" settings="$3" name="$4"
            if echo "$CHANGED" | grep -q "$pattern"; then
              if ! echo "$SEEN" | grep -q "$name"; then
                [ -n "$ENTRIES" ] && ENTRIES="${ENTRIES},"
                ENTRIES="${ENTRIES}{\"device\":\"${device}\",\"settings\":\"${settings}\",\"name\":\"${name}\"}"
                SEEN="${SEEN} ${name}"
              fi
            fi
          }

          add_entry "src/safety_monitor/" "safetymonitor/0" "base" "safetymonitor-0"
          add_entry "src/conformu/mocks/safety_monitor" "safetymonitor/0" "base" "safetymonitor-0"
          add_entry "src/camera/" "camera/0" "base" "camera-0"
          add_entry "src/conformu/mocks/camera" "camera/0" "base" "camera-0"
          add_entry "src/switch/" "switch/0" "base" "switch-0"
          add_entry "src/conformu/mocks/switch" "switch/0" "base" "switch-0"
          add_entry "src/cover_calibrator/" "covercalibrator/0" "base" "covercalibrator-0"
          add_entry "src/conformu/mocks/cover_calibrator" "covercalibrator/0" "base" "covercalibrator-0"
          add_entry "src/dome/" "dome/0" "base" "dome-0"
          add_entry "src/conformu/mocks/dome" "dome/0" "base" "dome-0"
          add_entry "src/filter_wheel/" "filterwheel/0" "base" "filterwheel-0"
          add_entry "src/conformu/mocks/filter_wheel" "filterwheel/0" "base" "filterwheel-0"
          add_entry "src/focuser/" "focuser/0" "base" "focuser-0"
          add_entry "src/conformu/mocks/focuser" "focuser/0" "base" "focuser-0"
          add_entry "src/observing_conditions/" "observingconditions/0" "base" "observingconditions-0"
          add_entry "src/conformu/mocks/observing_conditions" "observingconditions/0" "base" "observingconditions-0"
          add_entry "src/rotator/" "rotator/0" "base" "rotator-0"
          add_entry "src/conformu/mocks/rotator" "rotator/0" "base" "rotator-0"
          # Telescope changes trigger both core and pier jobs
          add_entry "src/telescope/" "telescope/0" "telescope-core" "telescope-core"
          add_entry "src/conformu/mocks/telescope" "telescope/0" "telescope-core" "telescope-core"
          add_entry "src/telescope/" "telescope/0" "telescope-pier" "telescope-pier"
          add_entry "src/conformu/mocks/telescope" "telescope/0" "telescope-pier" "telescope-pier"

          # Camera changes also need color camera test
          if echo "$SEEN" | grep -q "camera-0"; then
            [ -n "$ENTRIES" ] && ENTRIES="${ENTRIES},"
            ENTRIES="${ENTRIES}{\"device\":\"camera/1\",\"settings\":\"base\",\"name\":\"camera-1\"}"
          fi

          if [ -z "$ENTRIES" ]; then
            echo "any_changed=false" >> "$GITHUB_OUTPUT"
            echo "devices=[]" >> "$GITHUB_OUTPUT"
            echo "No device files changed — skipping ConformU"
          else
            echo "any_changed=true" >> "$GITHUB_OUTPUT"
            echo "devices=[${ENTRIES}]" >> "$GITHUB_OUTPUT"
            echo "Devices to test: [${ENTRIES}]"
          fi

  conformu:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    needs: [conformu-filter]
    if: needs.conformu-filter.outputs.any_changed == 'true'
    strategy:
      fail-fast: false
      matrix:
        include: ${{ fromJson(needs.conformu-filter.outputs.devices) }}
    env:
      CONFORMU_VERSION: "4.2.1"
    steps:
      - uses: actions/checkout@v6
      - uses: dtolnay/rust-toolchain@stable

      - name: Cache cargo build
        uses: actions/cache@v5
        with:
          path: |
            target
            ~/.cargo/registry
            ~/.cargo/git
          key: conformu-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}

      - name: Cache ConformU
        id: conformu-cache
        uses: actions/cache@v5
        with:
          path: conformu
          key: conformu-v${{ env.CONFORMU_VERSION }}-linux-x64

      - name: Download ConformU
        if: steps.conformu-cache.outputs.cache-hit != 'true'
        run: |
          curl -sL "https://github.com/ASCOMInitiative/ConformU/releases/download/v${CONFORMU_VERSION}/conformu.linux-x64.tar.xz" \
            -o conformu.tar.xz
          tar xJf conformu.tar.xz
          chmod +x conformu
          rm conformu.tar.xz

      - name: Add ConformU to PATH
        run: echo "$PWD" >> "$GITHUB_PATH"

      - name: Build and start test harness
        run: |
          cargo build --example conformu_harness --features conformu --release
          cargo run --example conformu_harness --features conformu --release &
          sleep 3
          curl -sf http://127.0.0.1:32888/management/apiversions || (echo "Harness failed to start" && exit 1)

      - name: Prepare settings
        env:
          DEVICE: ${{ matrix.device }}
          SETTINGS: ${{ matrix.settings }}
        run: |
          DEVICE_TYPE=$(echo "$DEVICE" | cut -d/ -f1)
          DEVICE_NUM=$(echo "$DEVICE" | cut -d/ -f2)
          SETTINGS_FILE=".github/conformu-settings/${SETTINGS}.json"

          # Inject AlpacaDevice and DeviceTechnology into settings for conformance-settings command
          python3 - "$SETTINGS_FILE" "$DEVICE_TYPE" "$DEVICE_NUM" <<'PYEOF'
          import json, sys
          settings_file, device_type, device_num = sys.argv[1], sys.argv[2], int(sys.argv[3])
          type_map = {
            "safetymonitor": "SafetyMonitor", "camera": "Camera", "switch": "Switch",
            "covercalibrator": "CoverCalibrator", "dome": "Dome", "filterwheel": "FilterWheel",
            "focuser": "Focuser", "observingconditions": "ObservingConditions",
            "rotator": "Rotator", "telescope": "Telescope"
          }
          with open(settings_file) as f:
              settings = json.load(f)
          settings["AlpacaDevice"] = {
            "AscomDeviceType": type_map[device_type],
            "AlpacaDeviceNumber": device_num,
            "ServiceType": "Http",
            "IpAddress": "127.0.0.1",
            "IpPort": 32888,
            "InterfaceVersion": 1
          }
          settings["DeviceTechnology"] = "Alpaca"
          settings["DeviceType"] = type_map[device_type]
          with open("conformu-active-settings.json", "w") as f:
              json.dump(settings, f, indent=2)
          PYEOF

      - name: Run ConformU
        env:
          NAME: ${{ matrix.name }}
        run: |
          conformu conformance-settings \
            --settingsfile conformu-active-settings.json \
            --resultsfile "conformu-${NAME}.json" \
            --logfilename "conformu-${NAME}.log" \
            || true

      - name: Check results
        env:
          NAME: ${{ matrix.name }}
        run: |
          python3 - <<PYEOF
          import json, sys, os
          name = os.environ["NAME"]
          f = f"conformu-{name}.json"
          try:
              data = json.load(open(f))
              errors = data.get("ErrorCount", 0)
              issues = data.get("IssueCount", 0)
              print(f"ConformU {name}: {errors} errors, {issues} issues")
              if errors > 0:
                  for e in data.get("Errors", []):
                      print(f"  ERROR: {e['Key']}: {e['Value']}")
                  print(f"::error::ConformU found {errors} conformance errors for {name}")
                  sys.exit(1)
              if issues > 0:
                  for i in data.get("Issues", []):
                      print(f"  ISSUE: {i['Key']}: {i['Value']}")
                  print(f"::error::ConformU found {issues} issues for {name}")
                  sys.exit(1)
          except Exception as e:
              print(f"::error::Failed to parse results: {e}")
              sys.exit(1)
          PYEOF

      - name: Upload results
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: conformu-${{ matrix.name }}
          path: |
            conformu-*.json
            conformu-*.log