diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index fb05763..d51c9f0 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -10,11 +10,18 @@ "rollForward": false }, "dotnet-inspect": { - "version": "0.25.0", + "version": "0.26.0", "commands": [ "dotnet-inspect" ], "rollForward": false + }, + "purview.build": { + "version": "0.5.4", + "commands": [ + "purview-build" + ], + "rollForward": false } } } \ No newline at end of file diff --git a/.config/lefthook.yml b/.config/lefthook.yml new file mode 100644 index 0000000..24dd351 --- /dev/null +++ b/.config/lefthook.yml @@ -0,0 +1,9 @@ +pre-commit: + jobs: + - name: lint staged + run: just lint-check + +commit-msg: + jobs: + - name: conventional commits + run: bunx commitlint --edit {1} diff --git a/.config/purview-build.json b/.config/purview-build.json index 1eea3a4..7eb26b4 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -7,6 +7,12 @@ "TestFilter": "/*/*/*/*[Category=Unit]" }, "PackValidation": { + // Explicit because Purview.Build 0.5.3 defaults RequiredCompilerFlags to empty. Earlier + // versions forced this check on and could not be configured off, so these packages have + // always been validated against it; declaring it keeps that behaviour. + "RequiredCompilerFlags": [ + "optimization=release" + ], // Every package is a library package that produces a symbol package, so RequireSymbolFiles and the // assembly checks below all apply to them. Purview.Results additionally carries the merged source // generator and its code fix under analyzers/dotnet/cs (no PDB, no lib folder for those), so the @@ -16,7 +22,6 @@ // package ships: a package with no rule here, or an entry matching none of its globs, fails. "RequiredContent": { "purview.results": [ - ".agents/**/*", "README.md", "analyzers/dotnet/cs/Purview.Results.SourceGenerator.CodeFixes.dll", "analyzers/dotnet/cs/Purview.Results.SourceGenerator.dll", @@ -26,21 +31,18 @@ "purview-logo-light.png" ], "purview.results.aspnetcore": [ - ".agents/**/*", "README.md", "lib/$(TFM)/Purview.Results.AspNetCore.dll", "lib/$(TFM)/Purview.Results.AspNetCore.xml", "purview-logo-light.png" ], "purview.results.zodsharp": [ - ".agents/**/*", "README.md", "lib/$(TFM)/Purview.Results.ZodSharp.dll", "lib/$(TFM)/Purview.Results.ZodSharp.xml", "purview-logo-light.png" ], "purview.results.zodsharp.aspnetcore": [ - ".agents/**/*", "README.md", "lib/$(TFM)/Purview.Results.ZodSharp.AspNetCore.dll", "lib/$(TFM)/Purview.Results.ZodSharp.AspNetCore.xml", @@ -50,11 +52,12 @@ "ForbiddenContent": { // The generator ships a merged, self-contained analyzer, so the framework assembly must never // appear loose beside it. - "*": ["analyzers/**/Purview.SourceGeneratorFramework.dll"] + "*": [ + "analyzers/**/Purview.SourceGeneratorFramework.dll" + ] } }, "Release": { "Mode": "None" } -} - +} \ No newline at end of file diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index bec93ff..d48cd09 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -20,7 +20,7 @@ jobs: # the .NET 11 SDK in global.json. Without the matching SDK the runner cannot resolve global.json # ("A compatible .NET SDK was not found") and every step fails. # Keep in sync with `sdk.version` in global.json. - dotnet-version: "11.0.100-rc.1.26425.128" + dotnet-version: "11.0.100-rc.2.26504.105" # Mirrors Build:TestFilter in purview-build.json: every test project is a *.UnitTests project # whose tests the SDK categorises as Unit. test-filter: "/*/*/*/*[Category=Unit]" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 655aa10..7f7d216 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: # The shared workflow defaults to the 10.0.x SDK, but this repository targets net11.0 and pins # the .NET 11 SDK in global.json; without it the release job cannot resolve global.json. # Keep in sync with `sdk.version` in global.json (and with .github/workflows/pr.yml). - dotnet-version: "11.0.100-rc.1.26425.128" + dotnet-version: "11.0.100-rc.2.26504.105" release-mode: NuGet release-branch: main secrets: inherit diff --git a/AGENTS.md b/AGENTS.md index 1f6ec9c..2faf879 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,8 @@ generator that makes C# 15 union error cases ergonomic), and the ZodSharp and AS | `src/src/AspNetCore` | Result-to-response mapping, endpoint filter, DI registration | | `src/src/ZodSharp` | ZodSharp `ValidationResult` bridge | | `src/src/ZodSharp.AspNetCore` | Validation-problem mapping for validation-carrying failures | -| `src/src//Sdk` | Package-only assets. `Sdk/README.md` is packed as the package README; `Sdk/.agents/**` would ship agent skills with the package | +| `src/src//Sdk` | Package-only assets. `Sdk/README.md` is packed as the package README | +| `skills` | Authored agent skills, installable with the skills CLI (see [Agent skills](#agent-skills)) | | `src/tests` | TUnit unit tests, including source-generation and incremental-cache tests | | `src/src/Examples.*` | Runnable, non-packable examples: one project per integration aspect on a shared Tenant* domain | | `docs/wiki` | User-facing documentation suite, aggregated by the purview-dev website | @@ -35,8 +36,6 @@ generator that makes C# 15 union error cases ergonomic), and the ZodSharp and AS | `package.json` | Authoritative repository and package version | | `purview-build.json` | Shared `Purview.Build` pipeline configuration (solution, test discovery, test filter) | | `Justfile` | Supported local workflow commands | -| `.agents` | Package-delivered skills, agents and prompts (see [Agent skills](#agent-skills)) | -| `.purview/agent-sync.cache` | SDK-managed manifest recording which `.agents` files are already mirrored | ## Standard workflow @@ -310,51 +309,41 @@ result and the HTTP mapping). ## Agent skills -The repository-root `.agents/**` tree is **delivered by NuGet packages**; this repository also **authors** its -own agent content under `src/src//Sdk/.agents/**`, which the packages ship to consumers. - -`Purview.BuildSdk` mirrors the `.agents` folder of every restored package into the repository root before build -and records what it copied in `.purview/agent-sync.cache`, so the root tree is complete even though it is not -maintained here. - -### Authored here (shipped by this repository's packages) - -| Content | Package | Path | -| --- | --- | --- | -| `skills/purview-results-core`, `skills/purview-results-union-errors`, `agents/purview-results-union-author.agent.md`, `prompts/migrate-error-returns-to-result-unions.prompt.md` | `Purview.Results` | `src/src/Results/Sdk/.agents` | -| `skills/purview-results-http-mapping` | `Purview.Results.AspNetCore` | `src/src/AspNetCore/Sdk/.agents` | -| `skills/purview-results-zodsharp-validation` | `Purview.Results.ZodSharp` | `src/src/ZodSharp/Sdk/.agents` | -| `skills/purview-results-zodsharp-problems` | `Purview.Results.ZodSharp.AspNetCore` | `src/src/ZodSharp.AspNetCore/Sdk/.agents` | - -- These files **are tracked source** and are packed as `.agents/**` (the SDK also generates the per-skill - `.gitignore` inside the package). Update them like any other package asset, and keep each skill's guidance in - step with the code it documents. -- A consuming repository that imports `Purview.BuildSdk` receives them in its own `.agents/` folder on the next - restore/build, so consumer-facing guidance is written for consumers (package-facing, not repository-facing). -- Load the relevant skill before doing the work it covers: - - modelling or migrating to result error unions → `purview-results-union-errors` - - the result type, its states or its combinators → `purview-results-core` - - result-to-HTTP mapping or unmapped-failure `500`s → `purview-results-http-mapping` - - ZodSharp validation flowing through results → `purview-results-zodsharp-validation`, then - `purview-results-zodsharp-problems` - -### Delivered by upstream packages (re-synced, do not edit) - -| Content | Source package | +Authored skills live in the repository-root `skills/` folder. **Nothing packs them into a NuGet package.** +`Purview.BuildSdk` 1.0.6 removed skill packaging outright — the skills CLI clones the *git* repository and only +discovers a root `skills/` folder, so that is the one delivery route. `PurviewSkillsToPack`, the +`IncludeRepositorySkillsInPackage` shim in `src/Directory.Build.targets`, the `Sdk/.agents/**` sub-agents and +prompts, and the `.agents/**/*` entries in `.config/purview-build.json` went with it. + +`.agents/skills/` is the CLI's install location for skills from **other** repositories, and is gitignored. This +repository's own skills are never installed into it — that would leave two copies to drift apart. Read them at +their authored path, `skills//SKILL.md`. + +| Skill | Answers | | --- | --- | -| `skills/project-placement-defaults`, `skills/sdk-configuration-reference`, `skills/sdk-project-behavior-and-detection`, `agents/sdk-consumer-setup.md`, `prompts/sdk-diagnose-agent-folder-copy.md` | `Purview.BuildSdk` | -| `skills/source-generator-codewriter-modernization`, `agents/source-generator-framework-writer.agent.md`, `prompts/refactor-source-generator-to-codewriter.prompt.md` | `Purview.SourceGeneratorFramework` | -| `skills/source-generator-testing` | `Purview.SourceGeneratorFramework.Testing` | -| `skills/tunit-test-authoring`, `agents/test-author-writer.agent.md`, `prompts/modernize-test-to-codequery-tunit.prompt.md` | `Purview.SourceGeneratorFramework.Testing.TUnit` | - -- **Upstream skills are not tracked by git.** Each `.agents/skills//` folder carries an SDK-generated - `.gitignore` that ignores everything except itself, so only those stub files appear in `git status`. Treat that - content as read-only: do not edit it here and do not force-add it to git. Changes belong in the owning package - repository (`build-sdk`, `sourcegenerator-framework`). -- Upstream agents and prompts under `.agents/agents/**` and `.agents/prompts/**` are tracked, but they are still - package-owned: editing them locally is overwritten by the next restore or build. -- If `.agents` content looks missing or stale after a package bump, run a build and inspect - `.purview/agent-sync.cache`; deleting an entry (or the manifest) forces a fresh copy. +| `skills/purview-results-core/` | the result type, its three states, the throw-on-misuse contract and the combinators | +| `skills/purview-results-union-errors/` | modelling failures as a `[GenerateResult]` union, the generated helpers, the `RSG1000`–`RSG1008` diagnostics, and migrating ad-hoc failure signalling | +| `skills/purview-results-http-mapping/` | result-to-HTTP mapping, failure resolution order and unmapped-failure `500`s | +| `skills/purview-results-zodsharp-validation/` | ZodSharp validation flowing through a result | +| `skills/purview-results-zodsharp-problems/` | validation problems from result failures | + +Every skill carries the `purview-results` prefix. Skills install into one flat directory per agent, shared with +every skill a developer has installed from anywhere, so an unprefixed name collides and the loser is whichever +installed second. + +To add a skill: create `skills//SKILL.md` with spec-legal frontmatter, and nothing else. There is no +per-project registration any more. Deep, task-shaped detail belongs in that skill's own `references/` folder — +`skills/purview-results-union-errors/references/migrating-error-returns.md` is the worked example. + +A relative link from one `SKILL.md` into another skill's folder is a rule violation: only a skill's own folder is +installed with it, so such a link works here and nowhere else. Name the other skill in prose instead. + +`src/tests/Repository.UnitTests/SkillTests.cs` validates every skill on every test run, through +`Purview.Skills.Testing`'s rule set (the Agent Skills specification: frontmatter fields, name and description +constraints, bundled links, body size). The `[InheritsTests]` attribute on that class is load-bearing — TUnit does +not walk base types during discovery, so without it the run is green and nothing is checked. That project pins +`net10.0` rather than taking the repository's testing set, because it asserts something about the repository +rather than about the runtime's reach. ## Build, format, test and pack commands @@ -402,7 +391,7 @@ just build repository's `Justfile`; the equivalent direct invocation (after `just ensure-pipeline-tool`) is: ```text -.tools/purview-build/purview-build --Release:Mode=LocalNuGet --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ +dotnet purview-build --Release:Mode=LocalNuGet --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ ``` `LOCAL_NUGET_FEED_PATH` is exported in this environment, so the explicit `--PublishLocalNuGet:LocalFeedPath` diff --git a/CHANGELOG.md b/CHANGELOG.md index af6b37c..68971eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,46 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `package.json` is the authoritative version; see [Release Flow](docs/wiki/Release-Flow.md). -Released versions correspond to `v` GitHub releases. Entries below the `Unreleased` heading have not -been published to NuGet. +Released versions correspond to `v` GitHub releases. -## Unreleased +## 1.0.2 + +Consolidates the `1.0.0` and `1.0.0.1` releases that followed `1.0.0-prerelease.2`: upgrading from the +prerelease goes straight to `1.0.2`, and the notes below cover that whole arc. + +> **Upgrading?** Start with [Upgrade notes](#upgrade-notes). Two APIs moved or changed shape along the way; +> each note names the old form, the new form, and the edit to make. + +### Upgrade notes + +- **The `[GenerateResult]` attribute now lives in `Purview.Results`.** + - *Previously:* the generated opt-in attribute was emitted into `Purview.Results.SourceGenerator`. + - *Now:* it is `Purview.Results.GenerateResultAttribute`. + - *To upgrade:* remove any `using Purview.Results.SourceGenerator;` that was added only for the attribute. + `Purview.Results` is already imported wherever the result types are used, so most files need no change. +- **`IResultValue` gained `AcceptSuccess`.** See + [`IResultValueVisitor`](#added--iresultvaluevisitortstate-treturn) for why. + - *To upgrade:* nothing, in the normal case — the two result types implement `IResultValue` in-box, and + external implementations are not expected. A hand-written implementation needs the new member. +- **Async combinators now await with `ConfigureAwait(false)`**, so they no longer resume on a captured + synchronization context. + - *To upgrade:* nothing for an ASP.NET Core or console host. Code that relied on continuing back onto a UI or + legacy ASP.NET context must marshal back itself. ### Added +- **Asynchronous validation folding in `Purview.Results.ZodSharp`.** `ToResultAsync` and `ToUnitResultAsync` accept + the `ValueTask>` that `ValidateAsync` returns — on the generated schema, the DI validator + adapter, or a `[ZodSchema(CustomValidationMethodName = ...)]` rule — so an async schema folds into the result + pipeline the same way the synchronous `Validate` does. As with the synchronous forms, the failure factory runs + only when validation failed. +- The `Examples.ValueObjects.Zod` example (and the matching end-to-end test fixture) now uses the **automatic + scalar form**, `[Scalar]`, so the composition is exercised against Purview.ValueObjects 1.0.1's + generator-declared property rather than only the manual `[Scalar]` form, whose schema Purview.ZodSharp 2.0.2+ + generates from the attribute itself. +- A test fixture covering a **nullable scalar** (`[Scalar(Nullable = true)]`) whose type-level rule is + ZodSharp's shipped null-tolerant `[NullOrNonWhiteSpace]`: `null` validates, whitespace-only is rejected, and the + rejection maps through the result pipeline by its code. - The value-less `Result` type: the same three-state `readonly record struct` contract as `Result`, but a success holds the stateless `Success` marker instead of a value. Comes with `Match`, `Switch`, `Tap`, `TapError`, `Ensure`, `Map`, `Bind`, `MapError`, `MapAsync`, `BindAsync`, `OrElse`, @@ -36,6 +69,12 @@ been published to NuGet. ### Changed +- **NuGet package validation.** All four packages now enable `EnablePackageValidation` against the previous + stable release (`1.0.0.1`), so an unintended change to the public API fails `dotnet pack` rather than + shipping. All four validate clean with no suppressions. +- `Purview.Results.ZodSharp.AspNetCore` references `Purview.ZodSharp` explicitly rather than relying on it + transitively through `Purview.ZodSharp.AspNetCore`, because the mapping uses `ZodException` and + `ValidationError` directly. No package content changes. - **Consuming these packages no longer forces preview features on your solution.** `EnablePreviewFeatures` was set repo-wide, which emitted `[assembly: RequiresPreviewFeatures]` into all four shipped assemblies. Because that attribute is assembly-wide, `CA2252` — an **error** by default — fired on every member a @@ -50,8 +89,9 @@ been published to NuGet. - Declaring your own `[GenerateResult]` union needs `LangVersion=preview` in **that** project. Both paths are verified against the packed package, the first with `TreatWarningsAsErrors=true`. -- **Breaking:** the generated `[GenerateResult]` opt-in attribute moved from `Purview.Results.SourceGenerator` - to `Purview.Results`. Remove any `using Purview.Results.SourceGenerator;` added for the attribute. +- The generated `[GenerateResult]` opt-in attribute moved from `Purview.Results.SourceGenerator` to + `Purview.Results`, so it sits alongside the types it opts into. Listed under + [Upgrade notes](#upgrade-notes). - **All four packages are now marked `IsAotCompatible`**, enabling `IsTrimmable` and both the trim and AOT analyzers, and all four are verified clean. For `Purview.Results` and `Purview.Results.ZodSharp` this independently confirms the "no reflection, no dynamic, no runtime type discovery" guarantee. @@ -95,6 +135,13 @@ been published to NuGet. pinned to `net11.0`. - `Results.UnitTests` multi-targets, so the core is now exercised on both runtimes — the suite went from 342 to 492 executions. +- **The `net10.0` mapping behaviour is now pinned by a test.** `AspNetCore.UnitTests` is itself pinned to + `net11.0` (it declares a union, which cannot compile earlier), so the `net10.0` branch of + `DefaultResultsHttpMapper.ResolveCases` could not be executed by the suite at all — the only coverage was the + end-to-end packed-consumer check. Two tests now assert the equivalence that branch relies on: a **non-union** + error type resolves as its own single case, both when it has a mapping and when it falls through to the + unmapped-failure response. That is precisely what `net10.0` always returns, so unwrapping that stopped + treating a non-union error as a single case now fails here instead of silently breaking `net10.0` consumers. - **Runtime async is scoped to `net11.0`.** `Features=runtime-async=on` was set repo-wide. On `net10.0` it lowers `await` to `System.Runtime.CompilerServices.AsyncHelpers`, which is evaluation-only and fails the build with `SYSLIB5007` — the API is explicitly subject to change or removal. A `net10.0` consumer @@ -112,14 +159,19 @@ been published to NuGet. - A new visitor interface and `IResultValue.AcceptSuccess` hand a result's successful value to infrastructure **with its static type intact**, without reflection. `SuccessValue` remains the right shape for inspection; use `AcceptSuccess` whenever the type matters, such as serialization. -- **Breaking for anyone implementing `IResultValue` themselves** (the two result types implement it in-box; - external implementations are not expected). +- The two result types implement `IResultValue` in-box, so this reaches a consumer only through a + hand-written implementation, which is not an expected shape. Listed under + [Upgrade notes](#upgrade-notes). - Async combinators now await with `ConfigureAwait(false)`, so they no longer resume on a captured - synchronization context. This affects consumers that relied on continuation back onto a UI or legacy - ASP.NET context. + synchronization context. Listed under [Upgrade notes](#upgrade-notes). ### Fixed +- **Registering the same validation-error rule factory twice is idempotent again.** `ZodResultsHttpOptions` + compared two factories by delegate identity, and a method group allocates a new delegate on every use — so + `MapCode(code, NotAValidationError)` registered twice was rejected as a conflict even though it answers + identically. Factories are now compared by the method they bind (and its target), so the same registration twice + keeps one rule while two different lambdas still throw. - **An array-typed union case crashed the generator and discarded every other union's output.** The discovery code explicitly handles `IArrayTypeSymbol` when checking accessibility and type parameters, then cast the case to `INamedTypeSymbol` — which an array is not — throwing `InvalidCastException`. With no diff --git a/Directory.Packages.props b/Directory.Packages.props index bb6bdd1..b6fada4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,18 +11,21 @@ something older. --> 11.0.0-rc.1.26425.128 - 10.0.12 + 10.0.13 - 2.0.2 - 1.0.1 + 2.1.0 + 1.1.0 - 1.0.0 + 1.1.1 5.9.0 - 1.68.17 + + 1.75.0 + 1.0.6 @@ -47,12 +50,15 @@ - + + + + diff --git a/Justfile b/Justfile index 9cd068a..f1084b5 100644 --- a/Justfile +++ b/Justfile @@ -11,8 +11,7 @@ build_configuration := "Debug" artifacts_folder := "./artifacts" default_test_filter := "/*/*/*/*" -pipeline_feed := "https://api.nuget.org/v3/index.json" -pipeline_tool := ".tools/purview-build/purview-build" +pipeline_tool := "dotnet purview-build" current_version := `bun -p "require('./package.json').version"` @@ -20,26 +19,24 @@ current_version := `bun -p "require('./package.json').version"` default: just --list -# Install the shared Purview.Build tool (authenticated to the Purview-Dev feed) if not present +# Restore the repository's .NET local tools (.config/dotnet-tools.json) [private] ensure-pipeline-tool: - if [ ! -x "{{ pipeline_tool }}" ]; then \ - dotnet tool install Purview.Build --tool-path .tools/purview-build --add-source "{{ pipeline_feed }}"; \ - fi + dotnet tool restore # Run the PR pipeline (restore, build, lint, tests) [group('Pipeline')] pipeline-pr *args: just ensure-pipeline-tool echo "Running PR pipeline..." - "{{ pipeline_tool }}" {{ args }} + {{ pipeline_tool }} {{ args }} # Run the build pipeline (restore, build, lint) [group('Pipeline')] pipeline-build *args: just ensure-pipeline-tool echo "Running build pipeline..." - "{{ pipeline_tool }}" --Build:RunTests=false --Release:Mode=None {{ args }} + {{ pipeline_tool }} --Build:RunTests=false --Release:Mode=None {{ args }} # Run the release pipeline (restore, build, lint, tests, pack, publish, GitHub release) [group('Pipeline')] @@ -47,7 +44,7 @@ pipeline-release *args: just ensure-pipeline-tool just lint-fix echo "Running release pipeline..." - "{{ pipeline_tool }}" --Release:Mode=NuGet {{ args }} + {{ pipeline_tool }} --Release:Mode=NuGet {{ args }} # Run the release pipeline (restore, build, lint, tests, pack, local nuget publish) # Note: `just` runs recipes through the shell, which strips backslashes from unquoted arguments. @@ -58,14 +55,14 @@ pipeline-local-release *args: just ensure-pipeline-tool just lint-fix echo "Running local release pipeline..." - "{{ pipeline_tool }}" --Release:Mode=LocalNuGet {{ args }} + {{ pipeline_tool }} --Release:Mode=LocalNuGet {{ args }} # Run the pipeline through pack + validate (restore, build, lint, tests, pack, validate pack contents) without publishing/releasing [group('Pipeline')] pipeline-pack-validate *args: just ensure-pipeline-tool echo "Running pack + validate pipeline..." - "{{ pipeline_tool }}" --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} + {{ pipeline_tool }} --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} # Displays the current version from package.json [group('Build and Test')] diff --git a/README.md b/README.md index c8999a9..3ae63cd 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Tenancy domain the Quick start uses, so one error-union vocabulary drives every | [`Examples.Zod`](src/src/Examples.Zod) | + `Purview.Results.ZodSharp` | A `[ZodSchema]` input validated into a result, where the rejection carries its `ValidationError`s, and a value-discarding `ToUnitResult` validation | | [`Examples.AspNetCore`](src/src/Examples.AspNetCore) | + `Purview.Results.AspNetCore` | `AddResultsHttp`/`Map`/`WithResultsHttp`, nested error-union leaf mapping, a successful unit result answering `204`, the mapping-gap and uninitialized-result paths | | [`Examples.AspNetCore.Zod`](src/src/Examples.AspNetCore.Zod) | + `Purview.Results.ZodSharp.AspNetCore` | A validation-carrying failure rendered as `HttpValidationProblemDetails`, with a case mapping winning over the fallback | -| [`Examples.ValueObjects.Zod`](src/src/Examples.ValueObjects.Zod) | `Purview.Results.ZodSharp`, `Purview.ValueObjects` | A `[Scalar]` value object whose type-level `[ZodRule]` owns its code and origin, validated into a result failure the HTTP layer can answer by origin | +| [`Examples.ValueObjects.Zod`](src/src/Examples.ValueObjects.Zod) | `Purview.Results.ZodSharp`, `Purview.ValueObjects` | An automatic `[Scalar]` value object whose type-level `[ZodRule]` owns its code and origin, validated into a result failure the HTTP layer can answer by origin | ```bash dotnet run --project src/src/Examples.Basic @@ -300,7 +300,7 @@ The full documentation suite lives in [`docs/wiki`](docs/wiki/Getting-Started.md | `src/src/AspNetCore` | Result-to-response mapping, endpoint filter and DI registration | | `src/src/ZodSharp` | ZodSharp `ValidationResult` bridge | | `src/src/ZodSharp.AspNetCore` | `HttpValidationProblemDetails` mapping for validation-carrying failures | -| `src/src//Sdk` | Package-only assets: `README.md` (packed as the package README) and any `Sdk/.agents/**` skills | +| `src/src//Sdk` | Package-only assets: `README.md` (packed as the package README) | | `src/tests` | TUnit unit tests, including source-generation and incremental-cache tests | | `src/src/Examples.*` | Runnable, non-packable examples: one project per integration aspect, built on the Tenant* domain | | `docs/wiki` | User-facing documentation suite, aggregated by the purview-dev website | @@ -322,7 +322,7 @@ just pack # pack all four packages to ./artifacts just pipeline-pr # the full PR pipeline (restore, build, lint, test, pack) ``` -`just pipeline-pr` installs the pinned `Purview.Build` tool to `.tools/purview-build` when it is missing. +`just pipeline-pr` restores the repository's .NET local tools, which include the pinned `Purview.Build`. Local CI-equivalent validation: ```bash diff --git a/docs/wiki/Agent-Skills.md b/docs/wiki/Agent-Skills.md index d06087b..a0be5be 100644 --- a/docs/wiki/Agent-Skills.md +++ b/docs/wiki/Agent-Skills.md @@ -1,22 +1,22 @@ # Agent Skills -The packages ship **agent skills** to consumers. A repository that imports `Purview.BuildSdk` receives them in -its own `.agents/` folder on the next restore or build, so AI agents working there get the guidance -automatically. The content is authored in this repository under `src/src//Sdk/.agents/**` and packed as -`.agents/**`; there is nothing to install by hand. +This repository authors **agent skills** so AI agents working with `Purview.Results` get the guidance +automatically. They are authored at `skills//SKILL.md` and installed with the skills CLI, which clones +this git repository — **no package ships them**. `Purview.BuildSdk` 1.0.6 removed skill packaging and the +build-time `.agents/` mirror, so the CLI is the only delivery route. -## Skills shipped by this repository +## Skills published by this repository -| Content | Package | Covers | -| --- | --- | --- | -| `skills/purview-results-core` | `Purview.Results` | The result type, its three states, combinators and the throw-on-misuse contract | -| `skills/purview-results-union-errors` | `Purview.Results` | Union modelling, the generated helpers, the diagnostics table, and the language rules that make the helper necessary | -| `skills/purview-results-http-mapping` | `Purview.Results.AspNetCore` | Result-to-HTTP mapping, the failure resolution order, and unmapped-failure `500`s | -| `skills/purview-results-zodsharp-validation` | `Purview.Results.ZodSharp` | ZodSharp validation flowing through results | -| `skills/purview-results-zodsharp-problems` | `Purview.Results.ZodSharp.AspNetCore` | Rendering validation-carrying failures as ProblemDetails | +| Skill | Covers | +| --- | --- | +| `skills/purview-results-core` | The result type, its three states, combinators and the throw-on-misuse contract | +| `skills/purview-results-union-errors` | Union modelling, the generated helpers, the diagnostics table, and the language rules that make the helper necessary | +| `skills/purview-results-http-mapping` | Result-to-HTTP mapping, the failure resolution order, and unmapped-failure `500`s | +| `skills/purview-results-zodsharp-validation` | ZodSharp validation flowing through results | +| `skills/purview-results-zodsharp-problems` | Rendering validation-carrying failures as ProblemDetails | -The `Purview.Results` package also ships the `purview-results-union-author` agent and the -`migrate-error-returns-to-result-unions` prompt. +Task-shaped detail lives in a skill's own `references/` folder — migrating existing code onto error unions is +`skills/purview-results-union-errors/references/migrating-error-returns.md`. ## When to load which skill @@ -28,9 +28,11 @@ The `Purview.Results` package also ships the `purview-results-union-author` agen ## Upstream skills -`Purview.BuildSdk` and the source-generator framework packages also deliver their own skills (project placement, -SDK configuration, generator authoring and testing, TUnit authoring) into the same `.agents/` tree. Those are -owned by the package that ships them — changes belong in the owning repository, not here. +`Purview.BuildSdk` and the source-generator framework repositories publish their own skills (project placement, +SDK configuration, generator authoring and testing, TUnit authoring) through the same CLI. Those are owned by +the repository that publishes them — changes belong there, not here. The CLI installs them into `.agents/skills/`, +which is gitignored; this repository's own skills are never installed into it, because two copies would drift +apart. ## Related diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index 494ac87..be7e7fd 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -10,6 +10,25 @@ GitHub Actions workflows in `.github/workflows/`. Consuming repositories own con `Purview.BuildSdk`. Never diverge a project's version by hand. Read the current line from `package.json` (`just version`) rather than from this page; a prerelease uses a `MAJOR.MINOR.PATCH-prerelease.N` suffix. +## Releasing a version + +A push to `main` publishes, so everything below has to be true *before* the merge. + +1. **Rename the changelog heading to the version being released.** `CHANGELOG.md`'s newest `## ` + heading is the one that ships; there is no `Unreleased` heading, precisely so a merge cannot publish an + unnamed release. Anything a consumer has to act on goes under that version's `### Upgrade notes`, each note + naming the old form, the new form and the edit to make. +2. **Move new diagnostics into a shipped block**, as [Analyzer release tracking](#analyzer-release-tracking) + describes. A rule stays recorded under the release that *introduced* it, so earlier blocks are never + rewritten. +3. **Bump the package-validation baseline.** `src/Directory.Build.targets` sets + `PackageValidationBaselineVersion` to the previous stable release; it must name the version currently on + NuGet, not the one being released. The build then fails `dotnet pack` on an unintended public-API change. An + intended one needs a `CompatibilitySuppressions.xml` beside the owning project + (`dotnet pack /p:GenerateCompatibilitySuppressionFile=true`) *and* a matching upgrade note — the two are each + other's evidence. +4. **Run `just pipeline-pr`**, which covers restore, build, lint, tests, pack and pack validation in one pass. + ## Workflows | Workflow | Trigger | Pipeline | @@ -17,14 +36,14 @@ GitHub Actions workflows in `.github/workflows/`. Consuming repositories own con | `.github/workflows/pr.yml` | pull requests against `main` (opened, synchronize, reopened, ready_for_review) | shared `purview-build.yml`, with `run-pack: true` and `validate-pack: true` | | `.github/workflows/release.yml` | push to `main` | shared `purview-release.yml` with `release-mode: NuGet` | -Both workflows pin `dotnet-version` to the SDK in `global.json` (`11.0.100-rc.1.26425.128`) — the shared workflow -defaults to the 10.0.x SDK, which cannot resolve a repository that targets `net11.0`. Keep the two workflow files -and `global.json` in sync. +Both workflows pin `dotnet-version` to the SDK in `global.json` — the shared workflow defaults to the 10.0.x SDK, +which cannot resolve a repository that targets `net11.0`. Keep the two workflow files and `global.json` in sync, +and read the pinned version from `global.json` rather than from this page. The release workflow packs, publishes to NuGet, and creates the `v` GitHub release only when that tag does not already exist. Do not create release tags or publish packages manually. -## `purview-build.json` +## `.config/purview-build.json` | Key | Value | | --- | --- | @@ -49,17 +68,20 @@ removed PDB, a renamed analyzer. `Purview.Results.SourceGenerator.CodeFixes.dll` and is never IL-merged into the generator. The generator is not published as a separate package. - The library packages ship `lib//.dll` plus the XML documentation file, and a symbol package. -- Every package ships its `README.md`, its `.agents/**` content and `purview-logo-light.png`. +- Every package ships its `README.md` and `purview-logo-light.png`. Agent skills are **not** packed — + `Purview.BuildSdk` 1.0.6 removed skill packaging, and they are delivered from the repository-root `skills/` + folder by the skills CLI instead. The framework assembly must never appear loose beside the merged analyzer — `ForbiddenContent` rejects `analyzers/**/Purview.SourceGeneratorFramework.dll`. ## Analyzer release tracking -The generator bundled in `Purview.Results` ships public diagnostics (`RSG1000`–`RSG1008`), so it maintains the -Roslyn release-tracking files. New or changed rules go in +The generator bundled in `Purview.Results` ships public diagnostics (`RSG1000`–`RSG1009` and `RSG9000`), so it +maintains the Roslyn release-tracking files. New or changed rules go in `src/src/SourceGenerator/AnalyzerReleases.Unshipped.md`, which the compiler's RS2008 catalogue validates during -the build. `RSG2000` is a *suppression* id, not a reported rule, so it must not appear there. +the build, and move into a `## Release ` block in `AnalyzerReleases.Shipped.md` when that version ships. +`RSG2000` is a *suppression* id, not a reported rule, so it must not appear in either file. ## Commands @@ -72,8 +94,8 @@ just pipeline-local-release # pack, publish to a local NuGet feed just version # the version package.json declares ``` -`pipeline-pr` and the other pipeline recipes install the pinned `Purview.Build` tool into `.tools/purview-build` -when it is missing. +`pipeline-pr` and the other pipeline recipes run `dotnet tool restore`, which installs the pinned +`Purview.Build` from `.config/dotnet-tools.json`. ## Testing the packed packages locally diff --git a/docs/wiki/Value-Objects-Composition.md b/docs/wiki/Value-Objects-Composition.md index 826015a..f46ddff 100644 --- a/docs/wiki/Value-Objects-Composition.md +++ b/docs/wiki/Value-Objects-Composition.md @@ -68,6 +68,33 @@ public readonly partial record struct AssetId } ``` +### Automatic and manual scalar forms + +`[Scalar]` is the *manual* form — you declare the underlying property. The *automatic* form, `[Scalar]` +(or `[Scalar(typeof(Guid))]`), has the value-object generator declare `public Guid Value { get; init; }`, and +Purview.ZodSharp 2.0.2+ generates the schema from that attribute even though the member is not visible to it: + +```csharp +[Scalar] +[ZodSchema] +[NonEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")] +public readonly partial record struct AssetId; +``` + +The automatic form is what a generator-declared property requires anyway: a property-level DataAnnotation has no +member to attach to, so the invariant has to be a **type-level** rule. Keep the manual form when property-level +DataAnnotations are needed. The two forms are mutually exclusive — declaring the generator-owned property yourself +is `VO1022`. + +A **nullable** scalar (`[Scalar(Nullable = true)]`, or a `T?` value type) round-trips JSON `null`, so its +schema must be null-tolerant. Use ZodSharp's shipped `[NullOrNonWhiteSpace]`, which accepts `null` but rejects an +empty or whitespace-only value, where `[RequiredZod]` (which always rejects `null`) cannot express the check. + +> **An escape hatch, not the pipeline.** A `[ZodSchema]` value object's generated `Create` throws `ZodException` +> on failure, and `TryCreate` catches it and returns `false`. That is the right shape for a call site that only +> needs a boolean — but expected rejections belong in the result pipeline, where the errors stay structured and +> the HTTP layer can render them. + ## The rejection as a result failure The case that carries the errors implements `IValidationErrorCarrier`, so the HTTP layer can render it without @@ -118,7 +145,7 @@ registration-ordered; see [ZodSharp Problem Details](ZodSharp-ProblemDetails.md) ## Runnable example [`Examples.ValueObjects.Zod`](https://github.com/purview-dev/results/tree/main/src/src/Examples.ValueObjects.Zod) -runs the whole composition: a `[Scalar]` tenant identifier whose type-level `[ZodRule]` owns +runs the whole composition: an automatic `[Scalar]` tenant identifier whose type-level `[ZodRule]` owns `Code = "invalid_tenant_id"` and `Origin = "value_object"`, validated into a `Result` failure and answerable by `MapOrigin("value_object", …)`. diff --git a/docs/wiki/ZodSharp-Integration.md b/docs/wiki/ZodSharp-Integration.md index 6b7917a..036fd1b 100644 --- a/docs/wiki/ZodSharp-Integration.md +++ b/docs/wiki/ZodSharp-Integration.md @@ -63,6 +63,22 @@ Result Validate(TenantInput input) => Use `ToResult` when the success has to carry the validated value, and `ToUnitResult` when it does not. +## Async validation + +`ValidateAsync` — on the generated schema, the `{Type}SchemaValidator` DI adapter, or a +`[ZodSchema(CustomValidationMethodName = ...)]` rule — returns a `ValueTask>`. +`ToResultAsync` and `ToUnitResultAsync` fold that into a result exactly as the synchronous methods fold `Validate`: + +```csharp +async Task> Validate(TenantInput input, CancellationToken cancellationToken) => + await TenantInputSchemaValidator + .ValidateAsync(input, cancellationToken) + .ToUnitResultAsync(errors => new TenantInputInvalid(input, errors)); +``` + +The error type is named explicitly for the same reason as the synchronous forms, and the failure factory runs only +when validation failed. + ## Carrying validation errors in the error value `IValidationErrorCarrier` is implemented by an error value that carries ZodSharp validation errors, so an HTTP @@ -90,6 +106,8 @@ map every validation-carrying error individually. | --- | --- | | `ValidationResult.ToResult(Func, TError> onFailure)` | Success carries the validated value; failure carries the created error. `onFailure` runs only when validation failed, so a successful validation allocates no error | | `ValidationResult.ToUnitResult(Func, TError> onFailure)` | The value-discarding counterpart: success is a unit `Result`, failure carries the created error | +| `ValueTask>.ToResultAsync(Func, TError> onFailure)` | The asynchronous counterpart, for a schema whose validation awaits | +| `ValueTask>.ToUnitResultAsync(Func, TError> onFailure)` | The value-discarding asynchronous counterpart | | `IValidationErrorCarrier` | Implemented by an error value that carries ZodSharp validation errors | A factory returning a **result** rather than an error is deliberately not offered: for a lambda returning diff --git a/global.json b/global.json index 047b1b2..f35b555 100644 --- a/global.json +++ b/global.json @@ -1,11 +1,11 @@ { "sdk": { - "version": "11.0.100-rc.1.26425.128", + "version": "11.0.100-rc.2.26504.105", "rollForward": "latestMajor", "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.4" + "Purview.BuildSdk": "1.0.6" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/package.json b/package.json index 58ceb30..3b13717 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "purview-results", - "version": "1.0.0.1", + "name": "results", + "version": "1.0.2", "license": "MIT", "private": true, "keywords": [], diff --git a/src/src/Results/Sdk/.agents/skills/purview-results-core/SKILL.md b/skills/purview-results-core/SKILL.md similarity index 100% rename from src/src/Results/Sdk/.agents/skills/purview-results-core/SKILL.md rename to skills/purview-results-core/SKILL.md diff --git a/src/src/AspNetCore/Sdk/.agents/skills/purview-results-http-mapping/SKILL.md b/skills/purview-results-http-mapping/SKILL.md similarity index 100% rename from src/src/AspNetCore/Sdk/.agents/skills/purview-results-http-mapping/SKILL.md rename to skills/purview-results-http-mapping/SKILL.md diff --git a/src/src/Results/Sdk/.agents/skills/purview-results-union-errors/SKILL.md b/skills/purview-results-union-errors/SKILL.md similarity index 98% rename from src/src/Results/Sdk/.agents/skills/purview-results-union-errors/SKILL.md rename to skills/purview-results-union-errors/SKILL.md index 988dcfe..ce6ea9a 100644 --- a/src/src/Results/Sdk/.agents/skills/purview-results-union-errors/SKILL.md +++ b/skills/purview-results-union-errors/SKILL.md @@ -8,6 +8,10 @@ description: "Use when modelling expected failures as a C# 15 union and converti Use this skill when a codebase models expected failures as a **union** and returns `Result` or the value-less `Result` from the `Purview.Results` package. +Replacing thrown domain exceptions, `bool`/`out` pairs or error enums in existing code? The +step-by-step playbook is in +[references/migrating-error-returns.md](references/migrating-error-returns.md). + ## The shape ```csharp diff --git a/skills/purview-results-union-errors/references/migrating-error-returns.md b/skills/purview-results-union-errors/references/migrating-error-returns.md new file mode 100644 index 0000000..b0405f4 --- /dev/null +++ b/skills/purview-results-union-errors/references/migrating-error-returns.md @@ -0,0 +1,67 @@ +# Migrating ad-hoc failure signalling to an error union + +A playbook for replacing thrown domain exceptions, `bool`/`out` pairs, sentinel return values and +error-code enums with a C# 15 `union` of case types returned as `Result`. + +## Before you start, collect + +- The file(s) being migrated, and the operation family they belong to — a union is per family, not + per method, so methods can come and go without changing the error contract. +- A name for the union (for example `TenantError`). +- The failure modes to model as cases, or the decision to infer them from the current code. +- Whether genuinely exceptional exits stay throwing. They normally should: a lost connection or a + violated invariant is not a domain outcome. + +## Requirements + +1. Declare one `readonly record struct` per failure mode, carrying the payload a caller needs to + react, and declare the union once: + + ```csharp + [GenerateResult] + public readonly union TenantError(TenantNotFound, TenantDisabled, TenantAlreadyExists); + ``` + +2. Change the affected signatures to `Result` — or the value-less + `Result` when the member has nothing to return on success — and produce failures with + `TenantError.Failure(case)` / `TenantError.Failure(case)`, or the per-case + `new Case(...).AsFailure()` / `AsFailure()` helpers. +3. Leave genuinely exceptional exits throwing. +4. Do **not** attempt implicit conversions from a case to a result, and do not add operators, + extension conversions or wrapper types: `CS0715`, `CS0556`, `CS9282`, `CS0246` and `CS0029` each + block that route. Use the generated factory or helper, or `(TenantError)new TenantNotFound(id)` + when a cast reads better. Prefer the union-receiver factory when a leaf case type is shared with + another union (`RSG1006`), because it names the union. +5. Remove the replaced mechanism — thrown domain exceptions, error enums, `out` error parameters, + sentinel values — rather than leaving both paths in place. +6. Keep the union free of transport concerns: no `IResult`, no status codes, no HTTP types. +7. One case type per meaning. A case carrying a `string Reason` or an `int Code` invites string or + int matching and defeats the point. +8. Compose unions rather than repeating leaf cases. A case type that is itself a union is an + *included union*: the including union's factory also covers its cases and builds the nested value, + and sharing a union-typed case across unions is not reported. This is how to avoid `RSG1006`, + which applies only to a shared **leaf** case. + +## Migration strategy + +- Inventory the failure modes and the call sites of each affected member. +- Introduce the cases and the union before changing signatures, so each edit compiles in isolation. +- Update call sites to `Match` / `Map` / `Bind` where a value has to come out of the result, rather + than `if (IsSuccess)`. +- Update or add tests: one per case, asserting the case the caller receives. +- Map the union once in the host — see the `purview-results-http-mapping` skill — instead of deciding + status codes in the domain. + +## Verification + +- Build the solution and confirm no `RSG1000`–`RSG1008` diagnostics. +- Run the affected tests. +- Confirm no remaining `throw` for an outcome the caller is expected to handle. +- Confirm the union appears in exactly one mapper, not scattered through handlers. +- Confirm no method can fall off the end of its body: `default` is a bug, not a failure. +- Reflect public API changes in the package documentation. + +## Report + +When the migration is done, state: the files changed, the union and its cases, the failure modes that +stayed exceptional and why, and the verification performed. diff --git a/src/src/ZodSharp.AspNetCore/Sdk/.agents/skills/purview-results-zodsharp-problems/SKILL.md b/skills/purview-results-zodsharp-problems/SKILL.md similarity index 100% rename from src/src/ZodSharp.AspNetCore/Sdk/.agents/skills/purview-results-zodsharp-problems/SKILL.md rename to skills/purview-results-zodsharp-problems/SKILL.md diff --git a/src/src/ZodSharp/Sdk/.agents/skills/purview-results-zodsharp-validation/SKILL.md b/skills/purview-results-zodsharp-validation/SKILL.md similarity index 84% rename from src/src/ZodSharp/Sdk/.agents/skills/purview-results-zodsharp-validation/SKILL.md rename to skills/purview-results-zodsharp-validation/SKILL.md index a20490a..83bc66c 100644 --- a/src/src/ZodSharp/Sdk/.agents/skills/purview-results-zodsharp-validation/SKILL.md +++ b/skills/purview-results-zodsharp-validation/SKILL.md @@ -67,6 +67,22 @@ Result Validate(TenantInput input) => Use `ToResult` when the success carries the validated value, `ToUnitResult` when it carries nothing, and the generated `AsFailure()` when the success carries something else. +## Async validation + +`ValidateAsync` returns a `ValueTask>`, and the async counterparts fold it in the same way: + +```csharp +async Task> Validate(TenantInput input, CancellationToken cancellationToken) => + await TenantInputSchemaValidator + .ValidateAsync(input, cancellationToken) + .ToUnitResultAsync(errors => new TenantInputInvalid(input, errors)); +``` + +- `ToResultAsync` carries the validated value on success; `ToUnitResultAsync` discards it. +- The error type is named explicitly for the same reason as the synchronous forms. +- Do not block on `ValidateAsync` to reuse the synchronous methods — that reintroduces the sync-over-async the + bridge exists to avoid. + ## Carrying the errors ```csharp @@ -97,7 +113,8 @@ results. Naming the error type explicitly keeps the intent unambiguous. ## Checklist 1. Validation outcomes are mapped, never thrown. -2. The error type is named explicitly (`ToResult(…)` or `ToUnitResult(…)`). +2. The error type is named explicitly (`ToResult(…)` or `ToUnitResult(…)`, and + the async counterparts for a `ValidateAsync` outcome). 3. The case that carries the errors implements `IValidationErrorCarrier`. 4. A method whose success type differs uses `AsFailure()`, or `AsFailure()` for a unit result, with the validation errors. diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index ab68169..f8b63df 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -1,3 +1,21 @@ + + + + true + 1.0.0.1 + diff --git a/src/Results.slnx b/src/Results.slnx index 78ac03a..90584fb 100644 --- a/src/Results.slnx +++ b/src/Results.slnx @@ -27,6 +27,7 @@ + diff --git a/src/settings.VisualStudio.json b/src/settings.VisualStudio.json new file mode 100644 index 0000000..194ba35 --- /dev/null +++ b/src/settings.VisualStudio.json @@ -0,0 +1,4 @@ +/* Visual Studio Settings File */ +{ + "csharpier.general.runonsave": true +} diff --git a/src/src/AspNetCore/ResultsHttpOptions.cs b/src/src/AspNetCore/ResultsHttpOptions.cs index cfa874f..cd40ece 100644 --- a/src/src/AspNetCore/ResultsHttpOptions.cs +++ b/src/src/AspNetCore/ResultsHttpOptions.cs @@ -38,7 +38,6 @@ public sealed class ResultsHttpOptions // ImmutableArray is a struct, so storing it as one would box on every access — once per failure, on // the request path. FrozenDictionary>? _frozenMappers; - IReadOnlyList>? _frozenFallbacks; /// /// Gets or sets the status code used when a result succeeded. Defaults to 200 OK. @@ -118,8 +117,11 @@ public sealed class ResultsHttpOptions /// consults them is a singleton shared by every request. A fallback added after the first failure is /// handled is therefore ignored rather than mutating a list another thread is enumerating. /// - public IReadOnlyList> Fallbacks => - _frozenFallbacks ??= ImmutableArray.CreateRange(_fallbacks); + public IReadOnlyList> Fallbacks + { + get => field ??= ImmutableArray.CreateRange(_fallbacks); + private set; + } /// /// Maps a case (or the error itself, for a non-union error type) onto a response. diff --git a/src/src/Examples.ValueObjects.Zod/TenantId.cs b/src/src/Examples.ValueObjects.Zod/TenantId.cs index f1adeb9..f78bcb3 100644 --- a/src/src/Examples.ValueObjects.Zod/TenantId.cs +++ b/src/src/Examples.ValueObjects.Zod/TenantId.cs @@ -70,16 +70,19 @@ sealed class NonEmptyTenantIdAttribute : ValidationAttribute /// The identifier of a tenant, as a scalar value object. /// /// -/// [Scalar] makes the value-object generator emit Create/Hydrate, and [ZodSchema] -/// makes ZodSharp's generator emit TenantIdSchema from the type-level rule below, so the value object's -/// own invariant is expressed as schema rules rather than hand-written guards. Hydrate never runs them, -/// which is what keeps rehydrating persisted values replay-safe. +/// +/// [Scalar<Guid>] is the automatic form: the value-object generator declares +/// public Guid Value { get; init; } and emits Create/Hydrate, and [ZodSchema] makes +/// ZodSharp's generator emit TenantIdSchema from the type-level rule below, so the value object's own +/// invariant is expressed as schema rules rather than hand-written guards. Hydrate never runs them, which +/// is what keeps rehydrating persisted values replay-safe. +/// +/// +/// A generator-declared property has no member for a property-level DataAnnotation to attach to, which is why +/// the invariant is a type-level rule rather than an attribute on a member. +/// /// -[Scalar] +[Scalar] [ZodSchema] [NonEmptyTenantId(Code = "invalid_tenant_id", Message = "A tenant id must not be empty.")] -readonly partial record struct TenantId -{ - /// Gets the underlying identifier value. - public Guid Value { get; } -} +readonly partial record struct TenantId { } diff --git a/src/src/Results/ResultTError.cs b/src/src/Results/ResultTError.cs index 6b47e66..4d0c3c4 100644 --- a/src/src/Results/ResultTError.cs +++ b/src/src/Results/ResultTError.cs @@ -65,7 +65,7 @@ TReturn IResultValue.AcceptSuccess(IResultValueVisitor diff --git a/src/src/Results/Results.csproj b/src/src/Results/Results.csproj index 9bf6fcf..0b35675 100644 --- a/src/src/Results/Results.csproj +++ b/src/src/Results/Results.csproj @@ -1,4 +1,4 @@ - + + + + + diff --git a/src/src/ZodSharp/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md index f2f2ff4..b80dc3e 100644 --- a/src/src/ZodSharp/Sdk/README.md +++ b/src/src/ZodSharp/Sdk/README.md @@ -47,17 +47,31 @@ return await ReconcileCoreAsync(validated.Value, repositories, cancellationToken | --- | --- | | `ValidationResult.ToResult(Func, TError> onFailure)` | Success carries the validated value; failure carries the created error. `onFailure` runs only when validation failed, so a successful validation allocates no error | | `ValidationResult.ToUnitResult(Func, TError> onFailure)` | The value-discarding counterpart: success is a unit `Result` and failure carries the created error. Use it when the method reports only why the input was rejected | +| `ValueTask>.ToResultAsync(Func, TError> onFailure)` | The asynchronous counterpart, for a schema whose validation awaits | +| `ValueTask>.ToUnitResultAsync(Func, TError> onFailure)` | The value-discarding asynchronous counterpart | | `IValidationErrorCarrier` | Implemented by an error value that carries ZodSharp validation errors, so an HTTP layer can turn it into a validation problem without knowing the error type | The error type must be named explicitly when it is a union, as in `ToResult(...)`, because a union case does not carry the -union type that contains it. `ToUnitResult` still names the validated value type for the same reason, even though -it discards it. +union type that contains it. `ToUnitResult` (and the async forms) still name the validated value type for the same +reason, even when the value is discarded. A factory returning a result rather than an error is deliberately **not** offered: for a lambda returning `Result` the compiler prefers a `Func<..., TError>` parameter and would silently nest the results. Naming the error type explicitly keeps the intent unambiguous. +## Async validation + +`ValidateAsync` — on the generated schema, the DI validator adapter, or a +`[ZodSchema(CustomValidationMethodName = ...)]` rule — returns a `ValueTask>`, and +`ToResultAsync`/`ToUnitResultAsync` fold that into a result exactly as the synchronous methods fold `Validate`: + +```csharp +return await TenantInputSchemaValidator + .ValidateAsync(input, cancellationToken) + .ToUnitResultAsync(errors => new TenantInputInvalid(input, errors)); +``` + ## Examples [`src/src/Examples.Zod`](https://github.com/purview-dev/results/tree/main/src/src/Examples.Zod) is a diff --git a/src/src/ZodSharp/ZodValidationResultExtensions.cs b/src/src/ZodSharp/ZodValidationResultExtensions.cs index a7d55dd..2545bce 100644 --- a/src/src/ZodSharp/ZodValidationResultExtensions.cs +++ b/src/src/ZodSharp/ZodValidationResultExtensions.cs @@ -115,4 +115,103 @@ Func, TError> onFailure return validation.IsSuccess ? Result.Success() : Result.Failure(onFailure(validation.Errors)); } + + /// + /// Converts an asynchronous validation result into a result whose failure carries the error produced by + /// . + /// + /// The type of the validated value. + /// + /// The error type of the result. A union type must be named explicitly, as in + /// ToResultAsync<RepositoryReconciliationResult, ReconciliationError>(...), because a union case + /// does not carry the union type that contains it. + /// + /// The validation result to convert, such as the one ValidateAsync returns. + /// + /// Creates the error from the validation errors. Only invoked when validation failed, so a successful + /// validation allocates no error. + /// + /// + /// A successful result carrying the validated value, or a failed result carrying the created error. + /// + /// + /// + /// The asynchronous counterpart of , for a schema whose validation + /// awaits — a [ZodSchema(CustomValidationMethodName = ...)] rule, or the DI adapter's + /// ValidateAsync. The validated value is carried on success exactly as the synchronous form carries it. + /// + /// + /// Because the method is asynchronous, the for a null + /// surfaces when the returned task is awaited rather than at the call site. + /// + /// + /// + /// + /// return await RepositoryReconciliationResultSchemaValidator + /// .ValidateAsync(reconciliationResult, cancellationToken) + /// .ToResultAsync<RepositoryReconciliationResult, ReconciliationError>(errors => + /// new ReconciliationResultInvalid(reconciliationResult, errors) + /// ); + /// + /// + /// + /// Thrown when is null, when the returned task is awaited. + /// + public static async ValueTask> ToResultAsync( + this ValueTask> validation, + Func, TError> onFailure + ) + { + ArgumentNullException.ThrowIfNull(onFailure); + + var validated = await validation.ConfigureAwait(false); + + return validated.IsSuccess + ? Result.Success(validated.Value) + : Result.Failure(onFailure(validated.Errors)); + } + + /// + /// Converts an asynchronous validation result into a unit result whose failure carries the error produced by + /// , discarding the validated value. + /// + /// The type of the validated value. + /// + /// The error type of the result. A union type must be named explicitly, as in + /// ToUnitResultAsync<TenantInput, TenantError>(...), because a union case does not carry the + /// union type that contains it. + /// + /// The validation result to convert, such as the one ValidateAsync returns. + /// + /// Creates the error from the validation errors. Only invoked when validation failed, so a successful + /// validation allocates no error. + /// + /// + /// A successful unit result, or a failed unit result carrying the created error. + /// + /// + /// + /// The asynchronous counterpart of : the method reports only why an + /// input was rejected, and the validated value is deliberately dropped, which is why the value type still has + /// to be named. Use when the success has to carry it. + /// + /// + /// Because the method is asynchronous, the for a null + /// surfaces when the returned task is awaited rather than at the call site. + /// + /// + /// + /// Thrown when is null, when the returned task is awaited. + /// + public static async ValueTask> ToUnitResultAsync( + this ValueTask> validation, + Func, TError> onFailure + ) + { + ArgumentNullException.ThrowIfNull(onFailure); + + var validated = await validation.ConfigureAwait(false); + + return validated.IsSuccess ? Result.Success() : Result.Failure(onFailure(validated.Errors)); + } } diff --git a/src/tests/AspNetCore.UnitTests/DefaultResultsHttpMapperTests.cs b/src/tests/AspNetCore.UnitTests/DefaultResultsHttpMapperTests.cs index c4b9729..7979a51 100644 --- a/src/tests/AspNetCore.UnitTests/DefaultResultsHttpMapperTests.cs +++ b/src/tests/AspNetCore.UnitTests/DefaultResultsHttpMapperTests.cs @@ -150,6 +150,67 @@ public async Task Map_GivenMapperForTheErrorType_MapsEveryCaseWithoutItsOwnMappi await Assert.That(response.Body).Contains("already exists"); } + /// + /// Pins the equivalence that lets Purview.Results.AspNetCore ship net10.0: union unwrapping in + /// DefaultResultsHttpMapper.ResolveCases is compiled for net11.0 only, and on net10.0 an + /// error is always its own single case. A non-union error must therefore resolve to exactly one case here too, + /// so both targets answer identically for every error a net10.0 consumer is able to declare. + /// + /// + /// This project is pinned to net11.0 because it declares a union, so the net10.0 branch cannot be + /// executed directly. Asserting the equivalence on net11.0 is what keeps the two in step: a change to + /// the unwrapping that stopped treating a non-union error as a single case would break net10.0 + /// consumers silently, and fails here instead. + /// + [Test] + public async Task Map_GivenNonUnionErrorType_ResolvesTheErrorAsItsOwnSingleCase() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(options => + options.Map(error => + TypedResults.Problem(statusCode: StatusCodes.Status418ImATeapot, title: error.Reason) + ) + ); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(Result.Failure(new NotAUnionError("brewing")), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status418ImATeapot); + await Assert.That(response.Body).Contains("brewing"); + } + + /// + /// A non-union error with no mapping of its own still reaches the unmapped-failure response rather than being + /// mistaken for a case of something else — the other half of the net10.0 equivalence above. + /// + [Test] + public async Task Map_GivenUnmappedNonUnionErrorType_ReturnsTheUnmappedFailureResponse() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(Result.Failure(new NotAUnionError("brewing")), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status500InternalServerError); + } + + /// + /// A plain error type, deliberately not a union and not a case of one: this is the only error shape a + /// net10.0 consumer can declare, because unions need .NET 11. + /// + sealed record NotAUnionError(string Reason); + [Test] public async Task Map_GivenCaseMappingAndErrorTypeMapping_PrefersTheCaseMapping() { diff --git a/src/tests/Repository.UnitTests/Repository.UnitTests.csproj b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj new file mode 100644 index 0000000..dcb35fb --- /dev/null +++ b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + false + + + + + + + diff --git a/src/tests/Repository.UnitTests/SkillTests.cs b/src/tests/Repository.UnitTests/SkillTests.cs new file mode 100644 index 0000000..8c7f585 --- /dev/null +++ b/src/tests/Repository.UnitTests/SkillTests.cs @@ -0,0 +1,60 @@ +using Purview.Skills.Testing; + +namespace Purview.Results; + +/// +/// Runs the Agent Skills conformance rule set against this repository's authored skills in +/// skills/. +/// +/// +/// +/// [InheritsTests] is load-bearing. TUnit does not walk base types when it discovers +/// tests, so without it this class compiles, the run is green, and nothing is validated. +/// +/// +/// There is no RepositoryRoot override: the default is the SDK's repository-root marker walk, +/// the same resolution InstallPurviewSkills uses, so an ordinary test run exercises it. +/// is what catches a walk that landed elsewhere - +/// every inherited rule passes vacuously against a catalogue that was never found. +/// +/// +[InheritsTests] +public sealed class SkillTests : PurviewSkillTests +{ + [Test] + public async Task SkillsFolder_ResolvesToThisRepository() + { + var catalogue = Evaluator.Catalogue; + + await Assert + .That(catalogue.FolderExists) + .IsTrue() + .Because($"the marker walk resolved '{catalogue.RepositoryRoot}', whose skills folder does not exist"); + + await Assert + .That(File.Exists(Path.Combine(catalogue.RepositoryRoot, "AGENTS.md"))) + .IsTrue() + .Because($"'{catalogue.RepositoryRoot}' does not look like this repository"); + + await Assert.That(catalogue.Skills).IsNotEmpty(); + } + + [Test] + public async Task EverySkill_IsNamedForThisProduct() + { + // Skills install into one flat directory per agent, shared with every skill a developer has + // installed from anywhere. An unprefixed name collides with somebody else's, and the loser is + // whichever installed second. + var unprefixed = Evaluator + .Catalogue.Skills.Select(skill => skill.FolderName) + .Where(name => !name.StartsWith("purview-results", StringComparison.Ordinal)) + .ToArray(); + + await Assert + .That(unprefixed) + .IsEmpty() + .Because( + "every skill this repository publishes must carry the product prefix: " + string.Join(", ", unprefixed) + ); + } +} diff --git a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs index 77608d1..eaf85b5 100644 --- a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs @@ -352,10 +352,7 @@ namespace Test // Act var result = await GenerateAsync( source, - new ResultsSourceGeneratorTestOptions - { - AnalyzerTypes = [typeof(Purview.Results.SourceGenerator.Analyzers.ResultsDiagnosticAnalyzer)], - }, + new ResultsSourceGeneratorTestOptions { AnalyzerTypes = [typeof(Analyzers.ResultsDiagnosticAnalyzer)] }, cancellationToken ); diff --git a/src/tests/ZodSharp.AspNetCore.UnitTests/ValueObjectRuleTestModels.cs b/src/tests/ZodSharp.AspNetCore.UnitTests/ValueObjectRuleTestModels.cs index e271e89..b9560ce 100644 --- a/src/tests/ZodSharp.AspNetCore.UnitTests/ValueObjectRuleTestModels.cs +++ b/src/tests/ZodSharp.AspNetCore.UnitTests/ValueObjectRuleTestModels.cs @@ -3,6 +3,7 @@ using Purview.ValueObjects.Serialization; using ZodSharp; using ZodSharp.Core; +using ZodSharp.Rules; namespace Purview.Results.ZodSharp.AspNetCore; @@ -66,11 +67,26 @@ sealed class NonEmptyValidatedIdAttribute : ValidationAttribute /// A Guid-backed scalar value object whose validation is driven entirely by a type-level custom rule, so its /// generated Create surfaces the rule's own code and origin while Hydrate stays replay-safe. /// -[Scalar] +/// +/// [Scalar<Guid>] is the automatic form — the value-object generator declares the underlying +/// property — and Purview.ZodSharp 2.0.2+ generates the schema from that attribute, so this exercises the +/// composition on the newest ValueObjects surface rather than the manual [Scalar] form. +/// +[Scalar] [ZodSchema] [NonEmptyValidatedId(Code = "invalid_tenant_id", Message = "A tenant id must not be empty.")] -readonly partial record struct ValidatedTenantId -{ - /// Gets the underlying identifier value. - public Guid Value { get; } -} +readonly partial record struct ValidatedTenantId { } + +/// +/// A nullable string scalar whose type-level rule is one of ZodSharp's built-in, generator-attributed rules, so +/// the schema accepts but rejects an empty or whitespace-only value. +/// +/// +/// A nullable scalar round-trips JSON , so its schema has to be null-tolerant — which is +/// what exists for, where [RequiredZod] (which always rejects +/// ) cannot express the check. +/// +[Scalar(Nullable = true)] +[ZodSchema] +[NullOrNonWhiteSpace] +readonly partial record struct NullableTenantAlias { } diff --git a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs index d8afde6..7db8da3 100644 --- a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs +++ b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs @@ -16,6 +16,8 @@ public sealed class ZodResultsFailureMapperTests const string MissingCode = "missing"; const string InvalidValueCategory = "invalid_value"; const string ValueObjectOrigin = "value_object"; + const string InvalidTenantIdCode = "invalid_tenant_id"; + const string NullableAliasInvalidCode = "invalid_string"; [Test] public async Task Map_GivenCodeRuleWithStatus_ReturnsTheValidationProblemWithThatStatus() @@ -259,6 +261,61 @@ public async Task Map_GivenAFailureFromAValueObjectRule_AnswersByOrigin() await Assert.That(body).Contains(ValueObjectOrigin); } + [Test] + public async Task Map_GivenAFailureFromANullableScalarRule_AnswersByItsCode() + { + // Arrange + // A nullable scalar round-trips null, so its type-level rule is the null-tolerant one: null validates and + // whitespace-only is rejected. The code it reports is enough for a code rule to answer the failure. + await using var services = CreateServices(zod => + zod.MapCode(NullableAliasInvalidCode, StatusCodes.Status422UnprocessableEntity) + ); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + await Assert.That(NullableTenantAliasSchema.Validate(NullableTenantAlias.Hydrate(null)).IsSuccess).IsTrue(); + + var validation = NullableTenantAliasSchema.Validate(NullableTenantAlias.Hydrate(" ")); + await Assert.That(validation.IsSuccess).IsFalse(); + + // Act + var result = validation.ToResult(errors => new InputRejected( + 7, + errors + )); + var (statusCode, body) = await ExecuteAsync(mapper.Map(result, context), context); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status422UnprocessableEntity); + await Assert.That(body).Contains(NullableAliasInvalidCode); + } + + [Test] + public async Task Map_GivenAUnitResultFailureFromAValueObjectRule_AnswersTheValidationProblem() + { + // Arrange + // A command that reports only why it was rejected folds the validation outcome into a unit result; the + // value being discarded must not cost the failure its validation response. + await using var services = CreateServices(zod => + zod.MapCode(InvalidTenantIdCode, StatusCodes.Status422UnprocessableEntity) + ); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + var validation = ValidatedTenantIdSchema.Validate(ValidatedTenantId.Hydrate(Guid.Empty)); + + // Act + var result = validation.ToUnitResult(errors => new InputRejected( + 7, + errors + )); + var (statusCode, body) = await ExecuteAsync(mapper.Map(result, context), context); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status422UnprocessableEntity); + await Assert.That(body).Contains(InvalidTenantIdCode); + } + [Test] public async Task MapOrigin_GivenEmptyOrigin_Throws() { @@ -291,6 +348,56 @@ public async Task MapOrigin_GivenTheSameOriginTwiceWithDifferentBehaviour_Throws await Assert.That(exception!.Message).Contains(ValueObjectOrigin); } + [Test] + public async Task Map_GivenDecliningOriginRule_FallsThroughToTheDefaultValidationProblem() + { + // Arrange + // The strict origin rule declines a mixed failure, so the failure keeps the default validation response. + await using var services = CreateServices(zod => + zod.MapOrigin( + ValueObjectOrigin, + (errors, _) => errors.Length == 1 && errors[0].Code == InvalidNameCode ? TypedResults.NotFound() : null + ) + ); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + // Act + var (statusCode, body) = await ExecuteAsync( + mapper.Map( + new InputRejected( + 7, + CreateOriginErrors(ValueObjectOrigin, InvalidNameCode, MissingCode) + ).AsFailure(), + context + ), + context + ); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status400BadRequest); + await Assert.That(body).Contains(MissingCode); + } + + [Test] + public async Task Map_GivenACarrierWithNoValidationErrors_ReturnsTheDefaultValidationProblem() + { + // Arrange + await using var services = CreateServices(zod => zod.MapCode(InvalidNameCode, StatusCodes.Status409Conflict)); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + // Act + var (statusCode, body) = await ExecuteAsync( + mapper.Map(new InputRejected(7, []).AsFailure(), context), + context + ); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status400BadRequest); + await Assert.That(body).DoesNotContain(InvalidNameCode); + } + [Test] public async Task Map_GivenNoMatchingRule_ReturnsTheDefaultValidationProblem() { @@ -479,6 +586,28 @@ public async Task MapCode_GivenTheSameCodeTwiceWithDifferentBehaviour_Throws() await Assert.That(exception!.Message).Contains(InvalidNameCode); } + [Test] + public async Task MapCode_GivenTheSameFactoryTwice_KeepsTheSingleRule() + { + // Arrange + // A method group allocates a new delegate on every use, so identity is not how "the same behaviour" is + // recognised: registering the same factory again is the same rule, not a conflicting one. + await using var services = CreateServices(zod => + zod.MapCode(InvalidNameCode, NotAValidationError).MapCode(InvalidNameCode, NotAValidationError) + ); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + // Act + var (statusCode, _) = await ExecuteAsync( + mapper.Map(new InputRejected(7, CreateErrors()).AsFailure(), context), + context + ); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status404NotFound); + } + [Test] public async Task MapCode_GivenTheSameCodeTwiceWithTheSameBehaviour_KeepsTheSingleRule() { @@ -570,6 +699,10 @@ static ImmutableArray CreateErrorsWithCategoryAndOrigin(string } } + /// The factory the same-factory-twice test registers, named so it is one method group. + static IResult NotAValidationError(ImmutableArray errors, HttpContext context) => + TypedResults.NotFound(); + static DefaultHttpContext CreateContext(IServiceProvider services) => new() { Response = { Body = new MemoryStream() }, RequestServices = services }; diff --git a/src/tests/ZodSharp.UnitTests/ZodValidationResultExtensionsTests.cs b/src/tests/ZodSharp.UnitTests/ZodValidationResultExtensionsTests.cs index 396600f..77220a9 100644 --- a/src/tests/ZodSharp.UnitTests/ZodValidationResultExtensionsTests.cs +++ b/src/tests/ZodSharp.UnitTests/ZodValidationResultExtensionsTests.cs @@ -162,4 +162,135 @@ public async Task ToUnitResult_GivenNullFailureFactory_ThrowsArgumentNullExcepti // Act & Assert await Assert.That(() => validation.ToUnitResult(null!)).Throws(); } + + [Test] + public async Task ToResultAsync_GivenSuccessfulValidation_ReturnsTheValidatedValueWithoutInvokingTheFactory() + { + // Arrange + var validation = ValueTask.FromResult(ValidationResult.Success(42)); + var factoryInvocations = 0; + + // Act + var result = await validation.ToResultAsync(errors => + { + factoryInvocations++; + return errors.Length; + }); + + // Assert + await Assert.That(result.IsInitialized).IsTrue(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(42); + await Assert.That(factoryInvocations).IsEqualTo(0); + } + + [Test] + public async Task ToResultAsync_GivenFailedValidation_MapsTheErrorValue() + { + // Arrange + var validation = ValueTask.FromResult( + ValidationResult.Failure(ValidationError.Create("too_small", "Value is too short.", ["Value"])) + ); + + // Act + var result = await validation.ToResultAsync(errors => errors.Single()); + + // Assert + await Assert.That(result.IsInitialized).IsTrue(); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error.Code).IsEqualTo("too_small"); + await Assert.That(result.Error.Message).IsEqualTo("Value is too short."); + await Assert.That(result.Error.Path).IsEquivalentTo(["Value"]); + } + + [Test] + public async Task ToResultAsync_GivenFailedValidation_KeepsEveryError() + { + // Arrange + var errorsSeenByFactory = 0; + var validation = ValueTask.FromResult( + ValidationResult.Failure([ + ValidationError.Create("first", "The first failure.", []), + ValidationError.Create("second", "The second failure.", []), + ]) + ); + + // Act + var result = await validation.ToResultAsync(errors => + { + errorsSeenByFactory = errors.Length; + return errors.Length; + }); + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error).IsEqualTo(2); + await Assert.That(errorsSeenByFactory).IsEqualTo(2); + } + + [Test] + public async Task ToResultAsync_GivenNullFailureFactory_ThrowsArgumentNullException() + { + // Arrange + var validation = ValueTask.FromResult(ValidationResult.Success(1)); + + // Act & Assert + await Assert + .That(async () => await validation.ToResultAsync(null!)) + .Throws(); + } + + [Test] + public async Task ToUnitResultAsync_GivenSuccessfulValidation_ReturnsAUnitSuccessWithoutInvokingTheFactory() + { + // Arrange + var validation = ValueTask.FromResult(ValidationResult.Success(42)); + var factoryInvocations = 0; + + // Act + var result = await validation.ToUnitResultAsync(errors => + { + factoryInvocations++; + return new OperationRejected(new Operation(), errors); + }); + + // Assert + await Assert.That(result.IsInitialized).IsTrue(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(factoryInvocations).IsEqualTo(0); + } + + [Test] + public async Task ToUnitResultAsync_GivenFailedValidation_ReturnsAUnitFailureCarryingTheCreatedError() + { + // Arrange + var validation = ValueTask.FromResult( + ValidationResult.Failure(ValidationError.Create("too_small", "Value is too short.", ["Value"])) + ); + + // Act + var result = await validation.ToUnitResultAsync(errors => new OperationRejected( + new Operation(), + errors + )); + + // Assert + await Assert.That(result.IsInitialized).IsTrue(); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error is OperationRejected).IsTrue(); + } + + [Test] + public async Task ToUnitResultAsync_GivenNullFailureFactory_ThrowsArgumentNullException() + { + // Arrange + var validation = ValueTask.FromResult(ValidationResult.Success(1)); + + // Act & Assert + await Assert + .That(async () => await validation.ToUnitResultAsync(null!)) + .Throws(); + } } diff --git a/src/tests/ZodSharp.UnitTests/ZodValidationResultIntegrationTests.cs b/src/tests/ZodSharp.UnitTests/ZodValidationResultIntegrationTests.cs index 1b7b62f..6648da6 100644 --- a/src/tests/ZodSharp.UnitTests/ZodValidationResultIntegrationTests.cs +++ b/src/tests/ZodSharp.UnitTests/ZodValidationResultIntegrationTests.cs @@ -188,4 +188,59 @@ public async Task ToResult_GivenConsistentAggregate_SucceedsWithTheValidatedAggr await Assert.That(result.IsSuccess).IsTrue(); await Assert.That(result.Value).IsEqualTo(operationResult); } + + [Test] + public async Task ToResultAsync_GivenTheAsyncValidator_ProducesTheSameRejectionAsTheSynchronousSchema( + CancellationToken cancellationToken + ) + { + // Arrange + // The generated validator adapter is the DI-friendly shape, whose ValidateAsync is what a hosted + // service resolves; its outcome must fold into a result exactly like the synchronous schema's. + var operation = Operation.Create(null); + + // Act + var result = await new OperationSchemaValidator() + .ValidateAsync(operation, cancellationToken) + .ToResultAsync(errors => new OperationRejected(operation, errors)); + + // Assert + await Assert.That(result.IsInitialized).IsTrue(); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.IsSuccess).IsFalse(); + + OperationRejected? rejected = result.Error switch + { + OperationRejected value => value, + _ => null, + }; + + await Assert.That(rejected).IsNotNull(); + await Assert.That(rejected!.Value.Operation).IsEqualTo(operation); + await Assert.That(rejected.Value.Errors[0].Path).Contains(nameof(Operation.Name)); + } + + [Test] + public async Task ToResultAsync_GivenTheAsyncValidatorAndAValidOperation_SucceedsWithoutInvokingTheFactory( + CancellationToken cancellationToken + ) + { + // Arrange + var operation = Operation.Create("reconcile"); + var factoryInvocations = 0; + + // Act + var result = await new OperationSchemaValidator() + .ValidateAsync(operation, cancellationToken) + .ToResultAsync(errors => + { + factoryInvocations++; + return new OperationRejected(operation, errors); + }); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(operation); + await Assert.That(factoryInvocations).IsEqualTo(0); + } }