diff --git a/AGENTS.md b/AGENTS.md index c6678d9..1a00e57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ makes C# 15 union error cases ergonomic, and the ZodSharp and ASP.NET Core integ | `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/tests` | TUnit unit tests, including source-generation and incremental-cache tests | | `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 | | `Directory.Packages.props` | Centrally managed NuGet versions | | `src/Directory.Build.props` / `src/Directory.Build.targets` | Solution-wide SDK, package and build behaviour | | `global.json` | Required .NET SDK, `Purview.BuildSdk` and Microsoft.Testing.Platform selection | @@ -243,6 +244,10 @@ document: `Examples.Basic` (the result type and the generated helpers), `Example - Keep each package `Sdk/README.md`, the repository-root `README.md`, `AGENTS.md` and the code aligned. If a change alters diagnostics, build properties, defaults, resolution order or public API, change all of them in the same commit. +- `docs/wiki` is the user-facing documentation suite the purview-dev website aggregates + (`source: github-path`, `path: docs/wiki`, landing page `Getting-Started.md`). Keep the affected page in step + with the code in the same commit, keep `_Sidebar.md`'s order matching the pages that exist, and keep every page + to a single top-level `#` heading because the site derives the page title from the first one. - Examples must compile conceptually against the current public API. Use placeholders for credentials and environment-specific values. @@ -376,8 +381,8 @@ Before handing work back: - Confirm only intended files changed. - Review public API, package-content and dependency-direction implications. - Add or update focused tests for code changes, including incremental-cache coverage for pipeline changes. -- Update the affected package `Sdk/README.md`, the root `README.md`, `AGENTS.md` and `AnalyzerReleases` when the - change affects them. +- Update the affected package `Sdk/README.md`, the root `README.md`, `docs/wiki`, `AGENTS.md` and + `AnalyzerReleases` when the change affects them. - Watch for the packaging traps: `true` missing from a new package project, a new union-declaring file missing from `.csharpierignore`, and a new diagnostic missing from `AnalyzerReleases.Unshipped.md`. diff --git a/README.md b/README.md index 8ba3c5c..38d5493 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,18 @@ the host mapping it — unless a rule answers one of its codes or categories: } ``` +## Documentation + +The full documentation suite lives in [`docs/wiki`](docs/wiki/Getting-Started.md): + +- [Getting started](docs/wiki/Getting-Started.md) +- [Core concepts](docs/wiki/Core-Concepts.md) and [combinators](docs/wiki/Combinators.md) +- [Union errors](docs/wiki/Union-Errors.md) +- [Source generator](docs/wiki/Source-Generator.md) and [diagnostics](docs/wiki/Diagnostics.md) +- [ASP.NET Core integration](docs/wiki/AspNetCore-Integration.md) +- [ZodSharp integration](docs/wiki/ZodSharp-Integration.md) and [problem details](docs/wiki/ZodSharp-ProblemDetails.md) +- [Guarantees and limitations](docs/wiki/Guarantees-and-Limitations.md) + ## Requirements - **.NET 11 SDK or later** — the runtime packages target `net11.0`; the source generator targets @@ -224,6 +236,7 @@ the host mapping it — unless a rule answers one of its codes or categories: | `src/src//Sdk` | Package-only assets: `README.md` (packed as the package README) and any `Sdk/.agents/**` skills | | `src/tests` | TUnit unit tests, including source-generation and incremental-cache tests | | `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 | | `Directory.Packages.props` | Centrally managed NuGet versions | | `src/Directory.Build.props` / `src/Directory.Build.targets` | Solution-wide SDK, package and build behaviour | | `global.json` | Required .NET SDK, `Purview.BuildSdk` and Microsoft.Testing.Platform selection | diff --git a/docs/wiki/Agent-Skills.md b/docs/wiki/Agent-Skills.md new file mode 100644 index 0000000..6519f3e --- /dev/null +++ b/docs/wiki/Agent-Skills.md @@ -0,0 +1,38 @@ +# 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. + +## Skills shipped 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.SourceGenerator` | 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 | + +The generator package also ships the `purview-results-union-author` agent and the +`migrate-error-returns-to-result-unions` prompt. + +## When to load which skill + +- 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` + +## 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. + +## Related + +- [Contributing](Contributing.md) — where the authored content lives in this repository. +- [Source Generator](Source-Generator.md) — packaging and distribution of the component. diff --git a/docs/wiki/AspNetCore-Integration.md b/docs/wiki/AspNetCore-Integration.md new file mode 100644 index 0000000..32509b0 --- /dev/null +++ b/docs/wiki/AspNetCore-Integration.md @@ -0,0 +1,148 @@ +# ASP.NET Core Integration + +`Purview.Results.AspNetCore` maps a `Result` onto an ASP.NET Core response, so an endpoint can +return a result and let the host decide what each error case looks like on the wire. + +## Installation + +```bash +dotnet add package Purview.Results.AspNetCore +``` + +## Quick start + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddResultsHttp(options => options + .Map(error => TypedResults.NotFound()) + .Map(error => TypedResults.Problem(statusCode: StatusCodes.Status403Forbidden)) + .Map(error => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict)) // every unmapped case + .AddFallback((error, context) => error is null ? null : TypedResults.Problem()) +); + +var app = builder.Build(); + +app.MapGet("/tenants/{id:int}", (int id) => GetTenant(id)).WithResultsHttp(); +``` + +`AddResultsHttp` also registers problem-details services (`AddProblemDetails()`), so the unmapped-failure and +uninitialized-result paths work without further host setup. It uses `TryAddSingleton` for `IResultsHttpMapper` and +`ResultsEndpointFilter`, so a host can register its own mapper first and replace the defaults. + +`WithResultsHttp()` exists on both `RouteHandlerBuilder` and `RouteGroupBuilder`. The endpoint filter is +non-invasive: a handler that returns something other than an `IResultValue` is left untouched. + +## How a result becomes a response + +**Success** — the value is serialized with `SuccessStatusCode` (`200 OK` by default). A result whose successful +value is itself an `IResult` is passed through untouched, and `SuccessMapper` overrides both when set. + +**Failure** — the error is resolved to its *case* value (the active case of a union error, or the error itself for +a non-union error), then mapped in this order: + +1. a mapping registered for the **case** type — `Map(...)` +2. a mapping registered for the **error** type — `Map(...)`, which handles every case without its own + mapping +3. the **fallback stage**, in registration order: `AddFallback(...)` delegates and `AddFailureMapper()` + mappers share one ordered list; a fallback or mapper returns `null` to defer to the next entry +4. a `ProblemDetails` response using `UnmappedStatusCode` (`500`), `UnmappedTitle`, and an `errorType` extension + naming the unmapped case — or an `InvalidOperationException` when `ThrowOnUnmappedFailure` is set + +An **uninitialized** result (`default`) takes the same path and is logged, because an endpoint returning `default` +is a host bug rather than a domain outcome. + +## Options + +| Option | Default | Purpose | +| --- | --- | --- | +| `SuccessStatusCode` | `200` | Status code for a serialized successful value | +| `SuccessMapper` | `null` | Replaces the default success handling entirely | +| `UnmappedStatusCode` | `500` | Status code for a failure with no mapping | +| `UnmappedTitle` | *"The operation failed with an error that is not mapped to an HTTP response."* | Title of the unmapped `ProblemDetails` | +| `ThrowOnUnmappedFailure` | `false` | Throw instead of producing a problem response; useful during development | +| `IncludeTraceId` | `true` | Whether problem responses this package writes itself carry the request trace identifier | +| `Map(Func)` | — | Maps a case (or the error itself) to a response | +| `Map(Func)` | — | Same, with access to the request | +| `AddFallback(Func)` | — | Consulted in order for unmapped failures, with the case value | +| `AddFailureMapper()` | — | Same stage, for a mapper class resolved from dependency injection | + +Registering the same type twice replaces the earlier mapping. + +## Choosing an extension point + +| The rule needs… | Use | +| --- | --- | +| One answer per error or case **type** | `Map(...)` | +| The **value** the failure carries — a validation code, a category, a field | `IResultsFailureMapper` via `AddFailureMapper()` | +| A quick inline rule, with no dependencies | `AddFallback((error, context) => ...)` | +| To replace the whole pipeline | Your own `IResultsHttpMapper` | + +A failure mapper is a **shape** rule, not a catch-all: + +```csharp +public sealed class BlankIdentifierMapper : IResultsFailureMapper +{ + public IResult? Map(ResultsFailureContext context) => + context.Case is ITenantFailure { TenantId.Value: var id } && string.IsNullOrWhiteSpace(id) + ? TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "An identifier is required.") + : null; // defer: the case mappings and the other fallbacks still apply +} + +builder.Services.AddSingleton(); +builder.Services.AddResultsHttp(options => options + .Map(_ => TypedResults.NotFound()) + .AddFailureMapper()); +``` + +`ResultsFailureContext` carries the failure's **case** (the active case of a union error, or the error itself), +the **error** the result carries, and the request. A mapper is resolved from the failing request's services the +first time it is needed, so it may take its own dependencies in its constructor; register it before the first +request. A failure that reaches an unregistered mapper throws an `InvalidOperationException` naming the +registration that is missing. + +Answering every failure in a mapper — with a generic problem or a `202`, for example — turns a mapping gap in the +host into a plausible-looking response, which is exactly what the unmapped-failure path exists to expose. Map the +shapes you can name and return `null` for the rest. + +## Converting a result by hand + +```csharp +app.MapGet("/tenants/{id:int}", (int id, HttpContext context) => + GetTenant(id).ToHttpResult(context)); +``` + +`ToHttpResult(HttpContext)` resolves `IResultsHttpMapper` from the request services; the +`ToHttpResult(IResultsHttpMapper, HttpContext)` overload takes one directly. + +## Extensibility + +`IResultsHttpMapper` is registered with `AddResultsHttp` as `DefaultResultsHttpMapper` via `TryAddSingleton`, so a +host can register its own implementation first to replace the defaults entirely. A host that replaces it also +bypasses `ResultsHttpOptions` — including the failure mappers — so prefer `Map`, `AddFallback` and +`AddFailureMapper` unless the pipeline itself has to change. + +## Example responses + +Running `Examples.AspNetCore`: + +| Request | Response | +| --- | --- | +| `GET /tenants/acme` | `200 OK` with the tenant | +| `GET /tenants/initech` | `404 Not Found` — the mapping for the `TenantNotFound` case | +| `GET /tenants/globex/usage` | `403 Forbidden` — the mapping for the `TenantDisabled` case | +| `POST /tenants/acme` | `409 Conflict` — the mapping for the `TenantError` error type | +| `GET /tenants/broken` | `500` with an `errorType` extension, because an endpoint returning `default` is a host bug | + +```bash +dotnet run --project src/examples/Examples.AspNetCore --urls http://localhost:5215 +``` + +## Related + +- [ZodSharp Problem Details](ZodSharp-ProblemDetails.md) — validation-carrying failures rendered as + `HttpValidationProblemDetails`. This package deliberately knows nothing about ZodSharp. +- [Getting Started](Getting-Started.md) — the end-to-end walkthrough. diff --git a/docs/wiki/Combinators.md b/docs/wiki/Combinators.md new file mode 100644 index 0000000..8ce8cb1 --- /dev/null +++ b/docs/wiki/Combinators.md @@ -0,0 +1,92 @@ +# Combinators + +The combinators fold a result into a value or chain another operation, without ever inspecting a state that is +not there. All of them throw `InvalidOperationException` (`"The result is uninitialized."`) for `default`; the +throwing input check for a `null` delegate is `ArgumentNullException`. + +## Transforming + +| Member | On success | On failure | On `default` | +| --- | --- | --- | --- | +| `Match(success, failure)` | `success(value)` | `failure(error)` | throws | +| `Map(map)` | `Result.Success(map(value))` | the existing error, unchanged | throws | +| `Bind(bind)` | the result `bind` returns | the existing error, unchanged | throws | +| `MapError(map)` | the value, unchanged | `Result.Failure(map(error))` | throws | + +```csharp +Result name = GetTenant(tenantId).Map(tenant => tenant.Name); + +Result created = GetTenant(tenantId) + .Bind(tenant => store.CreateTenant(new TenantId("newco"), tenant.Name)); + +Result renamed = GetTenant(tenantId) + .MapError(error => error.ToString()); +``` + +`Map` and `Bind` preserve the error type; `MapError` preserves the value. `Bind` is the operation to reach for +when the next step can fail with the **same** error type — otherwise `MapError` first or a `Match` is clearer. + +## Constraining a success + +`Ensure(predicate, errorFactory)` turns a successful value that fails the predicate into a failure: + +```csharp +Result enabled = GetTenant(tenantId) + .Ensure(tenant => tenant.Enabled, tenant => new TenantDisabled(tenant.Id)); +``` + +The `errorFactory` is invoked only when the predicate fails, so a satisfied constraint allocates no error. A +failure and its error pass through unchanged. + +## Probing + +`TryGetValue` and `TryGetError` never throw — not even for `default`: + +```csharp +if (result.TryGetValue(out Tenant? tenant)) + Console.WriteLine(tenant.Name); + +if (result.TryGetError(out TenantError error)) + Console.WriteLine(error); +``` + +## Fallbacks and alternatives + +| Member | Purpose | +| --- | --- | +| `GetValueOrDefault()` | The successful value, or `default` | +| `GetValueOrDefault(TValue fallback)` | The successful value, or `fallback` | +| `GetValueOrDefault(Func fallbackFactory)` | The successful value, or a lazily produced fallback | +| `GetErrorOrDefault(TError fallback)` | The error, or `fallback` | +| `OrElse(Result fallback)` | This result when successful, otherwise `fallback` | + +Each of these still throws for `default`, because an uninitialized result is a bug rather than an ordinary +failure. Use them after a state has been established (or after probing). + +## Side effects + +| Member | Purpose | +| --- | --- | +| `Switch(Action success, Action failure)` | Invoke the matching action and discard the value | +| `Tap(Action success)` | Observe a success and return the result unchanged | +| `TapError(Action failure)` | Observe a failure and return the result unchanged | + +`Tap` and `TapError` leave the result untouched, which keeps them usable in the middle of a chain. + +## Asynchronous composition + +```csharp +Task> dto = GetTenantAsync(tenantId) + .MapAsync(tenant => _mapper.MapAsync(tenant)); + +Task> saved = GetTenantAsync(tenantId) + .BindAsync(tenant => _store.SaveAsync(tenant)); +``` + +`MapAsync` and `BindAsync` behave exactly like their synchronous counterparts, and both throw for `default`. +They are extension methods in `ResultExtensions`, not members on the result type. + +## Related + +- [Core Concepts](Core-Concepts.md) — the state contract these operations obey. +- [Union Errors](Union-Errors.md) — producing the failure value a combinator carries. diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md new file mode 100644 index 0000000..e27a2a5 --- /dev/null +++ b/docs/wiki/Contributing.md @@ -0,0 +1,110 @@ +# Contributing + +Contributions are welcome. Open an issue or a pull request against +[purview-dev/results](https://github.com/purview-dev/results). + +## Repository layout + +``` +src/ + Results.slnx Canonical solution for restore, build, test and pack + src/ + Results/ Result, Result factories, IResultValue + SourceGenerator/ Roslyn incremental generator + analyzer + [GenerateResult] + SourceGenerator.CodeFixes/ CS0029 code fix for returning a bare union case + AspNetCore/ Result-to-response mapping, endpoint filter, DI registration + ZodSharp/ ZodSharp ValidationResult bridge + ZodSharp.AspNetCore/ Validation-problem mapping for validation-carrying failures + Examples.Basic/ Runnable examples, one project per integration aspect + Examples.Zod/ + Examples.AspNetCore/ + Examples.AspNetCore.Zod/ + /Sdk/ Package-only assets: README.md and .agents/** skills + tests/ TUnit unit tests (one *.UnitTests project per package) +docs/wiki/ This documentation suite +Directory.Packages.props Centrally managed NuGet versions +purview-build.json Shared pipeline configuration +package.json Authoritative repository and package version +``` + +## Commands + +```bash +dotnet tool restore +just restore +just build +just test-unit +just lint-check +just lint-fix +just pack +just pipeline-pr +``` + +Local CI-equivalent validation: + +```bash +dotnet restore src/Results.slnx +dotnet build src/Results.slnx --no-restore +dotnet test src/Results.slnx --no-build --treenode-filter "/*/*/*/*[Category=Unit]" +dotnet csharpier check . +bun run lint:slnx:check +``` + +`just lint-fix` runs CSharpier and the `.slnx` GUID cleanup. Formatting is enforced with CSharpier using the +root `.editorconfig`. + +## Working agreement + +1. Read `AGENTS.md`, inspect the working tree, and locate the implementation, tests, documentation and existing + patterns relevant to the change. +2. Confirm behaviour from code and tests rather than relying on memory or documentation alone. +3. Make the smallest coherent change; preserve public behaviour unless the task explicitly changes it. +4. Update tests for fixes and behaviour changes, and documentation when public behaviour changes. +5. Run the narrowest meaningful validation first, then broader validation in proportion to risk. +6. Review the diff for unrelated edits, generated noise, compatibility risks and missing docs or tests. + +## Commit conventions + +Commits follow [Conventional Commits](https://www.conventionalcommits.org/), enforced by Lefthook and Commitlint +(`.config/lefthook.yml`, `commitlint.config.mts`). Allowed types: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, +`perf`, `refactor`, `revert`, `style`, `test`. + +## Quality gates + +- `just build` succeeds with no new warnings or errors. +- The relevant tests pass (`just test-unit`). +- `just lint-check` reports no formatting or `.slnx` changes. +- Packed packages match `purview-build.json` `PackValidation` (`just pipeline-pack-validate`). +- Generated code stays deterministic and reviewable. + +## Documentation + +This wiki lives in `docs/wiki/`. The purview-dev site build pulls these files through `github-path` aggregation +and rewrites relative `.md` links into site routes. + +Conventions: + +- `_Sidebar.md` declares page order; it is parsed by the sync and never rendered (as are `Home.md` and + `index.md`, which the catalogue project excludes). +- Every page starts with a single `# ` heading followed by a one-paragraph description. +- Link to other pages with relative `.md` links: `[Getting Started](Getting-Started.md)`. +- GitHub alert blockquotes (`> [!NOTE]`, `> [!TIP]`, `> [!WARNING]`, `> [!CAUTION]`, `> [!IMPORTANT]`) become + Starlight asides. +- Relative repository-path links are rewritten to GitHub blob URLs automatically. +- Keep `README.md`, each package's `Sdk/README.md`, `AGENTS.md` and this wiki aligned with actual behaviour. If a + change alters diagnostics, build properties, defaults, resolution order or public API, change all of them in the + same commit. + +## Packaging traps + +- A project is packable **only** when its own `.csproj` declares `true`. +- A new union-declaring file must be added to `.csharpierignore`, because the preview syntax is not parseable. +- A new diagnostic must be added to `AnalyzerReleases.Unshipped.md`. +- A property the generator reads must be declared as a `CompilerVisibleProperty` and shipped in + `Sdk/buildTransitive/*.props`. + +## Related + +- [Testing](Testing.md) — the TUnit conventions and source-generator testing approach. +- [Release Flow](Release-Flow.md) — versioning, workflows and pack validation. +- [Agent Skills](Agent-Skills.md) — the guidance the packages ship to consumers. diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md new file mode 100644 index 0000000..6d22d97 --- /dev/null +++ b/docs/wiki/Core-Concepts.md @@ -0,0 +1,90 @@ +# Core Concepts + +`Result` is a `readonly record struct` with three states. Everything else in the suite — the +generator, the HTTP adapter, the ZodSharp bridge — is built on the behaviour described here. + +## The three states + +| State | How it is reached | `IsInitialized` | `IsSuccess` | `IsFailure` | +| --- | --- | --- | --- | --- | +| `Uninitialized` | `default(Result)` | `false` | `false` | `false` | +| `Success` | `Result.Success(value)` | `true` | `true` | `false` | +| `Failure` | `Result.Failure(error)` | `true` | `false` | `true` | + +`IsSuccess` and `IsFailure` carry `[MemberNotNullWhen]`, so reading `Value` after an `IsSuccess` check flows the +nullability information the compiler needs. + +## Throw on misuse, never coerce + +An expected failure is a value; a misuse is a bug. The contract is deliberately loud: + +- `Value` throws `InvalidOperationException` (`"The value of a non-successful result cannot be accessed."`) unless + the result is a success. +- `Error` throws `InvalidOperationException` (`"The error of a non-failed result cannot be accessed."`) unless the + result is a failure. +- `Match`, `Map`, `Bind`, `MapError` and the extension operations throw `InvalidOperationException` + (`"The result is uninitialized."`) for `default`. + +An uninitialized result is never treated as a failure or as a success. If that is too strict for a boundary — +inspecting a result you did not create — use the probing methods instead of the throwing ones. + +## Probing without throwing + +`TryGetValue` and `TryGetError` in `ResultExtensions` are the deliberate exception to the throwing contract: they +report whether a value or an error is available and never throw, even for `default`. + +```csharp +if (result.TryGetError(out var error)) + logger.LogWarning("Failed: {Error}", error); +``` + +## Factories and conversions + +| Form | Example | +| --- | --- | +| Generic factory | `Result.Success(tenant)` | +| Non-generic factory | `Result.Success(tenant)` | +| Implicit from the value | `Result ok = 42;` | +| Implicit from the error | `Result failed = "not a number";` | + +`Result.Success`/`Result.Failure` exist so a call site does not have to name the value type twice. The implicit +conversions from `TValue` and `TError` are public contract, and they are also what lets a method body `return` +a plain value or a non-union error. + +## `ToString` + +`ToString()` is stable and is what the examples print: + +| State | Output | +| --- | --- | +| Success | `Success(value)` | +| Failure | `Failure(error)` | +| Uninitialized | `Uninitialized` | + +## `IResultValue` + +`IResultValue` is the non-generic, read-only view used by infrastructure that cannot be generic over the value and +error types — the ASP.NET Core endpoint filter, for example. + +| Member | Behaviour | +| --- | --- | +| `IsInitialized` | Whether the result was created at all | +| `IsSuccess` | Whether the result represents success | +| `SuccessValue` | The successful value, or `null` when the result is not a success | +| `ErrorValue` | The error, or `null` when the result is not a failure | + +Its accessors **never throw**: the accessor that does not describe the current state returns `null`. That is what +makes it safe for a framework component to inspect a result without knowing the two type arguments. + +## Where extension methods live + +Value-style operations that do not belong on the result type itself — probing, fallbacks, side effects, +constraints and the asynchronous combinators — live in +`src/src/Results/Extensions/Purview/Results/ResultExtensions.cs`. Add new extension methods there rather than on +`Result`, so the type's own surface stays the three states and the four core combinators. + +## Related + +- [Combinators](Combinators.md) — the operations built on these states. +- [Union Errors](Union-Errors.md) — making `TError` a union. +- [Guarantees and Limitations](Guarantees-and-Limitations.md) — the invariants that must not regress. diff --git a/docs/wiki/Diagnostics.md b/docs/wiki/Diagnostics.md new file mode 100644 index 0000000..374914a --- /dev/null +++ b/docs/wiki/Diagnostics.md @@ -0,0 +1,74 @@ +# Diagnostics + +The union rules live in one shared library (`Diagnostics/DiagnosticLibrary.cs`, +`Diagnostics/ResultUnionDiagnostics.cs`, `Diagnostics/ResultDiagnostic.cs`) that both hosts consume: + +- **`ResultsDiagnosticAnalyzer` reports the per-target rules** (`RSG1000`–`RSG1004`, `RSG1007`) in the IDE and in + build output. +- **The generator reports the compilation-wide rules** (`RSG1005`, `RSG1006`) that need every opted-in union in + the compilation. +- The generator never reports a rule the analyzer reports. It still runs the same shared analysis, so + `DiagnosticLibrary.IsBlocking` is the single blocking policy, but a finding is never surfaced twice. + +## Rules + +| ID | Severity | Reported by | Blocking | Description | +| --- | --- | --- | --- | --- | +| `RSG1000` | Error | Analyzer | Yes | `[GenerateResult]` was applied to a type that is not a union. | +| `RSG1001` | Error | Analyzer | Yes | The union declares no union cases. | +| `RSG1002` | Error | Analyzer | Union: yes / case: no | A generic union, or a case type that contains type parameters. | +| `RSG1003` | Error | Analyzer | Union: yes / case: no | A type that generated code must reference is not accessible (for example a `file` type). | +| `RSG1004` | Error | Analyzer | No | The same union is configured more than once (for example on two partial declarations); the helpers are generated once. | +| `RSG1005` | Error | Generator | Yes (the colliding union is skipped) | Two unions produce the same generated class name in one namespace. | +| `RSG1006` | Warning | Generator | No (only the shared case's helper is skipped) | A case type is shared with another union, so its helper is generated once to keep call sites unambiguous. | +| `RSG1007` | Error | Analyzer | Yes | The union uses an `IUnionMembers` member provider, which the first implementation does not support. | + +Case-level findings never block the union: the remaining cases are still generated and the skipped case is +reported. Blocking is decided per rule in `DiagnosticLibrary.IsBlocking`, not derived from severity, because a +finding may be an error the consumer must fix while usable output can still be produced. + +Every rule is categorized as `Purview.Results.Usage` and tracked in +`src/src/SourceGenerator/AnalyzerReleases.Unshipped.md`, which the compiler's RS2008 rule catalogue validates. + +## Diagnostic suppressions + +A union declaration gets no value equality from the compiler, so every union raises `CA1815` ("Override equals +and operator equals on value types") unless it is silenced by hand with a `#pragma warning disable CA1815` or a +`[SuppressMessage]`. A `[GenerateResult]` union is the error type of a result and is read by matching its case +type, not by comparing two unions by value, so the package answers that warning: + +| Suppression ID | Suppressed rule | Applies to | +| --- | --- | --- | +| `RSG2000` | `CA1815` | A declaration that is a union **and** is opted in with `[GenerateResult]` | + +The suppression is narrow on purpose: + +- A union without `[GenerateResult]` keeps the warning — the package only speaks for the unions it generates for. +- The union's **case types** keep the warning, as does every other value type. A case declared as a plain `struct` + still gets `CA1815`; a `record struct` case never gets it, because records synthesise equality. +- Nothing is hidden: a suppressor can only suppress non-error, configurable diagnostics, and `RSG2000` is a + suppression id rather than a rule, so `RSG1000`–`RSG1007` remain the only diagnostics the package reports. + +Every suppression is logged as an `Info` diagnostic against `RSG2000`, in the verbose build log and in an MSBuild +binlog (and as a suppressed diagnostic in an `/errorlog` SARIF file), so a build can always be audited for what it +suppressed. + +### Keeping `CA1815` + +Add `RSG2000` to the compiler's warning suppressions, and the warning returns for every union declaration, +including the opted-in ones: + +```xml + + $(NoWarn);RSG2000 + +``` + +Declaring equality on the union is the other way to satisfy `CA1815` — it is the code the warning asks for, and a +union that has it never raises the warning, so the suppressor has nothing to do. + +## Related + +- [Union Errors](Union-Errors.md) — the union shapes the rules describe. +- [Source Generator](Source-Generator.md) — where each host runs and how output is produced. +- [Testing](Testing.md) — how the analyzer, suppressor and code fix are verified. diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md new file mode 100644 index 0000000..2295fad --- /dev/null +++ b/docs/wiki/Getting-Started.md @@ -0,0 +1,143 @@ +# Getting Started + +This guide installs the packages, models an error union, returns a result, and maps it onto an HTTP response. + +## Requirements + +- **.NET 11 SDK or later** — the runtime packages target `net11.0`; the source generator targets + `netstandard2.0` so any compiler host can load it. +- **C# 15 preview** — union declarations are a preview language feature, so a project that declares one needs + `LangVersion=preview` (the repository sets it centrally). + +## 1. Reference the packages + +```bash +dotnet add package Purview.Results +dotnet add package Purview.Results.SourceGenerator # only when you model errors as a union +``` + +`Purview.Results.SourceGenerator` is a Roslyn component: add it as an ordinary `PackageReference` and it applies +to the compilation automatically. It also brings the analyzer, the `CS0029` code fix, and the `CA1815` +suppression for opted-in unions. + +## 2. Model the error as a union (optional) + +A C# 15 union makes the error cases strongly typed without giving up the single `TError` the result needs: + +```csharp +using Purview.Results; + +[GenerateResult] +public readonly union TenantError(TenantNotFound, TenantDisabled, TenantAlreadyExists); + +public readonly record struct TenantNotFound(TenantId TenantId); +public readonly record struct TenantDisabled(TenantId TenantId); +public readonly record struct TenantAlreadyExists(TenantId TenantId); +``` + +`[GenerateResult]` is generated by the source generator itself, so it needs no separate reference. For every case +the generator emits `AsFailure()` in the union's namespace: + +```csharp +public static class TenantErrorResultExtensions +{ + public static Result AsFailure(this TenantNotFound error) => + Result.Failure(error); + // ... one overload per case type +} +``` + +See [Union Errors](Union-Errors.md) for the modelling rules and [Source Generator](Source-Generator.md) for the +generated shape. + +## 3. Return a result + +```csharp +using Purview.Results; + +Result GetTenant(TenantId tenantId) => + _tenants.TryGetValue(tenantId, out var tenant) + ? Result.Success(tenant) + : new TenantNotFound(tenantId).AsFailure(); +``` + +`Result.Success(value)` and `Result.Failure(error)` are the same factories +without repeating the value type. Both member states are also reachable through the result's implicit +conversions, so a non-union error or a value can be returned directly: + +```csharp +Result ok = 42; +Result failed = "not a number"; +``` + +## 4. Read the result + +```csharp +var result = GetTenant(tenantId); + +if (result.IsSuccess) + Console.WriteLine(result.Value.Name); + +var message = result.Match( + tenant => $"Found {tenant.Name}", + error => $"Could not load the tenant: {error}" +); +``` + +`default` is *uninitialized*: `IsInitialized`, `IsSuccess` and `IsFailure` are all `false`, and `Value`, `Error`, +`Match`, `Map`, `Bind` and `MapError` throw `InvalidOperationException` rather than guessing. See +[Core Concepts](Core-Concepts.md) and [Combinators](Combinators.md). + +## 5. Map it onto HTTP + +```bash +dotnet add package Purview.Results.AspNetCore +``` + +```csharp +builder.Services.AddResultsHttp(options => options + .Map(error => TypedResults.NotFound()) + .Map(error => TypedResults.Problem(statusCode: StatusCodes.Status403Forbidden)) + .Map(error => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict))); + +app.MapGet("/tenants/{id}", (string id) => GetTenant(new TenantId(id))).WithResultsHttp(); +``` + +A mapping for the **case** type wins, the mapping for the **error** type covers the remaining cases, and an +unmapped failure is a `500` that names the unmapped case, so a mapping gap is never silent. See +[ASP.NET Core Integration](AspNetCore-Integration.md). + +## 6. Validate without exceptions + +```bash +dotnet add package Purview.Results.ZodSharp +``` + +ZodSharp's `Validate` already returns a `ValidationResult`; `ToResult` turns that into a result whose error +type you choose: + +```csharp +return TenantInputSchema + .Validate(input) + .ToResult(errors => new TenantInputInvalid(input, errors)); +``` + +See [ZodSharp Integration](ZodSharp-Integration.md) and, for ProblemDetails rendering, +[ZodSharp Problem Details](ZodSharp-ProblemDetails.md). + +## Runnable examples + +Every example is a non-packable project under `src/examples`, built on the same Tenancy domain: + +```bash +dotnet run --project src/examples/Examples.Basic +dotnet run --project src/examples/Examples.Zod +dotnet run --project src/examples/Examples.AspNetCore --urls http://localhost:5215 +dotnet run --project src/examples/Examples.AspNetCore.Zod --urls http://localhost:5216 +``` + +## Next steps + +- [Core Concepts](Core-Concepts.md) — the three states and the throw-on-misuse contract. +- [Combinators](Combinators.md) — `Match`, `Map`, `Bind`, `Ensure`, probing, and the asynchronous forms. +- [Guarantees and Limitations](Guarantees-and-Limitations.md) — what the suite deliberately does not do. diff --git a/docs/wiki/Guarantees-and-Limitations.md b/docs/wiki/Guarantees-and-Limitations.md new file mode 100644 index 0000000..dec615b --- /dev/null +++ b/docs/wiki/Guarantees-and-Limitations.md @@ -0,0 +1,68 @@ +# Guarantees and Limitations + +This page records what the suite guarantees, and what it deliberately does not do. Treat it as the contract a +change must not break. + +## Runtime guarantees + +- `Result` is a `readonly record struct` with exactly three observable states: `Uninitialized` + (the `default` value), `Success` and `Failure`, each observable through `IsInitialized`, `IsSuccess` and + `IsFailure`. +- The throw-on-misuse contract holds: `Value` and `Error` throw `InvalidOperationException` in the wrong state, + and `Match`, `Map`, `Bind` and `MapError` throw `"The result is uninitialized."` for `default`. An uninitialized + result is never silently coerced into a success or a failure. +- `ToString()` stays `Success(value)` / `Failure(error)` / `Uninitialized`. +- `IResultValue` accessors never throw; the accessor that does not describe the current state returns `null`. +- The implicit conversions from `TValue` and `TError`, and the `Result.Success`/`Result.Failure` and + `Result.Success`/`.Failure` factories, are public contract. + +## Dependency guarantees + +- `Purview.Results` is dependency-free. +- The ZodSharp and ASP.NET Core packages depend on it, never the reverse. +- `Purview.Results.AspNetCore` deliberately knows nothing about ZodSharp; validation handling lives in + `Purview.Results.ZodSharp.AspNetCore`. +- The runtime packages contain no reflection, no `dynamic` and no runtime type discovery. Union structure is + inspected only by the source generator, through Roslyn symbols. + +## Union support limits + +- `[GenerateResult]` is supported on union declarations only. Generic unions are unsupported (`RSG1002`), and so + are `IUnionMembers` member providers (`RSG1007`). +- Generated implicit conversions are not possible — see [Union Errors](Union-Errors.md) for the five compiler + rules. The per-case `AsFailure()` helper and the IDE code fix are the ergonomics the language allows; + the helper-free alternative is the `(TenantError)caseValue` cast. +- Accessibility never widens: a union or case type that is not visible produces an `internal` generated class + (`RSG1003` covers the case where generated code could not reference a type at all). + +## HTTP mapping philosophy + +- An unmapped failure is a host mapping gap, not a domain outcome. It is answered with `UnmappedStatusCode` + (`500`) and a `ProblemDetails` carrying the `errorType` extension, and it is logged. +- `ThrowOnUnmappedFailure` exists for development and keeps throwing `InvalidOperationException`. +- An uninitialized result (`default`) takes the unmapped path and is logged, because an endpoint returning + `default` is a bug. +- `IResultsFailureMapper` is the way in for rules keyed by a **value** rather than a type, and it must not become + a catch-all that answers every failure — that hides exactly the gaps the unmapped-failure response exists to + expose. + +## ZodSharp mapping philosophy + +- Code rules are consulted before category rules (each in registration order), then the default validation + problem, so a rule can only narrow what the host already gets. +- A rule matches when **any** of the failure's errors carries its code or category: a rule a schema can silently + never reach is the kind of gap this suite surfaces rather than hides. +- A factory returns `null` to decline, and matching continues. Factories see the failure's whole error set. + +## Not in scope + +- No exception-to-result conversion: exceptions remain for exceptional circumstances. +- No runtime union inspection: matching an error case is ordinary C# pattern matching. +- No replacement for `Result`: the generator only adds call-site ergonomics. +- No `Task`/`ValueTask`-specific async combinators beyond `MapAsync` and `BindAsync`. + +## Related + +- [Core Concepts](Core-Concepts.md) — the state contract in detail. +- [Diagnostics](Diagnostics.md) — the rules that enforce the union limits. +- [ASP.NET Core Integration](AspNetCore-Integration.md) — the mapping order described above. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md new file mode 100644 index 0000000..9d0dd67 --- /dev/null +++ b/docs/wiki/Home.md @@ -0,0 +1,61 @@ +# Purview.Results Wiki + +Purview.Results is the Purview result suite for .NET: a small, dependency-light `Result` value +that makes **expected** failures part of a method's contract instead of an exception, a Roslyn source generator +that makes C# 15 union error cases ergonomic, and adapters that carry those results to ASP.NET Core and +ZodSharp. Exceptional circumstances still throw; states you expect — not found, invalid, conflict — are values. + +This wiki is the project documentation hub. Packages are published under the `Purview.Results.*` package IDs. + +## Start here + +- [Getting Started](Getting-Started.md) +- [Core Concepts](Core-Concepts.md) +- [Combinators](Combinators.md) +- [Union Errors](Union-Errors.md) +- [Guarantees and Limitations](Guarantees-and-Limitations.md) + +## Source generation + +- [Source Generator](Source-Generator.md) +- [Diagnostics](Diagnostics.md) + +## Integrations + +- [ASP.NET Core Integration](AspNetCore-Integration.md) +- [ZodSharp Integration](ZodSharp-Integration.md) +- [ZodSharp Problem Details](ZodSharp-ProblemDetails.md) + +## Workflow + +- [Testing](Testing.md) +- [Agent Skills](Agent-Skills.md) +- [Release Flow](Release-Flow.md) +- [Contributing](Contributing.md) + +## Packages + +| Package | Purpose | +| --- | --- | +| `Purview.Results` | `Result`, the `Result` factories and `IResultValue`. No dependencies. | +| `Purview.Results.SourceGenerator` | Generates `AsFailure()` helpers for `[GenerateResult]` unions, plus the analyzer and code fix. | +| `Purview.Results.AspNetCore` | Maps results onto ASP.NET Core responses (`IResult`, `ProblemDetails`). | +| `Purview.Results.ZodSharp` | Bridges ZodSharp `ValidationResult` values into results. | +| `Purview.Results.ZodSharp.AspNetCore` | Renders validation-carrying failures as `HttpValidationProblemDetails`. | + +## Feature highlights + +- **Three explicit states** — `Uninitialized` (the `default` value), `Success` and `Failure` stay observable, so + a result that was never created is never silently read as a failure or a success. +- **Throw-on-misuse, never silent** — `Value` and `Error` throw in the wrong state, and `Match`/`Map`/`Bind`/ + `MapError` throw for `default`; probing (`TryGetValue`, `TryGetError`) is the non-throwing way in. +- **Union error types** — a C# 15 union keeps every error case strongly typed while the result stays a single + value, and the generator supplies the per-case `AsFailure()` helper the language cannot express itself. +- **Compile-time diagnostics** — `RSG1000`–`RSG1007` report unsupported union shapes at build time, and the one + suppression (`RSG2000`) answers `CA1815` only for opted-in unions. +- **Host-controlled HTTP** — the ASP.NET Core package resolves an error **case**, then the **error type**, then + ordered fallbacks, and answers an unmapped failure with a logged `500` so mapping gaps stay visible. +- **Validation without exceptions** — ZodSharp `ValidationResult` values become ordinary results, and a + failure that carries validation errors becomes a standard ProblemDetails payload. +- **No reflection at runtime** — dependency-free runtime packages; union structure is inspected only by the + source generator, through Roslyn symbols. diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md new file mode 100644 index 0000000..8be52b8 --- /dev/null +++ b/docs/wiki/Release-Flow.md @@ -0,0 +1,102 @@ +# Release Flow + +Releases are driven by the shared [purview-dev/build](https://github.com/purview-dev/build) pipeline through the +GitHub Actions workflows in `.github/workflows/`. Consuming repositories own configuration through +`purview-build.json`, not pipeline source code. + +## Versioning + +`package.json` is the authoritative release and package version; every package is versioned from it by +`Purview.BuildSdk`. Never diverge a project's version by hand. The current line is `1.0.0-prerelease.1`; a +prerelease uses a `MAJOR.MINOR.PATCH-prerelease.N` suffix. + +## Workflows + +| Workflow | Trigger | Pipeline | +| --- | --- | --- | +| `.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. + +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` + +| Key | Value | +| --- | --- | +| `Build:Solution` | `src/Results.slnx` | +| `Build:TestRoot` | `src/tests` | +| `Build:TestPatterns` | `*Tests.csproj` | +| `Build:TestProjects` | `*UnitTests*` | +| `Build:TestFilter` | `/*/*/*/*[Category=Unit]` | +| `PackValidation:RequireSymbolPackage` | `false` | +| `PackValidation:RequiredContent` | The exhaustive per-package content declaration | +| `Release:Mode` | `None` (publishing is enabled only by the release workflow) | + +`RequireExplicitContent` defaults to `true`, so `RequiredContent` is exhaustive: a package with no rule, or an +entry matching none of its globs, fails validation. Update it whenever package content changes — a new asset, a +removed PDB, a renamed analyzer. + +## Packed shapes that must not regress + +- `Purview.Results.SourceGenerator` ships its merged analyzer under `analyzers/dotnet/cs/` with **no `lib/` + folder** and **no PDB** (`PurviewPackAnalyzerPdb=false`), because the packaged analyzer is the framework's + ILRepack-merged assembly whose rewritten PDB carries no Roslyn compiler-flags record. The `CS0029` code fix + ships beside it as `Purview.Results.SourceGenerator.CodeFixes.dll` and is never IL-merged into the generator. +- 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`. + +The framework assembly must never appear loose beside the merged analyzer — `ForbiddenContent` rejects +`analyzers/**/Purview.SourceGeneratorFramework.dll`. + +## Analyzer release tracking + +`Purview.Results.SourceGenerator` ships public diagnostics (`RSG1000`–`RSG1007`), 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. + +## Commands + +```bash +just pipeline-pr # restore, build, lint, tests, pack, validate pack +just pipeline-build # restore, build, lint (no tests, no release) +just pipeline-pack-validate # restore, build, lint, tests, pack, validate contents +just pipeline-release # pack, publish, GitHub release (NuGet mode) +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. + +## Testing the packed packages locally + +A `.nupkg` published under a **new** version always extracts fresh, so the supported flow is: bump +`package.json`, publish to the local feed, then scrub the consuming repository. + +In this repository, publish the new version to the local feed: + +```bash +just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ +``` + +Then, in the consuming repository: + +```bash +just scrub +just build +``` + +`LOCAL_NUGET_FEED_PATH` is exported in the development environment, so the explicit switch is only needed when it +is not. NuGet reuses an already-extracted package for the same id **and** version, so republishing the same +version requires deleting `$(NUGET_PACKAGES)//` first — prefer a new version. + +## Related + +- [Contributing](Contributing.md) — the local commands and quality gates. +- [Diagnostics](Diagnostics.md) — the rules the release-tracking file lists. diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md new file mode 100644 index 0000000..f7af625 --- /dev/null +++ b/docs/wiki/Source-Generator.md @@ -0,0 +1,99 @@ +# Source Generator + +`Purview.Results.SourceGenerator` is an incremental Roslyn source generator that makes C# 15 **union** error cases +ergonomic with `Result`. It generates the `[GenerateResult]` attribute, one `AsFailure()` +helper per union case, and the diagnostics that keep unsupported shapes out of a build. + +## Installation + +```bash +dotnet add package Purview.Results.SourceGenerator +dotnet add package Purview.Results +``` + +The component targets `netstandard2.0` (with the SDK's Roslyn defaults) so any compiler host can load it. +Requirements: a C# 15 compiler with union declaration support (the .NET 11 SDK or later) and +`LangVersion=preview`. + +## Activation and design + +- **Opt-in only.** Nothing is generated for a compilation that never applies `[GenerateResult]`; discovery runs + through `ForAttributeWithMetadataName` and only the attribute source is produced for every compilation. +- **The attribute is generated** during post-initialization (`GenerateResultAttribute.g.cs`), so consumers do not + declare it and do not need another package reference. +- **One analyzer, one generator, one diagnostics library.** The analyzer raises the per-target rules; the + generator shares the same analysis to decide whether generation can continue. No rule is reported twice. +- **One suppressor, with one narrow job.** `UnionEqualityDiagnosticSuppressor` answers `CA1815` for opted-in + unions only and reports no diagnostics of its own. +- **Union membership is decided by the language** (`ITypeSymbol.IsUnion`), never by type names, source text, + reflection or the union's runtime value. +- **Case types come from the language's own rule**: a union's public single-parameter constructors define its + case types, and a `union` declaration synthesises one per declared case. + +## The incremental pipeline + +The pipeline resolves Roslyn symbols during discovery and converts them into value-equatable models +(`ResultUnionModel`, `ResultUnionCaseModel`, `ResultSourceLocation`, `EquatableArray`). No `ISymbol`, +`Compilation`, `SemanticModel`, `IOperation`, `SyntaxNode`, `SyntaxTree` or `Location` ever reaches cached +state, which is what stops an unrelated edit from regenerating output. + +`ResultsSourceGeneratorCacheTests` proves this stage by stage with `GenerateIncrementalAsync`: `New` on the first +run, `Cached`/`Unchanged` on an identical rerun, and `Modified` only for the stages whose inputs actually changed. +`CodeWriter` is created inside the `RegisterSourceOutput` callback, never stored in cached state. + +## Deterministic output + +- Cases are ordered **ordinally by fully-qualified name**. +- Unions are processed in **ordinal order**. +- Hint names derive from the union's identity. +- Nothing emits timestamps, GUIDs or machine paths, so the same inputs always produce byte-identical output. + +## Accessibility and nullability + +Accessibility never widens: the generated class is `public` only when the union and every case type it references +are visible; otherwise it is `internal`. Generated files start with `#nullable enable`, reference fully qualified +names, and do not add `?` to non-nullable case types. + +## Generated shape + +```csharp +namespace Test.Tenancy; + +public static class TenantErrorResultExtensions +{ + public static Result AsFailure(this TenantNotFound error) => + Result.Failure(error); + + public static Result AsFailure(this TenantDisabled error) => + Result.Failure(error); +} +``` + +The generated helpers are pure static methods that call the existing `Result.Failure` factory: +there is no reflection, no `dynamic`, no runtime type discovery and no mutable static state. The generator does +not change, wrap or replace `Result`. + +## Build properties + +| Property | Default | Purpose | +| --- | --- | --- | +| `ResultsSourceGenerator_Disable` | `false` | Disables generation while still emitting the opt-in attribute | + +The switch reaches `PackageReference` consumers through +`buildTransitive/Purview.Results.SourceGenerator.props`, so `-p:ResultsSourceGenerator_Disable=true` (or a +`Directory.Build.props` setting) disables the helpers there too. Release tracking for the rules lives in +`src/src/SourceGenerator/AnalyzerReleases.Unshipped.md`. + +## Packaging + +The generator is never packed loose: the package ships its merged analyzer under `analyzers/dotnet/cs/` with no +`lib/` folder and **no PDB** (`PurviewPackAnalyzerPdb=false`), because the packaged analyzer is the framework's +ILRepack-merged assembly whose rewritten PDB carries no Roslyn compiler-flags record. The `CS0029` code fix ships +beside it as a second analyzer assembly (`Purview.Results.SourceGenerator.CodeFixes.dll`) and is never IL-merged +into the generator. + +## Related + +- [Union Errors](Union-Errors.md) — the union modelling this generator targets. +- [Diagnostics](Diagnostics.md) — the rules, the blocking policy and the suppression. +- [Testing](Testing.md) — the generation, cache and compiler-experiment tests. diff --git a/docs/wiki/Testing.md b/docs/wiki/Testing.md new file mode 100644 index 0000000..2aaca59 --- /dev/null +++ b/docs/wiki/Testing.md @@ -0,0 +1,78 @@ +# Testing + +Tests are **TUnit on Microsoft.Testing.Platform** only — never xUnit, NUnit, MSTest, NSubstitute or +FluentAssertions. `TUnit.Mocks` and `Bogus` are added to test projects automatically by `Purview.BuildSdk`, so +they are versioned centrally and must not be added as `PackageReference`s. + +## Running the tests + +```bash +just test-unit +``` + +That resolves to the whole solution's unit tests through the supported tree-node filter: + +```bash +dotnet test src/Results.slnx --no-build --treenode-filter "/*/*/*/*[Category=Unit]" +``` + +The `*.UnitTests` project naming is what the SDK uses to derive `TestingType=Unit` and apply the matching test +category, which is what the filter matches. Use `--treenode-filter` for filtering — not `dotnet test --filter`. + +## Test projects + +| Project | Covers | +| --- | --- | +| `src/tests/Results.UnitTests` | `Result` states, the throw-on-misuse contract, and the extension operations (`ResultsTests`, `ResultExtensionsTests`) | +| `src/tests/SourceGenerator.UnitTests` | Generation, diagnostics, incremental caching, the compiler experiments, the code fix, and the suppressor | +| `src/tests/AspNetCore.UnitTests` | `DefaultResultsHttpMapper`, the endpoint filter, failure mappers and unmapped-failure behaviour | +| `src/tests/ZodSharp.UnitTests` | `ToResult` and the validation-result integration | +| `src/tests/ZodSharp.AspNetCore.UnitTests` | `ZodResultsFailureMapper`, registration order and the validation problem mapper | + +## Conventions + +- Test naming is `{SubjectUnderTest}_{Scenario}_{Expectation}`, for example + `DefaultResult_ShouldBeUninitialized`, `MapError_WhenFailure_ShouldTransformError`, + `GenerateIncrementalAsync_GivenFirstRun_MarksEveryStageNew`. +- One `{Class}Tests` class per class under test, in the same namespace. +- `public async Task` with a `CancellationToken` whenever the API under test accepts one. +- Mock with `TUnit.Mocks`; do not introduce a substitute framework. + +## Source-generator tests + +Generator tests derive from `TUnitSourceGeneratorTestBase` with an options record that seeds +the required namespaces and assemblies; `ResultsSourceGeneratorTestOptions` is the reference. Assert on generated +code with the framework's `CodeQuery` and terminal assertion extensions (`result.Generated()`, +`HasGeneratedSyntaxTree`, `HasGeneratedClass`) rather than raw strings. + +**Incremental pipelines need stage-by-stage cache tests.** `ResultsSourceGeneratorCacheTests` is the reference: +it drives `GenerateIncrementalAsync`, asserts `New` on the first run, `Cached`/`Unchanged` on an identical rerun, +and `Modified` only for the stages whose inputs changed. + +## Compiler experiments + +Behaviour that motivates a design decision is recorded as a compiler experiment rather than an assumption. +`UnionCompilerBehaviourTests` proves the `CS0029`/`CS1929` cases the generator exists to work around, and the +`CS0715`/`CS0556`/`CS9282`/`CS0246` cases that rule out generated implicit conversions. When a language claim in +these docs changes, the experiment is where it is verified. + +## Code-fix tests + +A code fix that answers a **compiler** diagnostic cannot use `TUnitCodeFixTestBase`, because that base is driven +by an analyzer's diagnostics. `UnionCodeFixTestHarness` instead runs the generator, applies the fix through an +`AdhocWorkspace`, and recompiles the rewritten source so a broken fix cannot pass. A `Document` is an immutable +snapshot, so the harness re-resolves it from the workspace's current solution after adding documents. + +## Suppressor tests + +The real `CA1815` comes from the .NET analyzers the SDK loads, which a unit-test compilation cannot reference, so +`UnionEqualityDiagnosticSuppressorTests` reports the same id at the same location from a test-only analyzer +(`Ca1815ReporterAnalyzer`) and runs it beside the shipped suppressor through `CompilationWithAnalyzers`. The +compilation under test is still real: it is produced by running the generator, so the union, the generated +attribute and the generated helpers are all present. + +## Related + +- [Source Generator](Source-Generator.md) — the pipeline the cache tests guard. +- [Diagnostics](Diagnostics.md) — the rules the analyzer tests assert. +- [Contributing](Contributing.md) — the day-to-day command set. diff --git a/docs/wiki/Union-Errors.md b/docs/wiki/Union-Errors.md new file mode 100644 index 0000000..d4047cc --- /dev/null +++ b/docs/wiki/Union-Errors.md @@ -0,0 +1,115 @@ +# Union Errors + +A C# 15 union is a natural `TError`: the error cases stay strongly typed, and a caller still matches on one +value. This page covers the modelling rules and the helper the source generator supplies so a case value can be +returned where a result is expected. + +## Declaring the union + +```csharp +using Purview.Results; + +[GenerateResult] +public readonly union TenantError(TenantNotFound, TenantDisabled, TenantAlreadyExists); + +public readonly record struct TenantNotFound(TenantId TenantId); +public readonly record struct TenantDisabled(TenantId TenantId); +public readonly record struct TenantAlreadyExists(TenantId TenantId); +``` + +`[GenerateResult]` is generated by `Purview.Results.SourceGenerator` during post-initialization, so the consuming +project does not declare it and needs no second package reference. Opt-in is explicit: a compilation that never +applies the attribute gets no helpers. + +## The generated helper + +For each case type the generator emits one overload in a `{Union}ResultExtensions` static class in the union's +own namespace: + +```csharp +public static class TenantErrorResultExtensions +{ + public static Result AsFailure(this TenantNotFound error) => + Result.Failure(error); + + public static Result AsFailure(this TenantDisabled error) => + Result.Failure(error); + + public static Result AsFailure(this TenantAlreadyExists error) => + Result.Failure(error); +} +``` + +```csharp +Result GetTenant(TenantId tenantId) => + _tenants.TryGetValue(tenantId, out var tenant) + ? Result.Success(tenant) + : new TenantNotFound(tenantId).AsFailure(); +``` + +One overload is generated **per case type** rather than one for the union, because C# does not apply union +conversions to an extension-method receiver (`CS1929`), so a helper declared on the union itself would never bind. + +## Why there is no implicit conversion + +The shortest possible call site would be `return new TenantNotFound(id);`. C# blocks every route to a generated +implicit conversion, and each rule is recorded as a compiler experiment in +`src/tests/SourceGenerator.UnitTests/UnionCompilerBehaviourTests.cs`: + +| Rule | Diagnostic | +| --- | --- | +| A user-defined operator cannot be declared in a **static class** — the generated helper class is one | `CS0715` | +| A conversion operator must be declared by the **source or the target type**; a helper class is neither | `CS0556` | +| Conversion operators are **not permitted as extension members** | `CS9282` | +| A conversion operator **cannot declare its own type parameters**, so `TValue` is out of scope for a non-generic case type | `CS0246` | +| **Only one user-defined conversion** may participate in a sequence, so `case → union → Result<…>` can never compose | `CS0029` | + +The one shape the language accepts is a *generic* case type carrying the value type parameter — precisely the +shape the generator rejects as `RSG1002`. The per-case helper is therefore the best ergonomics the language +allows. + +## The helper-free form + +A cast closes the `case → union` conversion so only the library's `union → result` conversion remains, which +means the cast form needs no generated code: + +```csharp +Result GetTenant(TenantId tenantId) => + (TenantError)new TenantNotFound(tenantId); +``` + +It is not prettier than `AsFailure()`, but it is a legitimate choice when a project does not want the +generator applied to a union. + +## The code fix + +Returning a bare case value where a result is expected is a compiler error (`CS0029`). The package ships +`UnionCaseResultCodeFixProvider`, which offers the rewrite in the IDE from the lightbulb: + +```csharp +return new TenantNotFound(id); // CS0029 +return new TenantNotFound(id).AsFailure(); // after the fix +``` + +A fix is offered only when the rewrite will bind: the converted type is `Purview.Results.Result`, +`TError` is a union **and** opted in with `[GenerateResult]`, the expression's type is one of that union's case +types, and the union is reachable by its simple name at the call site. + +## Union shapes that are supported + +| Shape | Supported | +| --- | --- | +| A union declaration whose cases are public single-parameter constructors | Yes | +| A type marked `[Union]` with public single-parameter constructors | Yes | +| A case type shared by two unions | Yes — `RSG1006` warning; the helper is generated once | +| A generic union | No — `RSG1002` | +| A case type that itself contains type parameters | No — `RSG1002` (the remaining cases still generate) | +| `IUnionMembers` member providers | No — `RSG1007` | + +See [Diagnostics](Diagnostics.md) for the full rules table and the blocking policy. + +## Related + +- [Source Generator](Source-Generator.md) — activation, pipeline design and build properties. +- [Diagnostics](Diagnostics.md) — every rule and the `CA1815` suppression. +- [ASP.NET Core Integration](AspNetCore-Integration.md) — mapping each case onto a response. diff --git a/docs/wiki/ZodSharp-Integration.md b/docs/wiki/ZodSharp-Integration.md new file mode 100644 index 0000000..2db2362 --- /dev/null +++ b/docs/wiki/ZodSharp-Integration.md @@ -0,0 +1,94 @@ +# ZodSharp Integration + +`Purview.Results.ZodSharp` bridges [ZodSharp](https://www.nuget.org/packages/Purview.ZodSharp) +`ValidationResult` values into results, so validation outcomes flow through the same result pipeline as every +other expected outcome instead of throwing. + +## Installation + +```bash +dotnet add package Purview.Results.ZodSharp +``` + +## Why a bridge + +ZodSharp validation never throws: `Validate` returns a `ValidationResult` carrying the validated value on +success and every `ValidationError` on failure. That is already a result-shaped value, but it is not +`Result` — the failure type is fixed rather than chosen by the caller. `ToResult` closes that gap. + +## Quick start + +```csharp +return RepositoryReconciliationResultSchema + .Validate(reconciliationResult) + .ToResult(errors => + new ReconciliationResultInvalid(reconciliationResult, errors) + ); +``` + +`ToResult` names both type arguments explicitly — including the **error** type — because a union case does not +carry the union type that contains it: + +```csharp +.ToResult(...) +``` + +## When the success type differs from the validated type + +If the surrounding method's success value is not the validated value, produce the failure with the generated +`AsFailure()` helper instead, because the validated value cannot be carried forward: + +```csharp +var validated = ProviderConnectionIdSchema.Validate(providerConnectionId); + +if (!validated.IsSuccess) + return new ProviderConnectionInvalid(providerConnectionId, validated.Errors) + .AsFailure(); + +return await ReconcileCoreAsync(validated.Value, repositories, cancellationToken); +``` + +## Carrying validation errors in the error value + +`IValidationErrorCarrier` is 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: + +```csharp +public readonly record struct TenantInputInvalid(TenantInput Input, ImmutableArray Errors) + : IValidationErrorCarrier +{ + public ImmutableArray ValidationErrors => Errors; +} +``` + +`IValidationErrorCarrier` exposes a single `ValidationErrors` member, preserving each `ValidationError`'s code, +category, path and parameters. + +[ZodSharp Problem Details](ZodSharp-ProblemDetails.md) consumes the interface; without it, a host would have to +map every validation-carrying error individually. + +## API + +| Member | Purpose | +| --- | --- | +| `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 | +| `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 +`Result` the compiler prefers a `Func<..., TError>` parameter and would silently nest the results. +Naming the error type explicitly keeps the intent unambiguous. + +## Example + +```bash +dotnet run --project src/examples/Examples.Zod +``` + +`Examples.Zod` validates a `[ZodSchema] TenantInput` and turns the outcome into a `Result`, +with the rejection carrying its reported `ValidationError`s. + +## Related + +- [ZodSharp Problem Details](ZodSharp-ProblemDetails.md) — the ASP.NET Core rendering of an `IValidationErrorCarrier` + failure. +- [Core Concepts](Core-Concepts.md) — the result states the validated value flows into. diff --git a/docs/wiki/ZodSharp-ProblemDetails.md b/docs/wiki/ZodSharp-ProblemDetails.md new file mode 100644 index 0000000..defd72f --- /dev/null +++ b/docs/wiki/ZodSharp-ProblemDetails.md @@ -0,0 +1,109 @@ +# ZodSharp Problem Details + +`Purview.Results.ZodSharp.AspNetCore` renders a result failure that carries ZodSharp validation errors as an +ASP.NET Core `HttpValidationProblemDetails`, produced by the same mapper the ZodSharp exception handler uses — so a +validation failure carried by a result and the same failure thrown as a `ZodException` produce identical +responses. + +## Installation + +```bash +dotnet add package Purview.Results.ZodSharp.AspNetCore +``` + +## Quick start + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddZodSharpProblemDetails(); +builder.Services.AddResultsHttp(); + +// Every validation-carrying failure becomes one validation problem, except the codes and categories that a +// rule answers with something else. +builder.Services.AddResultsZodSharpHttp(options => options + .MapCode("tenant_not_found", StatusCodes.Status404NotFound) + .MapCategory("invalid_value", StatusCodes.Status422UnprocessableEntity) +); + +var app = builder.Build(); + +app.MapPost("/reconcile", (ReconciliationRequest request) => Reconcile(request)).WithResultsHttp(); +``` + +`AddResultsZodSharpHttp` registers the mapping as a **failure mapper**, so: + +- a mapping the host registered for a specific error **case** always wins, +- a mapping registered for the **error** type wins, +- and a host failure mapper registered **before** `AddResultsZodSharpHttp` wins too. + +Register it **last**, therefore, so everything the host declared earlier takes precedence. + +## Answering by validation error code or category + +| Member | Purpose | +| --- | --- | +| `MapCode(string code, int statusCode)` | Renders the validation problem with a different default status | +| `MapCode(string code, Func, HttpContext, IResult?>)` | Renders a response of your own | +| `MapCategory(string category, int statusCode)` | The same, for a category that spans many codes | +| `MapCategory(string category, Func, HttpContext, IResult?>)` | Renders a response of your own | + +A rule applies when **any** of the failure's errors carries its code or category, so a registered code rule is +always reachable whatever else the schema reported. Matching walks the **code** rules first, then the **category** +rules, each in registration order, and finally the default validation problem — a code is narrower than a +category, so it wins however the two were registered. A factory that wants stricter semantics returns `null` to +decline the failure, and matching continues: + +```csharp +options + // A code rule that only answers a failure whose every error is that code. + .MapCode("tenant_not_found", (errors, context) => + errors.All(error => error.Code == "tenant_not_found") + ? TypedResults.NotFound() + : null) + // A category rule that answers the failures the code rule declined. + .MapCategory("invalid_value", StatusCodes.Status422UnprocessableEntity); +``` + +A factory receives the failure's **full error set**, so a rule never hides the other problems the caller has to +fix, and the `int statusCode` overloads still render every error as an `HttpValidationProblemDetails`. A code or +category registered twice with different behaviour is rejected at configuration time. + +A failure that does **not** carry validation errors is declined (`null`), so other mappers and fallbacks still +apply — this mapper never answers a failure it does not understand. + +## API + +| Member | Purpose | +| --- | --- | +| `AddResultsZodSharpHttp(Action? configure = null)` | Registers the ZodSharp options and adds the validation failure mapper to `ResultsHttpOptions` | +| `ZodResultsHttpOptions.MapCode` / `.MapCategory` | The per-code and per-category rules described above | +| `ZodResultsFailureMapper` | The mapper itself, for a host that wants to register or compose it by hand | +| `IValidationErrorCarrier.ToValidationProblem(HttpContext, int statusCode = 400)` | Creates the validation problem for the errors the error value carries | +| `ImmutableArray.ToValidationProblem(HttpContext, int statusCode = 400)` | Creates the validation problem for a set of errors | +| `ZodValidationProblems.ToProblem(errors, options, defaultStatusCode = 400, traceId = null)` | The underlying mapper, for hosts that resolve the options themselves | + +The status code resolved by `ZodProblemDetailsOptions.StatusCodeSelector` wins; `statusCode` (or +`defaultStatusCode`, or a rule's `statusCode`) is only used when the resolved error type does not define one. The +trace identifier is included when `ResultsHttpOptions.IncludeTraceId` is `true` (the default). + +The mapper reuses `ZodValidationProblems.ToProblem` rather than reimplementing error-to-problem mapping, which is +what keeps a result-carried validation failure and a thrown `ZodException` in agreement on status code, title, +detail and the `issues` extension. + +## Example + +```bash +dotnet run --project src/examples/Examples.AspNetCore.Zod --urls http://localhost:5216 +``` + +`Examples.AspNetCore.Zod` shows a `TenantInputInvalid` failure carrying ZodSharp errors becoming a `400` +validation problem, while the host's own `TenantAlreadyExists` mapping still returns `409`. + +## Related + +- [ZodSharp Integration](ZodSharp-Integration.md) — producing the results that carry validation errors. +- [ASP.NET Core Integration](AspNetCore-Integration.md) — the failure resolution order this mapper plugs into. diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md new file mode 100644 index 0000000..44dc81b --- /dev/null +++ b/docs/wiki/_Sidebar.md @@ -0,0 +1,15 @@ +- [Home](Home.md) +- [Getting Started](Getting-Started.md) +- [Core Concepts](Core-Concepts.md) +- [Combinators](Combinators.md) +- [Union Errors](Union-Errors.md) +- [Source Generator](Source-Generator.md) +- [Diagnostics](Diagnostics.md) +- [ASP.NET Core Integration](AspNetCore-Integration.md) +- [ZodSharp Integration](ZodSharp-Integration.md) +- [ZodSharp Problem Details](ZodSharp-ProblemDetails.md) +- [Guarantees and Limitations](Guarantees-and-Limitations.md) +- [Testing](Testing.md) +- [Agent Skills](Agent-Skills.md) +- [Release Flow](Release-Flow.md) +- [Contributing](Contributing.md) diff --git a/docs/wiki/index.md b/docs/wiki/index.md new file mode 100644 index 0000000..2972bba --- /dev/null +++ b/docs/wiki/index.md @@ -0,0 +1,18 @@ +# Purview Results + +Result types for .NET — a dependency-light `Result`, C# 15 union ergonomics for its error cases, +and integrations that let expected failures flow through a value instead of an exception. + +[Get started](Getting-Started.md){ .md-button .md-button--primary } +[Documentation overview](Home.md){ .md-button } + +## Guides + +- [Core concepts](Core-Concepts.md) +- [Combinators and probing](Combinators.md) +- [Union error types](Union-Errors.md) +- [Source generator](Source-Generator.md) +- [Diagnostics](Diagnostics.md) +- [ASP.NET Core integration](AspNetCore-Integration.md) +- [ZodSharp integration](ZodSharp-Integration.md) +- [ZodSharp problem details](ZodSharp-ProblemDetails.md) diff --git a/package.json b/package.json index 4e06052..318df55 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-results", - "version": "1.0.0-prerelease.1", + "version": "1.0.0-prerelease.2", "license": "MIT", "private": true, "keywords": [],