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
3 changes: 1 addition & 2 deletions .github/workflows/benchmarks.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
name: Benchmarks

on:
pull_request:
workflow_dispatch:

jobs:
Expand All @@ -18,7 +17,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
dotnet-version: 10.0.x

- name: Run Benchmarks
run: dotnet run --project ${{ env.PROJECT }} --configuration Release -- --filter '*'
12 changes: 11 additions & 1 deletion .github/workflows/unit-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,14 @@ jobs:
with:
platform: ${{ matrix.platform }}
dotnet-version: 9.0.x
test-project-path: tests/Verifast.Tests.Unit/Verifast.Tests.Unit.csproj
test-project-path: tests/Verifast.Tests.Unit/Verifast.Tests.Unit.csproj
unit-tests-standard:
strategy:
fail-fast: false
matrix:
platform: [ubuntu-latest, windows-latest, macos-latest]
uses: dusrdev/actions/.github/workflows/reusable-dotnet-test.yaml@main
with:
platform: ${{ matrix.platform }}
dotnet-version: 8.0.x
test-project-path: tests/Verifast.Tests.Unit.Standard/Verifast.Tests.Unit.Standard.csproj
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@

# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
*WARP.md
*AGENTS.md

# Mono auto generated files
mono_crash.*
Expand Down
77 changes: 77 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# AGENTS.md

This file provides guidance to agents when working with code in this repository.

## Project Overview
- Tech: Multi‑targeted .NET library (`src/Verifast`) targeting `net9.0`, `netstandard2.1`, and `netstandard2.0`, focused on fast, allocation‑aware validation. Unit tests live in `tests/Verifast.Tests.Unit` (net9.0) using xUnit v3 with the Microsoft Testing Platform, and `tests/Verifast.Tests.Unit.Standard` (net8.0) using xUnit v2 to exercise the `netstandard2.0` target. A minimal benchmarks project exists under `benchmarks/Verifast.Benchmarks` (net9.0).
- Design: The library centers on simple, interface‑driven validators and a lightweight result type that captures errors and warnings only when needed.

## Big‑Picture Architecture
- Validation contracts:
- `IValidator<T, TMessage>` and `IValidator<T>`: synchronous validators. On `net9.0`, both use `where T : allows ref struct` so validators can be used with stack‑only types without forcing `ref struct` everywhere. On `netstandard2.1`/`netstandard2.0`, the constraint is omitted.
- `IAsyncValidator<T, TMessage>` and `IAsyncValidator<T>`: asynchronous validators returning `ValueTask<ValidationResult<...>>` across targets. The `netstandard2.0` forms are declared with `in T` variance.
- Orchestrator APIs:
- Static `Validator` class: extension methods for synchronous validation and `TryValidate` overloads. Only sync helpers exist (no async orchestrator today). On `net9.0` the extensions include `allows ref struct` constraints.
- Result type:
- `ValidationResult<TMessage>`: a struct that tracks `IsValid`, `Errors`, and `Warnings`. Message lists are allocated on‑demand when the first message is added; `Errors`/`Warnings` return `ReadOnlyCollection<TMessage>` wrappers.

## Usage Model
- Implement `IValidator<T>` (or `IAsyncValidator<T>`) on the type being validated or in a separate validator type.
- Sync validators populate results via `Validate(in T instance, ref ValidationResult<...> result)`.
- Execute via `validator.Validate(instance)` / `validator.TryValidate(instance, out var result)` or `validator.ValidateAsync(instance, ct)`.
- Choose `string` messages for simplicity or a custom `TMessage` type for structured metadata.

## Repository Layout
- `src/Verifast`: Library code (`IValidator`, `IAsyncValidator`, `Validator`, `ValidationResult`).
- `tests/Verifast.Tests.Unit`: xUnit v3 tests referencing the library. Configured as `OutputType Exe` to support Microsoft Testing Platform.
- `tests/Verifast.Tests.Unit.Standard`: xUnit v2 tests targeting `net8.0` so the library resolves to its `netstandard2.0` target.
- `benchmarks/Verifast.Benchmarks`: BenchmarkDotNet project (scaffolded).
- `Verifast.slnx`: Solution file exists, but project‑scoped commands are preferred.

## Commands You’ll Commonly Use
Note: Favor project‑scoped commands (operate on `src/Verifast` or `tests/Verifast.Tests.Unit` explicitly). Avoid explicit `dotnet build` before running tests; `dotnet run` builds implicitly.

- Build the library (project‑scoped):
- `dotnet build src/Verifast/Verifast.csproj`

- Format/lint (EditorConfig conventions assumed):
- Analyze only: `dotnet format analyze --severity info`
- Apply style/whitespace fixes: `dotnet format`
- If `dotnet format` isn’t installed: `dotnet tool update -g dotnet-format`

- Run all tests (Microsoft Testing Platform via dotnet run):
- From repo root: `dotnet run --project tests/Verifast.Tests.Unit`

- Run netstandard2.0 coverage tests (xUnit v2 via dotnet test):
- `dotnet test tests/Verifast.Tests.Unit.Standard`

- List tests (Microsoft Testing Platform semantics):
- `dotnet run --project tests/Verifast.Tests.Unit -- --list-tests`

- Run a single test (recommended: filter by fully qualified name):
- By method: `dotnet run --project tests/Verifast.Tests.Unit --filter-method="*TestMethodPattern*"`
- By class: `dotnet run --project tests/Verifast.Tests.Unit --filter-class="*TestClassPattern*"`
- Alternative (traditional): `dotnet test tests/Verifast.Tests.Unit --filter "FullyQualifiedName~Pattern"`

- Run benchmarks (Release, no debugger):
- `dotnet run --project benchmarks/Verifast.Benchmarks -c Release`

## Style and Conventions
- C# 10+ idioms; file‑scoped namespaces.
- Braces required around blocks.
- Private fields: `_camelCase`; private static fields: `s_camelCase`.
- Interfaces start with `I`; type parameters start with `T`.
- Prefer explicit, straightforward logic (no heavy reflection or expression trees). Keep hot paths allocation‑aware.

## Test Stack Specifics
- xUnit v3 with `Microsoft.NET.Test.Sdk`. The test project sets `OutputType Exe` and includes `xunit.runner.json`.
- Filters and test listing use Microsoft Testing Platform semantics when using `dotnet run`.
- You can also use `dotnet test` with similar filter semantics if preferred.

## AOT/Trimming Notes
- `IsAotCompatible` and `IsTrimmable` are enabled for the `net9.0` target. Avoid patterns that rely on runtime code generation or deep reflection without proper annotations.

## Notes for Future Agents
- The `allows ref struct` constraints are intentional to enable stack‑only scenarios. Avoid introducing APIs that disallow `ref struct` usage unless necessary.
- Keep the API surface minimal and allocation‑friendly. `ValidationResult<TMessage>` should remain a lightweight struct that only allocates when messages are added.
- Prefer project‑scoped commands; the solution file exists but isn’t required for everyday operations.
28 changes: 20 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
[![NuGet](https://img.shields.io/nuget/v/Verifast.svg?style=flat-square)](https://www.nuget.org/packages/Verifast)
[![NuGet Downloads](https://img.shields.io/nuget/dt/Verifast?style=flat&label=Downloads)](https://www.nuget.org/packages/Verifast)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE)
[![.NET](https://img.shields.io/badge/.NET-9.0-512BD4?style=flat-square)](#)
[![.NET](https://img.shields.io/badge/.NET-net9.0%20%7C%20netstandard2.1%20%7C%20netstandard2.0-512BD4?style=flat-square)](#)

High‑performance, allocation‑friendly validation for .NET 9 and above. No complicated APIs, no expression trees - just plain C#. Implement a tiny interface, add errors or warnings, and you’re done.
High‑performance, allocation‑friendly validation for .NET 9 and netstandard (2.1/2.0). No complicated APIs, no expression trees - just plain C#. Implement a tiny interface, add errors or warnings, and you’re done.

## Why Verifast

Expand All @@ -15,16 +15,28 @@ High‑performance, allocation‑friendly validation for .NET 9 and above. No co
- Plays well with `ref struct`: APIs are designed to enable stack‑only scenarios.
- Your messages, your way: use `string` or a custom `TMessage` for richer metadata.

## Benchmarks

[BenchmarkDotNet](https://benchmarkdotnet.org/) results comparing `Verifast` to [FluentValidation](https://github.com/FluentValidation/FluentValidation) (baseline, industry standard). Full report: [BenchmarkRun-joined.md](benchmarks/Verifast.Benchmarks/BenchmarkRun-joined.md).

| Type | Method | ValidDto | Mean | Ratio | Allocated | Alloc Ratio |
|---------------- |----------------- |--------- |------------:|----------------:|----------:|--------------:|
| AsyncValidation | FluentValidation | False | 10,935.2 ns | baseline | 14104 B | |
| AsyncValidation | Verifast | False | 2,203.2 ns | 4.96x faster | 1096 B | 12.87x less |
| AsyncValidation | FluentValidation | True | 8,470.8 ns | baseline | 9808 B | |
| AsyncValidation | Verifast | True | 2,101.9 ns | 5.20x faster | 1016 B | 13.88x less |
| | | | | | | |
| SyncValidation | FluentValidation | False | 35,687.7 ns | baseline | 63112 B | |
| SyncValidation | Verifast | False | 141.4 ns | 252.377x faster | 328 B | 192.415x less |
| SyncValidation | FluentValidation | True | 26,091.2 ns | baseline | 50063 B | |
| SyncValidation | Verifast | True | 112.3 ns | 320.264x faster | - | NA |

## Install

```bash
dotnet add package Verifast
```

## Versioning

While the version is below `1.0.0.0` minor versions can change the public API without warning (SemVer will not be followed until `1.0.0.0` is reached).

## Quick Start

Define your model and implement its validator. Then call the extension helpers.
Expand All @@ -34,7 +46,7 @@ using Verifast;

public readonly record struct User(string? Name, int Age);

public readonly ref struct UserValidator : IValidator<User> {
public readonly struct UserValidator : IValidator<User> {
public void Validate(in User instance, ref ValidationResult<string> result) {
if (string.IsNullOrWhiteSpace(instance.Name))
result.AddError("'Name' must be non-empty");
Expand Down Expand Up @@ -95,7 +107,7 @@ using Verifast;

public readonly record struct Msg(string Code, string Text);

public readonly ref struct EvenValidator : IValidator<int, Msg> {
public readonly struct EvenValidator : IValidator<int, Msg> {
public void Validate(in int value, ref ValidationResult<Msg> result) {
if ((value & 1) != 0)
result.AddError(new Msg("NotEven", "Value must be even"));
Expand Down
1 change: 1 addition & 0 deletions Verifast.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<Project Path="src/Verifast/Verifast.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Verifast.Tests.Unit.Standard/Verifast.Tests.Unit.Standard.csproj" />
<Project Path="tests/Verifast.Tests.Unit/Verifast.Tests.Unit.csproj" />
</Folder>
</Solution>
23 changes: 23 additions & 0 deletions benchmarks/Verifast.Benchmarks/BenchmarkRun-joined.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
```

BenchmarkDotNet v0.15.8, macOS Tahoe 26.1 (25B78) [Darwin 25.1.0]
Apple M2 Pro, 1 CPU, 10 logical and 10 physical cores
.NET SDK 10.0.101
[Host] : .NET 10.0.1 (10.0.1, 10.0.125.57005), Arm64 RyuJIT armv8.0-a
MediumRun : .NET 10.0.1 (10.0.1, 10.0.125.57005), Arm64 RyuJIT armv8.0-a

Job=MediumRun OutlierMode=RemoveAll IterationCount=15
IterationTime=100ms LaunchCount=2 WarmupCount=10

```
| Type | Method | ValidDto | Mean | Ratio | Allocated | Alloc Ratio |
|---------------- |----------------- |--------- |------------:|----------------:|----------:|--------------:|
| AsyncValidation | FluentValidation | False | 10,935.2 ns | baseline | 14104 B | |
| AsyncValidation | Verifast | False | 2,203.2 ns | 4.96x faster | 1096 B | 12.87x less |
| AsyncValidation | FluentValidation | True | 8,470.8 ns | baseline | 9808 B | |
| AsyncValidation | Verifast | True | 2,101.9 ns | 5.20x faster | 1016 B | 13.88x less |
| | | | | | | |
| SyncValidation | FluentValidation | False | 35,687.7 ns | baseline | 63112 B | |
| SyncValidation | Verifast | False | 141.4 ns | 252.377x faster | 328 B | 192.415x less |
| SyncValidation | FluentValidation | True | 26,091.2 ns | baseline | 50063 B | |
| SyncValidation | Verifast | True | 112.3 ns | 320.264x faster | - | NA |
8 changes: 4 additions & 4 deletions benchmarks/Verifast.Benchmarks/Benchmarks/AsyncValidation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ namespace Verifast.Benchmarks.Benchmarks;

public class AsyncValidation {
[Params(true, false)]
public bool DtoValid { get; set; }
public bool ValidDto { get; set; }

private UserProfile? _dto;
private FakeUserRepository _repo = null!;
Expand All @@ -18,19 +18,19 @@ public async Task Setup() {
// Simulate seeding work
await _repo.AddAsync("taken@spam.com");

_dto = DtoValid
_dto = ValidDto
? UserProfileFactory.CreateValid("valid@example.com")
: UserProfileFactory.CreateInvalid("taken@spam.com");
}

[Benchmark(Baseline = true)]
[Benchmark(Baseline = true, Description = "FluentValidation")]
public async Task<int> FluentValidation_Async() {
var validator = new UserProfileFluentAsyncValidator(_repo);
var result = await validator.ValidateAsync(_dto!);
return result.Errors.Count;
}

[Benchmark]
[Benchmark(Description = "Verifast")]
public async Task<int> Verifast_Async() {
var validator = new AsyncUserProfileVerifastValidator(_repo);
var result = await validator.ValidateAsync(_dto!);
Expand Down
8 changes: 4 additions & 4 deletions benchmarks/Verifast.Benchmarks/Benchmarks/SyncValidation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,25 @@ namespace Verifast.Benchmarks.Benchmarks;
[ReturnValueValidator]
public class SyncValidation {
[Params(true, false)]
public bool DtoValid { get; set; }
public bool ValidDto { get; set; }

private UserProfile? _dto;

[GlobalSetup]
public void Setup() {
_dto = DtoValid
_dto = ValidDto
? UserProfileFactory.CreateValid()
: UserProfileFactory.CreateInvalid();
}

[Benchmark(Baseline = true)]
[Benchmark(Baseline = true, Description = "FluentValidation")]
public int FluentValidation() {
var validator = new UserProfileFluentValidator();
var result = validator.Validate(_dto!);
return result.Errors.Count;
}

[Benchmark]
[Benchmark(Description = "Verifast")]
public int Verifast() {
var validator = new UserProfileVerifastValidator();
var result = validator.Validate(_dto!);
Expand Down
7 changes: 5 additions & 2 deletions benchmarks/Verifast.Benchmarks/Models/BenchmarkConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
using BenchmarkDotNet.Columns;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Diagnosers;
using BenchmarkDotNet.Exporters;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Loggers;
using BenchmarkDotNet.Order;
using BenchmarkDotNet.Reports;
using BenchmarkDotNet.Running;

using Perfolizer.Horology;
using Perfolizer.Mathematics.OutlierDetection;

namespace Verifast.Benchmarks.Models;
Expand All @@ -17,14 +19,15 @@ public class BenchmarkConfig : ManualConfig {
public BenchmarkConfig() {
SummaryStyle = SummaryStyle.Default.WithRatioStyle(RatioStyle.Trend);
AddDiagnoser(MemoryDiagnoser.Default);
AddJob(Job.MediumRun.WithOutlierMode(OutlierMode.RemoveAll));
AddJob(Job.MediumRun.WithOutlierMode(OutlierMode.RemoveAll).WithIterationTime(TimeInterval.FromMilliseconds(100)));
AddColumnProvider(DefaultColumnProviders.Instance);
AddColumn(RankColumn.Arabic);
HideColumns(Column.Error, Column.StdDev, Column.Median, Column.RatioSD);
HideColumns(Column.Error, Column.StdDev, Column.Median, Column.RatioSD, Column.Gen0, Column.Gen1, Column.Gen2, Column.Rank);
WithOrderer(new GroupByTypeOrderer());
WithOptions(ConfigOptions.JoinSummary);
WithOptions(ConfigOptions.StopOnFirstError);
WithOptions(ConfigOptions.DisableLogFile);
AddExporter(MarkdownExporter.GitHub);
AddLogger(ConsoleLogger.Default);
}
}
Expand Down
8 changes: 4 additions & 4 deletions benchmarks/Verifast.Benchmarks/Verifast.Benchmarks.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.15.2" />
<PackageReference Include="Bogus" Version="35.6.3" />
<PackageReference Include="FluentValidation" Version="12.0.0" />
<PackageReference Include="BenchmarkDotNet" Version="0.15.8" />
<PackageReference Include="Bogus" Version="35.6.5" />
<PackageReference Include="FluentValidation" Version="12.1.1" />
</ItemGroup>

<ItemGroup>
Expand Down
30 changes: 30 additions & 0 deletions src/Verifast/IAsyncValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
namespace Verifast;

/// <summary>
/// Interface for implementing an asynchronous validator for <typeparamref name="T"/> with message type <typeparamref name="TMessage"/>
/// </summary>
/// <typeparam name="T"></typeparam>
/// <typeparam name="TMessage"></typeparam>
public interface IAsyncValidator<in T, TMessage> {
/// <summary>
/// ValidateAsync method
/// </summary>
/// <param name="instance"></param>
/// <param name="ct"></param>
/// <returns></returns>
ValueTask<ValidationResult<TMessage>> ValidateAsync(T instance, CancellationToken ct = default);
}

/// <summary>
/// Interface for implementing an asynchronous validator for <typeparamref name="T"/>
/// </summary>
/// <typeparam name="T"></typeparam>
public interface IAsyncValidator<in T> {
/// <summary>
/// ValidateAsync method
/// </summary>
/// <param name="instance"></param>
/// <param name="ct"></param>
/// <returns></returns>
ValueTask<ValidationResult<string>> ValidateAsync(T instance, CancellationToken ct = default);
}
Loading