fasti 0.2.0

Dates, calendars, business-day conventions and day-count fractions for financial code. Native Rust, no_std, float-free; designed after QuantLib's ql/time.
Documentation
name: CI

on:
  push:
    branches: [main]
  pull_request:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
  CARGO_TERM_COLOR: always
  CARGO_INCREMENTAL: 0
  RUST_BACKTRACE: 1

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

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

  test:
    name: test (${{ matrix.toolchain }})
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        # Stable plus the MSRV declared in Cargo.toml.
        toolchain: [stable, "1.90"]
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@master
        with:
          toolchain: ${{ matrix.toolchain }}
      - uses: Swatinem/rust-cache@v2
      # --locked throughout: Cargo.lock is committed so that the MSRV job
      # tests a resolution that is known to build on 1.90. Without it a
      # dependency raising its own MSRV turns this job red for reasons
      # unrelated to the change under test.
      - run: cargo test --locked --all-features --all-targets
      # Doctests are not covered by --all-targets.
      - run: cargo test --locked --all-features --doc

  # The committed lockfile pins one resolution; this proves the declared
  # version requirements still work when dependencies float, so the lock
  # is never quietly load-bearing.
  test-unlocked:
    name: test (updated dependencies)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - run: cargo update
      - run: cargo test --all-features --all-targets

  test-non-utc:
    name: test (non-UTC timezone)
    runs-on: ubuntu-latest
    env:
      # The library has no clock and no timezone handling, so results
      # must be identical in any environment timezone. Keep it honest
      # with a timezone that is ahead of UTC and observes DST.
      TZ: Pacific/Auckland
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - run: cargo test --locked --all-features --all-targets

  no-std:
    name: no_std build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: thumbv7em-none-eabihf
      - uses: Swatinem/rust-cache@v2
      # A bare-metal target has no std at all, so this proves the
      # #![no_std] claim for both feature sets.
      - run: cargo check --locked --target thumbv7em-none-eabihf --no-default-features
      - run: cargo check --locked --target thumbv7em-none-eabihf --all-features

  doc:
    name: doc
    runs-on: ubuntu-latest
    env:
      RUSTDOCFLAGS: -D warnings
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - run: cargo doc --locked --all-features --no-deps

  deny:
    name: cargo-deny
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: taiki-e/install-action@cargo-deny
      - run: cargo deny check

  # Proves the crate is publishable from a clean checkout: that the
  # packaged file list compiles on its own, and that LICENSE-APACHE,
  # LICENSE-MIT and THIRD-PARTY-NOTICES are actually inside the .crate
  # rather than only in the repository.
  package:
    name: package
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - run: cargo package --locked
      - name: License and notice files are in the package
        run: |
          set -euo pipefail
          list=$(cargo package --locked --list)
          for f in LICENSE-APACHE LICENSE-MIT THIRD-PARTY-NOTICES; do
            echo "$list" | grep -qx "$f" || { echo "::error::$f missing from packaged crate"; exit 1; }
          done

  # ---- Python bindings -------------------------------------------------
  #
  # bindings/python is its own cargo workspace with its own lockfile, so
  # pyo3 never reaches the jobs above: not their MSRV, not their lockfile,
  # not cargo-deny.

  py-lint:
    name: python bindings (rustfmt + clippy)
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: bindings/python
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy, rustfmt
      - uses: Swatinem/rust-cache@v2
        with:
          workspaces: bindings/python
      - run: cargo fmt --all --check
      - run: cargo clippy --locked --all-targets -- -D warnings

  py-test:
    name: python (${{ matrix.os }})
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    defaults:
      run:
        working-directory: bindings/python
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-python@v6
        with:
          # The abi3 floor. Doctest compares exception messages
          # differently before 3.11, so this is the version that has to
          # be honest about them.
          python-version: "3.10"
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
        with:
          workspaces: bindings/python
      - run: python -m pip install --upgrade pip
      - run: pip install . pytest mypy
      - run: pytest
      - run: mypy

  py-wheel:
    name: abi3 wheel
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: bindings/python
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-python@v6
        with:
          python-version: "3.10"
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
        with:
          workspaces: bindings/python
      - run: pip install maturin abi3audit twine
      - run: maturin build --release --locked --out dist
      # One wheel has to cover every supported version, so the tag it
      # carries is part of the contract.
      - name: The wheel is abi3, from the declared floor
        run: |
          python - <<'PY'
          import pathlib, re, sys
          built = [p.name for p in pathlib.Path("dist").glob("*.whl")]
          if len(built) != 1 or not re.search(r"-cp310-abi3-", built[0]):
              sys.exit(f"::error::expected one cp310-abi3 wheel, got {built}")
          PY
      - run: abi3audit --strict dist/*.whl
      - run: twine check --strict dist/*
      - uses: actions/upload-artifact@v4
        with:
          name: abi3-wheel
          path: bindings/python/dist/*.whl

  py-abi3:
    name: abi3 wheel on ${{ matrix.python }}
    needs: py-wheel
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        python: ["3.10", "3.11", "3.12", "3.13", "3.14"]
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-python@v6
        with:
          python-version: ${{ matrix.python }}
      - uses: actions/download-artifact@v4
        with:
          name: abi3-wheel
          path: wheel
      # The same wheel, unbuilt, on every version it claims to support.
      - run: pip install pytest wheel/*.whl
      - run: pytest
        working-directory: bindings/python

  py-free-threaded:
    name: python 3.14 free-threaded
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: bindings/python
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-python@v6
        with:
          python-version: "3.14t"
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
        with:
          workspaces: bindings/python
      # abi3 is a no-op on a free-threaded interpreter, so this builds a
      # version-specific 3.14t extension. The module declares
      # gil_used = false; every value it hands out is frozen.
      - run: pip install . pytest
      - run: pytest

  py-sdist:
    name: install from sdist
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-python@v6
        with:
          python-version: "3.12"
      - uses: dtolnay/rust-toolchain@stable
      - run: pip install maturin pytest twine
      - run: maturin sdist --out dist
        working-directory: bindings/python
      - run: twine check --strict bindings/python/dist/*
      # The sdist has to carry the core crate: this compiles it from
      # nothing but the tarball, in a directory that is not the checkout.
      - name: Build and test from the sdist alone
        run: |
          set -euo pipefail
          mkdir -p "$RUNNER_TEMP/from-sdist"
          tar xzf bindings/python/dist/*.tar.gz -C "$RUNNER_TEMP/from-sdist"
          python -m venv "$RUNNER_TEMP/venv"
          "$RUNNER_TEMP/venv/bin/pip" install pytest
          "$RUNNER_TEMP/venv/bin/pip" install --no-binary fasti-dates bindings/python/dist/*.tar.gz
          cd "$RUNNER_TEMP/from-sdist"/fasti_dates-*
          "$RUNNER_TEMP/venv/bin/pytest" bindings/python/tests