Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 195 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened]

env:
CARGO_TERM_COLOR: always
Expand Down Expand Up @@ -34,3 +38,194 @@ jobs:

- name: cargo test with feature impl_from
run: cargo test --verbose --package patchable --features impl_from

coverage-report:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: llvm-tools-preview
cache-key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}

- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov

- name: Run merged coverage test matrix
run: |
cargo llvm-cov clean --workspace
cargo llvm-cov --workspace --no-default-features --tests --no-report
cargo llvm-cov --workspace --tests --no-report
cargo llvm-cov --workspace --features impl_from --tests --no-report

- name: Generate HTML and JSON reports
run: |
cargo llvm-cov report --html --output-dir target/llvm-cov
cargo llvm-cov report --json --summary-only --output-path target/llvm-cov/summary.json

- name: Capture current line coverage
run: |
CURRENT_LINE_COVERAGE=$(jq -r '.data[0].totals.lines.percent' target/llvm-cov/summary.json)
printf "%s\n" "${CURRENT_LINE_COVERAGE}" > target/llvm-cov/current_line_coverage.txt
{
echo "## Coverage Report"
echo ""
echo "Current total line coverage: ${CURRENT_LINE_COVERAGE}%"
} >> "${GITHUB_STEP_SUMMARY}"

- name: Upload HTML report artifact
uses: actions/upload-artifact@v4
with:
name: coverage-html
path: target/llvm-cov/html
if-no-files-found: error
retention-days: 14

- name: Upload coverage summary artifact
uses: actions/upload-artifact@v4
with:
name: coverage-summary
path: |
target/llvm-cov/summary.json
target/llvm-cov/current_line_coverage.txt
if-no-files-found: error
retention-days: 14

coverage-compare:
runs-on: ubuntu-latest
needs: coverage-report
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Download current coverage summary artifact
uses: actions/download-artifact@v4
with:
name: coverage-summary
path: target/coverage-current

- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: llvm-tools-preview
cache-key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}

- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov

- name: Compare current coverage against base
env:
EVENT_NAME: ${{ github.event_name }}
BASE_SHA_PR: ${{ github.event.pull_request.base.sha }}
BASE_SHA_PUSH: ${{ github.event.before }}
run: |
set -euo pipefail

if [ "${EVENT_NAME}" = "pull_request" ]; then
BASE_SHA="${BASE_SHA_PR}"
else
BASE_SHA="${BASE_SHA_PUSH}"
fi

if [ -z "${BASE_SHA}" ] || [ "${BASE_SHA}" = "0000000000000000000000000000000000000000" ]; then
echo "No valid base SHA available. Skipping coverage comparison."
exit 0
fi

if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
echo "Base SHA ${BASE_SHA} not found locally. Fetching remote branch refs..."
git fetch --no-tags origin "+refs/heads/*:refs/remotes/origin/*" || true
fi

if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
echo "Base SHA ${BASE_SHA} is unavailable (likely rewritten history). Skipping coverage comparison."
exit 0
fi

CURRENT_SUMMARY_FILE=$(find target/coverage-current -name summary.json -print -quit)
if [ -z "${CURRENT_SUMMARY_FILE}" ]; then
echo "Could not find summary.json in downloaded artifact."
exit 1
fi

CURRENT_LINE_COVERAGE=$(jq -r '.data[0].totals.lines.percent' "${CURRENT_SUMMARY_FILE}")
CURRENT_REGION_COVERAGE=$(jq -r '.data[0].totals.regions.percent' "${CURRENT_SUMMARY_FILE}")
CURRENT_FUNCTION_COVERAGE=$(jq -r '.data[0].totals.functions.percent' "${CURRENT_SUMMARY_FILE}")

if ! git checkout --force "${BASE_SHA}"; then
echo "Checkout of ${BASE_SHA} failed. Trying to fetch commit object directly..."
if ! git fetch --no-tags --depth=1 origin "${BASE_SHA}"; then
echo "Could not fetch base SHA ${BASE_SHA}. Skipping coverage comparison."
exit 0
fi
git checkout --force "${BASE_SHA}" || {
echo "Checkout still failed after fetch. Skipping coverage comparison."
exit 0
}
fi

cargo llvm-cov clean --workspace
cargo llvm-cov --workspace --no-default-features --tests --no-report
cargo llvm-cov --workspace --tests --no-report
cargo llvm-cov --workspace --features impl_from --tests --no-report
mkdir -p target/llvm-cov
cargo llvm-cov report --json --summary-only --output-path target/llvm-cov/base-summary.json

BASE_LINE_COVERAGE=$(jq -r '.data[0].totals.lines.percent' target/llvm-cov/base-summary.json)
BASE_REGION_COVERAGE=$(jq -r '.data[0].totals.regions.percent' target/llvm-cov/base-summary.json)
BASE_FUNCTION_COVERAGE=$(jq -r '.data[0].totals.functions.percent' target/llvm-cov/base-summary.json)

LINE_DELTA=$(awk -v current="${CURRENT_LINE_COVERAGE}" -v base="${BASE_LINE_COVERAGE}" 'BEGIN { printf "%.4f", current - base }')
REGION_DELTA=$(awk -v current="${CURRENT_REGION_COVERAGE}" -v base="${BASE_REGION_COVERAGE}" 'BEGIN { printf "%.4f", current - base }')
FUNCTION_DELTA=$(awk -v current="${CURRENT_FUNCTION_COVERAGE}" -v base="${BASE_FUNCTION_COVERAGE}" 'BEGIN { printf "%.4f", current - base }')

{
echo "## Coverage Comparison"
echo ""
echo "Current function coverage: ${CURRENT_FUNCTION_COVERAGE}%"
echo "Base function coverage: ${BASE_FUNCTION_COVERAGE}%"
echo "Function delta: ${FUNCTION_DELTA}%"
echo ""
echo "Current line coverage: ${CURRENT_LINE_COVERAGE}%"
echo "Base line coverage: ${BASE_LINE_COVERAGE}%"
echo "Line delta: ${LINE_DELTA}%"
echo ""
echo "Current region coverage: ${CURRENT_REGION_COVERAGE}%"
echo "Base region coverage: ${BASE_REGION_COVERAGE}%"
echo "Region delta: ${REGION_DELTA}%"
} >> "${GITHUB_STEP_SUMMARY}"

if awk -v current="${CURRENT_LINE_COVERAGE}" -v base="${BASE_LINE_COVERAGE}" 'BEGIN { exit (current + 1e-9 >= base) ? 0 : 1 }'; then
echo "Coverage status: very good (no line coverage regression)."
{
echo ""
echo "Result: very good"
echo "Reason: line coverage did not regress."
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi

echo "Coverage regression detected. Evaluating relaxed thresholds..."
if awk -v func="${CURRENT_FUNCTION_COVERAGE}" -v line="${CURRENT_LINE_COVERAGE}" -v region="${CURRENT_REGION_COVERAGE}" 'BEGIN { exit (func + 1e-9 >= 100.0 && line + 1e-9 >= 90.0 && region + 1e-9 >= 90.0) ? 0 : 1 }'; then
echo "Coverage status: acceptable regression (functions >= 100%, lines >= 90%, regions >= 90%)."
{
echo ""
echo "Result: acceptable"
echo "Reason: regression exists, but quality thresholds are satisfied."
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi

echo "Coverage status: failed (regression with thresholds not satisfied)."
{
echo ""
echo "Result: failed"
echo "Reason: regression detected and quality thresholds not met."
echo "Required for acceptable regression: functions >= 100%, lines >= 90%, regions >= 90%."
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
52 changes: 52 additions & 0 deletions patchable/tests/impl_from.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,22 @@ struct Outer<InnerType> {
extra: u32,
}

#[patchable_model]
#[derive(Clone, Debug, PartialEq)]
struct TupleOuter<InnerType>(#[patchable] InnerType, u32);

#[patchable_model]
#[derive(Clone, Debug, PartialEq)]
struct UnitOuter;

#[patchable_model]
#[derive(Clone, Debug, PartialEq)]
struct SkipOuter {
value: i32,
#[patchable(skip)]
untouched: u32,
}

#[test]
fn test_from_struct_to_patch() {
let original = Outer {
Expand All @@ -30,3 +46,39 @@ fn test_from_struct_to_patch() {
target.patch(patch);
assert_eq!(target, original);
}

#[test]
fn test_from_tuple_struct_to_patch() {
let original = TupleOuter(Inner { value: 42 }, 7);
let patch: <TupleOuter<Inner> as Patchable>::Patch = original.clone().into();
let mut target = TupleOuter(Inner { value: 0 }, 0);

target.patch(patch);
assert_eq!(target, original);
}

#[test]
fn test_from_unit_struct_to_patch() {
let patch: <UnitOuter as Patchable>::Patch = UnitOuter.into();
let mut target = UnitOuter;

target.patch(patch);
assert_eq!(target, UnitOuter);
}

#[test]
fn test_from_patch_respects_skipped_fields() {
let original = SkipOuter {
value: 10,
untouched: 7,
};
let patch: <SkipOuter as Patchable>::Patch = original.into();
let mut target = SkipOuter {
value: 0,
untouched: 99,
};

target.patch(patch);
assert_eq!(target.value, 10);
assert_eq!(target.untouched, 99);
}
77 changes: 77 additions & 0 deletions patchable/tests/no_serde.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
use patchable::patchable_model;

fn plus_one(x: i32) -> i32 {
x + 1
}

#[patchable_model]
#[derive(Clone, Debug, PartialEq)]
struct PlainInner {
value: i32,
}

#[patchable_model]
#[derive(Clone, Debug, PartialEq)]
struct PlainOuter<T> {
#[patchable]
inner: T,
version: u32,
}

#[derive(Clone, Debug, PartialEq, patchable::Patchable, patchable::Patch)]
struct DeriveOnlyStruct {
value: i32,
#[patchable(skip)]
sticky: u32,
}

#[patchable_model]
#[derive(Clone, Debug)]
struct AllSkipped {
#[patchable(skip)]
marker: fn(i32) -> i32,
}

#[test]
fn test_patchable_model_and_derive_generate_patch_types_without_serde() {
fn assert_patchable<T: patchable::Patchable + patchable::Patch>() {}
fn assert_patch_debug<T: patchable::Patchable>()
where
T::Patch: core::fmt::Debug,
{
}

assert_patchable::<PlainInner>();
assert_patchable::<PlainOuter<PlainInner>>();
assert_patchable::<DeriveOnlyStruct>();
assert_patchable::<AllSkipped>();

assert_patch_debug::<PlainInner>();
assert_patch_debug::<PlainOuter<PlainInner>>();
assert_patch_debug::<DeriveOnlyStruct>();
assert_patch_debug::<AllSkipped>();
}

#[test]
fn test_patch_methods_are_generated_without_serde() {
let _: fn(
&mut PlainOuter<PlainInner>,
<PlainOuter<PlainInner> as patchable::Patchable>::Patch,
) = <PlainOuter<PlainInner> as patchable::Patch>::patch;

let _: fn(&mut DeriveOnlyStruct, <DeriveOnlyStruct as patchable::Patchable>::Patch) =
<DeriveOnlyStruct as patchable::Patch>::patch;

let _: fn(&mut AllSkipped, <AllSkipped as patchable::Patchable>::Patch) =
<AllSkipped as patchable::Patch>::patch;

let outer_patch_name =
std::any::type_name::<<PlainOuter<PlainInner> as patchable::Patchable>::Patch>();
let derive_patch_name =
std::any::type_name::<<DeriveOnlyStruct as patchable::Patchable>::Patch>();
assert!(outer_patch_name.contains("PlainOuter"));
assert!(derive_patch_name.contains("DeriveOnlyStruct"));

let value = AllSkipped { marker: plus_one };
assert_eq!((value.marker)(1), 2);
}
Loading