diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 00000000..06a2f013 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,40 @@ +# Nextest configuration — https://nexte.st/book/configuration +# Run with: cargo nextest run + +[store] +dir = "target/nextest" + +# Default profile: local dev and CI +[profile.default] +# Retry flaky tests once before failing +retries = 1 +# Per-test timeout (catch infinite loops / deadlocks) +slow-timeout = { period = "30s", terminate-after = 2 } +# Fail fast: stop on first failure in CI (overridden in dev profile) +fail-fast = true +# Status output level +status-level = "pass" +final-status-level = "flaky" + +# Local development: more lenient +[profile.dev] +retries = 0 +fail-fast = false +slow-timeout = { period = "60s", terminate-after = 2 } + +# CI profile: strict, with JUnit output for test reporting +[profile.ci] +retries = 2 +fail-fast = true +slow-timeout = { period = "30s", terminate-after = 2 } +status-level = "retry" +final-status-level = "slow" + +# Test groups — isolate heavy tests that can't run in parallel +[test-groups.serial-integration] +max-threads = 1 + +# Assign integration tests to serial group (they bind ports / use shared state) +[[profile.default.overrides]] +filter = "test(/^integration/)" +test-group = "serial-integration" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 719c85f6..ce34a13c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,7 +67,29 @@ jobs: with: shared-key: test-${{ matrix.os }} save-if: ${{ github.ref == 'refs/heads/develop' }} - - run: cargo test + - uses: taiki-e/install-action@nextest + - run: cargo nextest run --profile ci + - run: cargo test --doc + + coverage: + name: Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + - uses: Swatinem/rust-cache@v2 + with: + shared-key: coverage + save-if: ${{ github.ref == 'refs/heads/develop' }} + - uses: taiki-e/install-action@cargo-llvm-cov + - run: cargo llvm-cov --lcov --output-path lcov.info + - uses: codecov/codecov-action@v5 + with: + files: lcov.info + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} audit: name: Security Audit diff --git a/CHANGELOG.md b/CHANGELOG.md index 995e8b89..1240f16a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.9.0](https://github.com/azerozero/grob/compare/v0.1.3...v0.9.0) - 2026-02-26 + +### Added + +- *(dx)* add nextest, insta, tracing-test, coverage, cargo-chef + +### Fixed + +- remove invalid release_branch field from release-plz.toml + +### Other + +- add develop branch workflow and auto-merge release PRs +- enable auto-merge for release-plz PRs + ### Added - **Budget enforcement**: global, per-provider, and per-model monthly spend limits (`[budget]`, `budget_usd`) diff --git a/Cargo.lock b/Cargo.lock index 0d351187..4a973126 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -459,6 +459,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "windows-sys 0.59.0", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -688,6 +700,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -944,6 +962,7 @@ dependencies = [ "hex", "hmac", "http", + "insta", "jsonwebtoken", "memchr", "metrics", @@ -951,7 +970,6 @@ dependencies = [ "mockito", "moka", "nix", - "once_cell", "p256", "pin-project", "proptest", @@ -976,6 +994,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "tracing-test", "unicode-normalization", "url", "uuid", @@ -1319,6 +1338,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "insta" +version = "1.46.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4" +dependencies = [ + "console", + "once_cell", + "serde", + "similar", + "tempfile", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2956,6 +2988,27 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "tracing-test" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracing-test-macro", +] + +[[package]] +name = "tracing-test-macro" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -3344,6 +3397,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index b9eadc1c..acb82d38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,8 +50,7 @@ anyhow = "1" thiserror = "1" # Utilities -once_cell = "1" # Lazy statics -async-trait = "0.1" # Async trait support +async-trait = "0.1" # Async trait support (needed for dyn Provider dispatch) dirs = "5" # User directories regex = "1" # Regular expressions uuid = { version = "1.0", features = ["v4", "serde"] } # UUID generation for streaming @@ -112,6 +111,8 @@ tokio-test = "0.4" mockito = "1" criterion = "0.5" # Benchmarking tempfile = "3" # Temporary directory testing +insta = { version = "1", features = ["json", "yaml"] } # Snapshot testing +tracing-test = "0.2" # Capture tracing logs in tests # Property Testing proptest = "1" @@ -128,7 +129,9 @@ tls = ["axum-server", "rustls", "rustls-pemfile"] # ecdsa: trait crate required for p256::ecdsa::signature::Signer bounds # tower: required by tower-http for Service/Layer trait impls # rustls/rustls-pemfile: behind optional "tls" feature flag -ignored = ["ecdsa", "tower", "rustls", "rustls-pemfile"] +# insta: snapshot testing — used incrementally as tests are converted +# tracing-test: used via #[traced_test] attribute on individual tests +ignored = ["ecdsa", "tower", "rustls", "rustls-pemfile", "insta", "tracing-test"] [profile.release] opt-level = 3 diff --git a/Containerfile b/Containerfile index 1fdb9d22..9cf971ac 100644 --- a/Containerfile +++ b/Containerfile @@ -1,24 +1,24 @@ # Grob - LLM Routing Proxy # Containerfile for Podman/Docker (rootless compatible) -# Multi-stage build for minimal attack surface +# Multi-stage build with cargo-chef for fast rebuilds -# Stage 1: Build environment -FROM rust:1.85-alpine AS builder - -# Install build dependencies -RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static - -# Create app directory +# Stage 1: Chef planner — compute dependency recipe +FROM rust:1.85-alpine AS chef +RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static && \ + cargo install cargo-chef --locked WORKDIR /usr/src/grob -# Copy manifest files first (cache dependencies) -COPY Cargo.toml Cargo.lock deny.toml ./ +FROM chef AS planner +COPY . . +RUN cargo chef prepare --recipe-path recipe.json -# Copy source code -COPY src ./src -COPY benches ./benches +# Stage 2: Build dependencies (cached unless Cargo.toml/lock change) +FROM chef AS builder +COPY --from=planner /usr/src/grob/recipe.json recipe.json +RUN cargo chef cook --release --target x86_64-unknown-linux-musl --recipe-path recipe.json -# Build static binary with musl +# Copy source and build final binary (only this layer invalidates on code changes) +COPY . . ENV RUSTFLAGS="-C target-feature=+crt-static -C link-self-contained=yes" RUN cargo update time@0.3.47 --precise 0.3.35 && \ cargo build --release --target x86_64-unknown-linux-musl @@ -26,7 +26,7 @@ RUN cargo update time@0.3.47 --precise 0.3.35 && \ # Strip symbols for smaller binary RUN strip target/x86_64-unknown-linux-musl/release/grob -# Stage 2: Runtime (scratch - empty base) +# Stage 3: Runtime (scratch - empty base) FROM scratch # Metadata diff --git a/release-plz.toml b/release-plz.toml index 5aa62579..da9961d4 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -8,8 +8,6 @@ git_tag_name = "v{{ version }}" git_tag_enable = true changelog_update = true pr_labels = ["release"] -# Release PRs target main from develop -release_branch = "main" [[package]] name = "grob" diff --git a/src/router/mod.rs b/src/router/mod.rs index 08cd17df..b5ea6efe 100644 --- a/src/router/mod.rs +++ b/src/router/mod.rs @@ -1,17 +1,17 @@ use crate::cli::AppConfig; use crate::models::{AnthropicRequest, MessageContent, RouteDecision, RouteType, SystemPrompt}; use anyhow::Result; -use once_cell::sync::Lazy; use regex::Regex; +use std::sync::LazyLock; use tracing::{debug, info}; /// Regex to detect capture group references ($1, $name, ${1}, ${name}) -static CAPTURE_REF_PATTERN: Lazy = - Lazy::new(|| Regex::new(r"\$(?:\d+|[a-zA-Z_]\w*|\{[^}]+\})").unwrap()); +static CAPTURE_REF_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r"\$(?:\d+|[a-zA-Z_]\w*|\{[^}]+\})").unwrap()); /// Pre-compiled regex for subagent model tag extraction (avoids per-request compilation) -static SUBAGENT_TAG_REGEX: Lazy = - Lazy::new(|| Regex::new(r"(.*?)").unwrap()); +static SUBAGENT_TAG_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"(.*?)").unwrap()); /// Check if a string contains capture group references fn contains_capture_reference(s: &str) -> bool {