From ea309ba69600ba3ac128989b4037e00437c9b457 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 2 Oct 2026 15:20:06 +0100 Subject: [PATCH 01/11] chore: formatting --- .agents/agents/sdk-consumer-setup.md | 8 +- .agents/agents/sdk-repository-rationaliser.md | 38 +++++ .../prompts/sdk-review-repository-shape.md | 33 +++++ .../sdk-engineering-principles/.gitignore | 8 ++ .editorconfig | 130 +++++++++--------- Directory.Packages.props | 2 +- global.json | 4 +- package.json | 2 +- src/Directory.Build.props | 6 + .../Examples.AspNetCore.Zod/TenantError.cs | 2 +- src/src/Examples.AspNetCore/TenantError.cs | 2 +- src/src/Examples.Basic/TenantError.cs | 2 +- src/src/Examples.Zod/TenantError.cs | 2 +- .../SourceGenerator.CodeFixes.csproj | 1 - .../UnionCaseResultCodeFixProvider.cs | 4 +- .../Analyzers/ResultsDiagnosticAnalyzer.cs | 6 +- .../Diagnostics/DiagnosticLibrary.cs | 2 +- .../Diagnostics/ResultDiagnostic.cs | 2 +- .../Diagnostics/ResultUnionDiagnostics.cs | 4 +- .../Discovery/ResultUnionDiscovery.cs | 8 +- .../Emit/GenerateResultAttributeEmitter.cs | 4 +- .../Emit/ResultUnionEmitter.cs | 6 +- src/src/SourceGenerator/Helpers/DocText.cs | 2 +- .../Helpers/PropertyLibrary.cs | 4 +- .../Helpers/ResultsTypeLibrary.cs | 2 +- .../Models/ResultUnionModel.cs | 2 +- .../SourceGenerator/ResultsSourceGenerator.cs | 10 +- .../SourceGenerator/SourceGenerator.csproj | 3 +- .../UnionEqualityDiagnosticSuppressor.cs | 4 +- .../Validation/ResultUnionValidator.cs | 6 +- .../ResultsHttpTestUnions.cs | 2 +- src/tests/Results.UnitTests/ResultsTests.cs | 28 ++-- .../GeneratedTestUnions.cs | 2 - .../ResultsDiagnosticAnalyzerTests.cs | 2 +- .../ResultsSourceGeneratorCacheTests.cs | 1 - .../ResultsSourceGeneratorDiagnosticsTests.cs | 3 +- .../ResultsSourceGeneratorTestOptions.cs | 2 +- .../ResultsSourceGeneratorTests.cs | 2 - .../SourceGenerator.UnitTests/TestSources.cs | 2 +- .../UnionCaseResultCodeFixProviderTests.cs | 4 +- .../UnionCodeFixTestHarness.cs | 5 +- .../UnionCompilerBehaviourTests.cs | 1 - .../UnionEqualityDiagnosticSuppressorTests.cs | 7 +- .../ValidationTestUnions.cs | 2 +- .../ZodSharpResultsHttpRegistrationTests.cs | 28 ++-- .../ZodValidationProblemsTests.cs | 47 +++---- .../ValidationTestUnions.cs | 2 +- 47 files changed, 265 insertions(+), 184 deletions(-) create mode 100644 .agents/agents/sdk-repository-rationaliser.md create mode 100644 .agents/prompts/sdk-review-repository-shape.md create mode 100644 .agents/skills/sdk-engineering-principles/.gitignore diff --git a/.agents/agents/sdk-consumer-setup.md b/.agents/agents/sdk-consumer-setup.md index 8e5066c..c3cc84f 100644 --- a/.agents/agents/sdk-consumer-setup.md +++ b/.agents/agents/sdk-consumer-setup.md @@ -14,11 +14,14 @@ Help a consuming repository adopt or troubleshoot `Purview.BuildSdk` correctly, variables, `.git` root, or a nearby `package.json`. `UsePackageJsonVersion=Strict` fails fast instead of silently skipping resolution. 4. If the bundled `.agents/**` content isn't appearing in the repo root, check `EnableAgentFolderInPackage` - (default `true`) and `AgentPackDestinationFolder` (default `.agents`) — the copy runs before build via + (default `true`) and `AgentPackDestinationFolder` (default `.agents`) - the copy runs before build via `EnsureAgentFolderInPackageTarget`. 5. For test-framework or project-shape questions, confirm the project follows repo naming and placement conventions the SDK expects, rather than introducing bespoke structure. -6. Re-run `dotnet build` (or the repo's canonical build command) after each configuration change to confirm +6. For naming, layout, and test readability questions, start from the engineering principles: naming and + placement are configuration, short project names are preferred, the detected test type becomes the baseline + category, and subject-based tests should be named for the subject they own. +7. Re-run `dotnet build` (or the repo's canonical build command) after each configuration change to confirm the fix. ## Constraints @@ -32,3 +35,4 @@ Help a consuming repository adopt or troubleshoot `Purview.BuildSdk` correctly, ## Related skill See `../skills/sdk-configuration-reference/SKILL.md` for the full property reference. +See `../skills/sdk-engineering-principles/SKILL.md` for the higher-level repository conventions. diff --git a/.agents/agents/sdk-repository-rationaliser.md b/.agents/agents/sdk-repository-rationaliser.md new file mode 100644 index 0000000..d41060b --- /dev/null +++ b/.agents/agents/sdk-repository-rationaliser.md @@ -0,0 +1,38 @@ +# sdk-repository-rationaliser (generic agent spec) + +## Goal + +Help a repository move toward `Purview.BuildSdk` conventions without unnecessary churn. + +## Workflow + +1. Start by reading the repo's `Directory.Build.props`, `Directory.Build.targets`, solution entry point, + and current project layout. +2. Identify the current `NamespacePrefix`, project naming scheme, and test project suffixes in use. +3. Compare the repo's structure against the engineering principles: + - short project names + - source/test split where practical + - exact shared/shared-testing names when SDK behavior is expected + - readable, scalable test naming +4. Separate findings into three groups: + - already aligned + - misaligned but harmless + - misaligned and blocking SDK automatic behavior +5. Prefer the smallest sequence of changes that improves predictability without forcing broad renames. +6. When recommending test changes, preserve readable behavior-oriented suites while tightening subject-based + suites toward `{SubjectName}Tests` and `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}`. +7. Confirm whether specialized dependencies such as `TUnit.Aspire` or `Testcontainers` are genuinely needed + rather than treating them as universal defaults. + +## Constraints + +- Do not assume every older repo should be renamed wholesale. +- Preserve meaningful established structure unless it interferes with SDK inference. +- Prefer explaining the effect on `RootNamespace`, `AssemblyName`, `PackageId`, `TestingType`, and + `TargetProjectName` instead of arguing from taste. + +## Related skills + +- `../skills/sdk-engineering-principles/SKILL.md` +- `../skills/project-placement-defaults/SKILL.md` +- `../skills/sdk-project-behavior-and-detection/SKILL.md` diff --git a/.agents/prompts/sdk-review-repository-shape.md b/.agents/prompts/sdk-review-repository-shape.md new file mode 100644 index 0000000..eb089c9 --- /dev/null +++ b/.agents/prompts/sdk-review-repository-shape.md @@ -0,0 +1,33 @@ +# sdk-review-repository-shape (generic prompt spec) + +Review a repository that uses `Purview.BuildSdk` for naming, placement, and test-structure alignment. + +## Required behaviour + +1. Inspect the repository's `Directory.Build.props`, `Directory.Build.targets`, solution entry point, and + project layout before making assumptions. +2. Identify whether the repository follows the SDK-friendly structure: + - source projects under `src/` + - test projects under `tests/` + - `.csproj` filenames matching directory names + - short project names with `NamespacePrefix` carrying the repo identity +3. Check whether test project names use recognised `*Tests` suffixes and whether shared/shared-testing + projects use exact SDK-recognised names. +4. Explain the consequences of deviations in terms of automatic `RootNamespace`, `AssemblyName`, + `PackageId`, `TargetProjectName`, and automatic project references. +5. Review test readability conventions: + - subject-based `{SubjectName}Tests` + - subject-based `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}` method names + - non-subject-based suites named clearly for their broader role + - appropriate use of TUnit categories and display names +6. Distinguish between: + - acceptable existing variance worth preserving + - structural debt that blocks the SDK's automatic behavior + - incremental rationalisation opportunities + +## Suggested output + +- A concise summary of whether the repo broadly fits the SDK conventions. +- A list of concrete mismatches, ordered by impact. +- A list of low-risk rationalisation steps for naming, placement, identity, or test readability. +- Explicit note of which behaviors are already automatic defaults and which require manual configuration. diff --git a/.agents/skills/sdk-engineering-principles/.gitignore b/.agents/skills/sdk-engineering-principles/.gitignore new file mode 100644 index 0000000..2799754 --- /dev/null +++ b/.agents/skills/sdk-engineering-principles/.gitignore @@ -0,0 +1,8 @@ +# Ignore all files +* + +# Don't ignore directories, so Git can traverse them +!*/ + +# Keep this file +!.gitignore \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index e6735ee..120d509 100644 --- a/.editorconfig +++ b/.editorconfig @@ -27,8 +27,10 @@ dotnet_search_reference_assemblies = true # Nullability settings dotnet_build_property.Nullable = enable -# Enable or disable the analyzers -dotnet_analyzer_diagnostic.severity = warning +# Per-rule severity is authoritative in this file. The SDK sets AnalysisMode/AnalysisLevel as +# MSBuild properties, and the .NET SDK ignores bulk 'dotnet_analyzer_diagnostic.*' severity +# configuration whenever those properties are present. Do not re-add a bulk entry here: it +# silently does nothing and hides which severities are actually enforced. # Visual Studio XML Project Files [*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] @@ -76,23 +78,20 @@ indent_style = tab dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = warning dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields -dotnet_naming_rule.private_fields.severity = warning -dotnet_naming_rule.private_fields.style = camel_case_underscore -dotnet_naming_rule.private_fields.symbols = private_fields -dotnet_naming_rule.private_fields_style.severity = warning -dotnet_naming_rule.private_fields_style.style = camel_case -dotnet_naming_rule.private_fields_style.symbols = private_fields dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case +# NOTE: the private-field rules that used to sit here referenced a style ('camel_case_underscore') +# and a symbol group ('private_fields') that were never defined, so they never applied. The real +# private-field rules are declared in the "StyleCop Field Naming Rules" section below. dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field dotnet_naming_symbols.non_private_static_fields.required_modifiers = static -# Constants are PascalCase +# Constants are PascalCase (field constants; local constants are locals and stay camelCase) dotnet_naming_rule.constants_should_be_pascal_case.severity = warning dotnet_naming_rule.constants_should_be_pascal_case.style = non_private_static_field_style dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants dotnet_naming_style.constant_style.capitalization = pascal_case -dotnet_naming_symbols.constants.applicable_kinds = field, local +dotnet_naming_symbols.constants.applicable_kinds = field dotnet_naming_symbols.constants.required_modifiers = const # Locals and parameters are camelCase @@ -102,9 +101,7 @@ dotnet_naming_rule.locals_should_be_camel_case.severity = warning # camel_case_style - Define the camelCase style dotnet_naming_style.camel_case_style.capitalization = camel_case -dotnet_naming_style.static_field_style.required_prefix = s_ dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local -dotnet_naming_symbols.static_fields.required_modifiers = static # first_upper_style - The first character must start with an upper-case character dotnet_naming_style.first_upper_style.capitalization = first_word_upper @@ -152,22 +149,22 @@ dotnet_naming_symbols.other_public_protected_fields_group.applicable_kinds = fie # StyleCop Field Naming Rules -# All constant fields must be PascalCase -dotnet_naming_rule.private_or_internal_field_should_be__fieldname.severity = warning -dotnet_naming_rule.private_or_internal_field_should_be__fieldname.style = _fieldname -dotnet_naming_rule.private_or_internal_field_should_be__fieldname.symbols = private_or_internal_field +# All non-private constant fields must be PascalCase. Private constants are owned by +# 'private_static_fields_group' below - a field must be covered by exactly one rule, otherwise the +# same violation is reported once per matching rule. dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.severity = warning dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.style = non_private_static_field_style dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.symbols = stylecop_constant_fields_group -dotnet_naming_symbols.stylecop_constant_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected, private +dotnet_naming_symbols.stylecop_constant_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected dotnet_naming_symbols.stylecop_constant_fields_group.applicable_kinds = field dotnet_naming_symbols.stylecop_constant_fields_group.required_modifiers = const -# All static readonly fields must be PascalCase +# All non-private static readonly fields must be PascalCase. Private static readonly fields are +# owned by 'private_static_fields_group' below (see the note on the constant rule above). dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.severity = warning dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.style = non_private_static_field_style dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.symbols = stylecop_static_readonly_fields_group -dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected, private +dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_kinds = field dotnet_naming_symbols.stylecop_static_readonly_fields_group.required_modifiers = static, readonly @@ -178,12 +175,22 @@ dotnet_naming_rule.stylecop_instance_fields_must_be_private_rule.symbols = style dotnet_naming_symbols.stylecop_fields_must_be_private_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected dotnet_naming_symbols.stylecop_fields_must_be_private_group.applicable_kinds = field -# Private fields must be camelCase -dotnet_naming_rule.stylecop_private_fields_must_be_camel_case_rule.severity = warning -dotnet_naming_rule.stylecop_private_fields_must_be_camel_case_rule.style = camel_case_style -dotnet_naming_rule.stylecop_private_fields_must_be_camel_case_rule.symbols = stylecop_private_fields_group -dotnet_naming_symbols.stylecop_private_fields_group.applicable_accessibilities = private -dotnet_naming_symbols.stylecop_private_fields_group.applicable_kinds = field +# Private static fields are PascalCase: they are type-level state and are never qualified with 'this.' +dotnet_naming_rule.private_static_fields_must_be_pascal_case_rule.severity = warning +dotnet_naming_rule.private_static_fields_must_be_pascal_case_rule.style = non_private_static_field_style +dotnet_naming_rule.private_static_fields_must_be_pascal_case_rule.symbols = private_static_fields_group +dotnet_naming_symbols.private_static_fields_group.applicable_accessibilities = private +dotnet_naming_symbols.private_static_fields_group.applicable_kinds = field +dotnet_naming_symbols.private_static_fields_group.required_modifiers = static + +# Private instance fields must be camelCase with a leading underscore: '_name', never 'name'. The +# prefix keeps field access unambiguous (so the noisy 'this.' qualifier is never needed) and keeps +# fields distinguishable from locals and parameters. +dotnet_naming_rule.private_instance_fields_must_be_camel_case_with_underscore_prefix.severity = warning +dotnet_naming_rule.private_instance_fields_must_be_camel_case_with_underscore_prefix.style = camel_case_underscore_style +dotnet_naming_rule.private_instance_fields_must_be_camel_case_with_underscore_prefix.symbols = private_instance_fields_group +dotnet_naming_symbols.private_instance_fields_group.applicable_accessibilities = private +dotnet_naming_symbols.private_instance_fields_group.applicable_kinds = field # Local variables must be camelCase dotnet_naming_rule.stylecop_local_fields_must_be_camel_case_rule.severity = silent @@ -232,25 +239,14 @@ dotnet_naming_style.type_parameter_style.required_prefix = T dotnet_naming_symbols.type_parameter_symbol.applicable_accessibilities = * dotnet_naming_symbols.type_parameter_symbol.applicable_kinds = type_parameter -# Instance fields are camelCase and start with _ -dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion -dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style -dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields -dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion -dotnet_naming_rule.instance_fields_should_be_camel_case.style = camel_case_underscore_style -dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields +# Naming style shared by the private-field rules: camelCase with a required '_' prefix. dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case dotnet_naming_style.camel_case_underscore_style.required_prefix = _ -dotnet_naming_style.instance_field_style.capitalization = camel_case -dotnet_naming_style.instance_field_style.required_prefix = _ -dotnet_naming_symbols.instance_fields.applicable_kinds = field -dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal -dotnet_naming_symbols.private_internal_fields.applicable_kinds = field # Local functions are PascalCase dotnet_naming_rule.local_functions_should_be_pascal_case.severity = warning -dotnet_naming_rule.local_functions_should_be_pascal_case.style = non_private_static_field_style -dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = all_members +dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style +dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions dotnet_naming_style.local_function_style.capitalization = pascal_case dotnet_naming_symbols.local_functions.applicable_kinds = local_function @@ -264,12 +260,10 @@ dotnet_style_qualification_for_property = false:silent dotnet_style_operator_placement_when_wrapping = end_of_line # Naming styles -dotnet_naming_rule.interface_should_be_begins_with_i.severity = warning -dotnet_naming_rule.interface_should_be_begins_with_i.style = prefix_interface_with_i_style -dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface -dotnet_naming_rule.types_should_be_pascal_case.severity = warning -dotnet_naming_rule.types_should_be_pascal_case.style = non_private_static_field_style -dotnet_naming_rule.types_should_be_pascal_case.symbols = types +# NOTE: a 'types_should_be_pascal_case' rule (symbol group 'types') and an +# 'interface_should_be_begins_with_i' rule (symbol group 'interface') used to sit here, but neither +# symbol group was ever defined, so neither rule applied. Types are covered by 'element_rule' and +# interfaces by 'interface_rule' above. # By default, name items with PascalCase dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = warning @@ -278,7 +272,6 @@ dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_m # pascal_case_style - Define the PascalCase style dotnet_naming_style.pascal_case_style.capitalization = pascal_case -dotnet_naming_symbols.all_members.applicable_kinds = * # Symbol specifications dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected @@ -286,7 +279,6 @@ dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, meth dotnet_naming_symbols.non_field_members.required_modifiers = * # Naming styles -dotnet_naming_style._fieldname.capitalization = camel_case dotnet_naming_style.begins_with_i.capitalization = pascal_case dotnet_naming_style.begins_with_i.required_prefix = I dotnet_naming_style.begins_with_i.required_suffix = @@ -314,9 +306,11 @@ dotnet_code_quality.prefer_const = true dotnet_code_quality.prefer_inferred_anonymous_type_member_names = true dotnet_code_quality.prefer_inferred_tuple_names = true dotnet_code_quality.prefer_readonly = true -dotnet_code_quality.require_accessibility_modifiers = true +# The real modifier policy is 'dotnet_style_require_accessibility_modifiers' (declared under +# "Modifier preferences" below). The 'require_accessibility_modifiers' and +# 'require_explicit_visibility' entries that used to live here are not .NET analyzer options: +# declaring them only pretended to enforce a policy that was never applied, so they were removed. dotnet_code_quality.require_explicit_type_arguments = true -dotnet_code_quality.require_explicit_visibility = true dotnet_code_quality.require_variable_declaration_for_explicit_type = false dotnet_code_quality_unused_parameters = all:warning dotnet_enable_roslyn_analyzers = true @@ -587,10 +581,14 @@ dotnet_diagnostic.CA1030.severity = suggestion dotnet_diagnostic.CA1031.severity = error # Implement standard exception constructors -dotnet_diagnostic.CA1032.severity = suggestion +# Raised to warning: the public API surface of exceptions is part of the accessibility policy and +# must not be invisible to command-line builds. +dotnet_diagnostic.CA1032.severity = warning # Interface methods should be callable by child types -dotnet_diagnostic.CA1033.severity = suggestion +# Raised to warning: explicit interface implementations that hide the member from derived types are +# an accessibility problem, not a style preference. +dotnet_diagnostic.CA1033.severity = warning # Nested types should not be visible dotnet_diagnostic.CA1034.severity = error @@ -599,7 +597,9 @@ dotnet_diagnostic.CA1034.severity = error dotnet_diagnostic.CA1036.severity = suggestion # Avoid empty interfaces -dotnet_diagnostic.CA1040.severity = suggestion +# Raised to warning: empty interfaces are unenforceable contracts and were previously invisible to +# command-line builds. +dotnet_diagnostic.CA1040.severity = warning # Provide ObsoleteAttribute message dotnet_diagnostic.CA1041.severity = warning @@ -794,7 +794,9 @@ dotnet_diagnostic.CA1513.severity = warning dotnet_diagnostic.CA1514.severity = warning # Consider making public types internal -dotnet_diagnostic.CA1515.severity = suggestion +# Reported as a real build warning (not a suggestion) so accessibility hygiene cannot be ignored: +# a public type in an application or test assembly should be made internal rather than suppressed. +dotnet_diagnostic.CA1515.severity = warning # Naming Rules (CA1700-CA1727 and IDE0130) # Naming rules support adherence to the naming conventions of the .NET design guidelines @@ -973,8 +975,9 @@ dotnet_diagnostic.CA1845.severity = error # Prefer AsSpan over Substring dotnet_diagnostic.CA1846.severity = error -dotnet_diagnostic.ignore_internalsvisibleto = true -dotnet_diagnostic.CA1852.ignore_internalsvisibleto = true +# CA1852 (seal internal types) is intentionally not suppressed for internal types that are exposed +# to other assemblies through InternalsVisibleTo: those types must still be sealed. The blanket +# suppression that used to sit here relied on a malformed key, so it never even applied. # Unsafe DataSet or DataTable in serializable type can be vulnerable to remote code execution attacks dotnet_diagnostic.CA2352.severity = error @@ -1319,8 +1322,9 @@ dotnet_diagnostic.IDE0038.severity = warning # Use local function instead of lambda dotnet_diagnostic.IDE0039.severity = suggestion -# Add accessibility modifiers -dotnet_diagnostic.IDE0040.severity = error +# IDE0040 is declared exactly once, in the "Accessibility modifiers" section above. A second entry +# here used to override that value (later entries win), which made the effective modifier policy +# ambiguous and let repos believe the rule had been turned off. # Use is null check dotnet_diagnostic.IDE0041.severity = suggestion @@ -1557,8 +1561,9 @@ dotnet_diagnostic.IDE0380.severity = warning # Remove unnecessary suppression (null-forgiving operator) dotnet_diagnostic.IDE0370.severity = warning -# Naming rule violation -dotnet_diagnostic.IDE1006.severity = silent +# Naming rule violation - must stay visible: the field-naming policy is only enforced when this +# diagnostic is reported, because naming rules without their own severity inherit this one. +dotnet_diagnostic.IDE1006.severity = warning # Embedded statements must be on their own line dotnet_diagnostic.IDE2001.severity = warning @@ -2671,17 +2676,14 @@ dotnet_diagnostic.PDS0004.severity = none [**/Extensions/**.{cs,vb}] # Files under the Extensions/ namespace-reset convention must not force developers to add pragmas; -# suppress the namespace-conflict diagnostics that arise from declaring framework-style namespaces. +# suppress only the namespace-conflict diagnostics that arise from declaring framework-style +# namespaces. Accessibility/design rules (for example CA1034) are deliberately not suppressed here. dotnet_diagnostic.IDE0130.severity = none -dotnet_diagnostic.CA1034.severity = none dotnet_diagnostic.CA1724.severity = none dotnet_diagnostic.CS0436.severity = none dotnet_diagnostic.CS1591.severity = none dotnet_diagnostic.IDE0005.severity = none -[**/{Extension,Extensions}.{cs,vb}] -dotnet_diagnostic.CA1034.severity = none - [**/Generated/**/*.{cs,vb}] generated_code = true dotnet_diagnostic.CS8602.severity = none diff --git a/Directory.Packages.props b/Directory.Packages.props index bec92e1..ad5e220 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,7 @@ 11.0.0-rc.1.26425.128 - 2.0.0 + 2.0.1-prerelease.1 1.0.0-prerelease.54 diff --git a/global.json b/global.json index 7b17e04..24cc5ad 100644 --- a/global.json +++ b/global.json @@ -5,9 +5,9 @@ "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.0" + "Purview.BuildSdk": "1.0.2.2" }, "test": { "runner": "Microsoft.Testing.Platform" } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 318df55..dfa6d44 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-results", - "version": "1.0.0-prerelease.2", + "version": "1.0.0-prerelease.3", "license": "MIT", "private": true, "keywords": [], diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 2c6086b..234a151 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -20,6 +20,12 @@ + + + + + + true netstandard2.0 - Purview.Results.SourceGeneration.CodeFixes Purview Results Source Generator Code Fixes IDE code fixes for the Purview.Results source generator: rewrites a returned union case value into the generated AsFailure<TValue>() failed result. diff --git a/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs b/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs index 71c4e01..cf37a98 100644 --- a/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs +++ b/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs @@ -12,7 +12,7 @@ // ITypeSymbol.IsUnion is [Experimental] until C# 15 union support ships, and this provider targets it. #pragma warning disable RSEXPERIMENTAL006 -namespace Purview.Results.SourceGeneration.CodeFixes; +namespace Purview.Results.SourceGenerator.CodeFixes; /// /// Offers the generated AsFailure<TValue>() helper when a union case value is returned where a @@ -40,7 +40,7 @@ public sealed class UnionCaseResultCodeFixProvider : CodeFixProvider const string EquivalenceKey = "PurviewResultsUseAsFailure"; const string ResultNamespace = "Purview.Results"; const string ResultMetadataName = "Result`2"; - const string SourceGenerationNamespace = "Purview.Results.SourceGeneration"; + const string SourceGenerationNamespace = "Purview.Results.SourceGenerator"; const string GenerateResultAttributeMetadataName = "GenerateResultAttribute"; const string FailureHelperName = "AsFailure"; diff --git a/src/src/SourceGenerator/Analyzers/ResultsDiagnosticAnalyzer.cs b/src/src/SourceGenerator/Analyzers/ResultsDiagnosticAnalyzer.cs index e735320..65c97c7 100644 --- a/src/src/SourceGenerator/Analyzers/ResultsDiagnosticAnalyzer.cs +++ b/src/src/SourceGenerator/Analyzers/ResultsDiagnosticAnalyzer.cs @@ -1,9 +1,9 @@ -using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; -using Purview.Results.SourceGeneration.Diagnostics; +using Purview.Results.SourceGenerator.Diagnostics; +using System.Collections.Immutable; -namespace Purview.Results.SourceGeneration.Analyzers; +namespace Purview.Results.SourceGenerator.Analyzers; /// /// Raises the per-target result diagnostics so consumers see them in the IDE and in build output. diff --git a/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs b/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs index 14050e5..e247521 100644 --- a/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs +++ b/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs @@ -1,7 +1,7 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis; -namespace Purview.Results.SourceGeneration.Diagnostics; +namespace Purview.Results.SourceGenerator.Diagnostics; /// /// Identifies which host owns a rule and how it affects generation. diff --git a/src/src/SourceGenerator/Diagnostics/ResultDiagnostic.cs b/src/src/SourceGenerator/Diagnostics/ResultDiagnostic.cs index 9883f20..9528850 100644 --- a/src/src/SourceGenerator/Diagnostics/ResultDiagnostic.cs +++ b/src/src/SourceGenerator/Diagnostics/ResultDiagnostic.cs @@ -1,7 +1,7 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis; -namespace Purview.Results.SourceGeneration.Diagnostics; +namespace Purview.Results.SourceGenerator.Diagnostics; /// /// A single diagnostic finding produced by the shared diagnostics library. diff --git a/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs b/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs index 09ea7e8..0e4727b 100644 --- a/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs +++ b/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs @@ -1,12 +1,12 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis; -using Purview.Results.SourceGeneration.Helpers; +using Purview.Results.SourceGenerator.Helpers; // The language's union support is a preview feature of C# 15; ITypeSymbol.IsUnion is marked // [Experimental] until the feature ships, and this analysis deliberately targets it. #pragma warning disable RSEXPERIMENTAL006 -namespace Purview.Results.SourceGeneration.Diagnostics; +namespace Purview.Results.SourceGenerator.Diagnostics; /// /// The outcome of analyzing one opted-in target, shared by the analyzer (which reports diff --git a/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs b/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs index e7418cc..2c17ab2 100644 --- a/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs +++ b/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs @@ -1,11 +1,11 @@ using System.Collections.Immutable; using System.Text; using Microsoft.CodeAnalysis; -using Purview.Results.SourceGeneration.Diagnostics; -using Purview.Results.SourceGeneration.Helpers; -using Purview.Results.SourceGeneration.Models; +using Purview.Results.SourceGenerator.Diagnostics; +using Purview.Results.SourceGenerator.Helpers; +using Purview.Results.SourceGenerator.Models; -namespace Purview.Results.SourceGeneration.Discovery; +namespace Purview.Results.SourceGenerator.Discovery; /// /// Converts an opted-in attribute target into the immutable the pipeline diff --git a/src/src/SourceGenerator/Emit/GenerateResultAttributeEmitter.cs b/src/src/SourceGenerator/Emit/GenerateResultAttributeEmitter.cs index 6f6662e..6705c2b 100644 --- a/src/src/SourceGenerator/Emit/GenerateResultAttributeEmitter.cs +++ b/src/src/SourceGenerator/Emit/GenerateResultAttributeEmitter.cs @@ -1,9 +1,9 @@ using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; -using Purview.Results.SourceGeneration.Helpers; +using Purview.Results.SourceGenerator.Helpers; -namespace Purview.Results.SourceGeneration.Emit; +namespace Purview.Results.SourceGenerator.Emit; /// /// Emits the GenerateResultAttribute that consumers apply to their unions. diff --git a/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs b/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs index fd7d9e6..ae19e8b 100644 --- a/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs +++ b/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs @@ -1,10 +1,10 @@ using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; -using Purview.Results.SourceGeneration.Helpers; -using Purview.Results.SourceGeneration.Models; +using Purview.Results.SourceGenerator.Helpers; +using Purview.Results.SourceGenerator.Models; -namespace Purview.Results.SourceGeneration.Emit; +namespace Purview.Results.SourceGenerator.Emit; /// /// Emits the strongly typed result helpers for every opted-in union. diff --git a/src/src/SourceGenerator/Helpers/DocText.cs b/src/src/SourceGenerator/Helpers/DocText.cs index efbac0b..50c1a89 100644 --- a/src/src/SourceGenerator/Helpers/DocText.cs +++ b/src/src/SourceGenerator/Helpers/DocText.cs @@ -1,4 +1,4 @@ -namespace Purview.Results.SourceGeneration.Helpers; +namespace Purview.Results.SourceGenerator.Helpers; /// /// Composes and escapes text that is written into generated XML documentation. diff --git a/src/src/SourceGenerator/Helpers/PropertyLibrary.cs b/src/src/SourceGenerator/Helpers/PropertyLibrary.cs index 250bb47..acde104 100644 --- a/src/src/SourceGenerator/Helpers/PropertyLibrary.cs +++ b/src/src/SourceGenerator/Helpers/PropertyLibrary.cs @@ -1,4 +1,4 @@ -namespace Purview.Results.SourceGeneration.Helpers; +namespace Purview.Results.SourceGenerator.Helpers; /// /// Contains the names, hint names and build properties used by the results source generator. @@ -15,7 +15,7 @@ static class PropertyLibrary /// The namespace of the GenerateResultAttribute opt-in attribute generated into consuming /// assemblies. /// - public const string SourceGenerationNamespace = "Purview.Results.SourceGeneration"; + public const string SourceGenerationNamespace = "Purview.Results.SourceGenerator"; /// /// The metadata name of the generated opt-in attribute. diff --git a/src/src/SourceGenerator/Helpers/ResultsTypeLibrary.cs b/src/src/SourceGenerator/Helpers/ResultsTypeLibrary.cs index 6077758..96b96e5 100644 --- a/src/src/SourceGenerator/Helpers/ResultsTypeLibrary.cs +++ b/src/src/SourceGenerator/Helpers/ResultsTypeLibrary.cs @@ -1,4 +1,4 @@ -namespace Purview.Results.SourceGeneration.Helpers; +namespace Purview.Results.SourceGenerator.Helpers; /// /// Provides the TypeIdentity values and namespaces the results source generator resolves diff --git a/src/src/SourceGenerator/Models/ResultUnionModel.cs b/src/src/SourceGenerator/Models/ResultUnionModel.cs index 5fb0bf2..34423ec 100644 --- a/src/src/SourceGenerator/Models/ResultUnionModel.cs +++ b/src/src/SourceGenerator/Models/ResultUnionModel.cs @@ -1,7 +1,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; -namespace Purview.Results.SourceGeneration.Models; +namespace Purview.Results.SourceGenerator.Models; /// /// A cache-safe description of a source location: the originating file path plus spans. diff --git a/src/src/SourceGenerator/ResultsSourceGenerator.cs b/src/src/SourceGenerator/ResultsSourceGenerator.cs index 0718590..69ed0d0 100644 --- a/src/src/SourceGenerator/ResultsSourceGenerator.cs +++ b/src/src/SourceGenerator/ResultsSourceGenerator.cs @@ -1,10 +1,10 @@ using Microsoft.CodeAnalysis; -using Purview.Results.SourceGeneration.Discovery; -using Purview.Results.SourceGeneration.Emit; -using Purview.Results.SourceGeneration.Helpers; -using Purview.Results.SourceGeneration.Validation; +using Purview.Results.SourceGenerator.Discovery; +using Purview.Results.SourceGenerator.Emit; +using Purview.Results.SourceGenerator.Helpers; +using Purview.Results.SourceGenerator.Validation; -namespace Purview.Results.SourceGeneration; +namespace Purview.Results.SourceGenerator; /// /// Generates strongly typed helpers that create diff --git a/src/src/SourceGenerator/SourceGenerator.csproj b/src/src/SourceGenerator/SourceGenerator.csproj index 941f43e..1f637f0 100644 --- a/src/src/SourceGenerator/SourceGenerator.csproj +++ b/src/src/SourceGenerator/SourceGenerator.csproj @@ -22,12 +22,11 @@ --> netstandard2.0 - Purview.Results.SourceGeneration Purview Results Source Generator .NET source generator that makes C# 15 union error cases ergonomic to use with the Purview.Results Result<TValue, TError> type. source-generator;results;result;union;unions;csharp-15;net11;purview;results; diff --git a/src/src/SourceGenerator/Suppressors/UnionEqualityDiagnosticSuppressor.cs b/src/src/SourceGenerator/Suppressors/UnionEqualityDiagnosticSuppressor.cs index e26b65b..f911fb5 100644 --- a/src/src/SourceGenerator/Suppressors/UnionEqualityDiagnosticSuppressor.cs +++ b/src/src/SourceGenerator/Suppressors/UnionEqualityDiagnosticSuppressor.cs @@ -2,13 +2,13 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; -using Purview.Results.SourceGeneration.Diagnostics; +using Purview.Results.SourceGenerator.Diagnostics; // The language's union support is a preview feature of C# 15; ITypeSymbol.IsUnion is marked // [Experimental] until the feature ships, and this suppression deliberately targets it. #pragma warning disable RSEXPERIMENTAL006 -namespace Purview.Results.SourceGeneration.Suppressors; +namespace Purview.Results.SourceGenerator.Suppressors; /// /// Suppresses CA1815 (override Equals and the equality operators on value types) for the union diff --git a/src/src/SourceGenerator/Validation/ResultUnionValidator.cs b/src/src/SourceGenerator/Validation/ResultUnionValidator.cs index ced96b3..3af4415 100644 --- a/src/src/SourceGenerator/Validation/ResultUnionValidator.cs +++ b/src/src/SourceGenerator/Validation/ResultUnionValidator.cs @@ -1,9 +1,9 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis; -using Purview.Results.SourceGeneration.Diagnostics; -using Purview.Results.SourceGeneration.Models; +using Purview.Results.SourceGenerator.Diagnostics; +using Purview.Results.SourceGenerator.Models; -namespace Purview.Results.SourceGeneration.Validation; +namespace Purview.Results.SourceGenerator.Validation; /// /// Validates the discovered union models against each other and produces the single aggregate generation diff --git a/src/tests/AspNetCore.UnitTests/ResultsHttpTestUnions.cs b/src/tests/AspNetCore.UnitTests/ResultsHttpTestUnions.cs index 7a4ac60..a287eab 100644 --- a/src/tests/AspNetCore.UnitTests/ResultsHttpTestUnions.cs +++ b/src/tests/AspNetCore.UnitTests/ResultsHttpTestUnions.cs @@ -1,4 +1,4 @@ -using Purview.Results.SourceGeneration; +using Purview.Results.SourceGenerator; namespace Purview.Results.AspNetCore; diff --git a/src/tests/Results.UnitTests/ResultsTests.cs b/src/tests/Results.UnitTests/ResultsTests.cs index 05efa1b..8125ee0 100644 --- a/src/tests/Results.UnitTests/ResultsTests.cs +++ b/src/tests/Results.UnitTests/ResultsTests.cs @@ -48,10 +48,10 @@ public async Task Value_WhenFailure_ShouldThrow() { var result = Result.Failure(new(Error)); - string act() => result.Value; + string Act() => result.Value; await Assert - .That((Func)act) + .That((Func)Act) .ThrowsExactly() .WithMessage("The value of a non-successful result cannot be accessed.", StringComparison.Ordinal); } @@ -61,10 +61,10 @@ public async Task Error_WhenSuccess_ShouldThrow() { var result = Result.Success(Value); - TestError act() => result.Error; + TestError Act() => result.Error; await Assert - .That(act) + .That(Act) .ThrowsExactly() .WithMessage("The error of a non-failed result cannot be accessed.", StringComparison.Ordinal); } @@ -74,9 +74,9 @@ public async Task Value_WhenUninitialized_ShouldThrow() { Result result = default; - string act() => result.Value; + string Act() => result.Value; - await Assert.That((Func)act).ThrowsExactly(); + await Assert.That((Func)Act).ThrowsExactly(); } [Test] @@ -84,9 +84,9 @@ public async Task Error_WhenUninitialized_ShouldThrow() { Result result = default; - TestError act() => result.Error; + TestError Act() => result.Error; - await Assert.That(act).ThrowsExactly(); + await Assert.That(Act).ThrowsExactly(); } #endregion @@ -190,10 +190,10 @@ public async Task Match_WhenUninitialized_ShouldThrow() { Result result = default; - string act() => result.Match(success: value => value, failure: error => error.Message); + string Act() => result.Match(success: value => value, failure: error => error.Message); await Assert - .That((Func)act) + .That((Func)Act) .ThrowsExactly() .WithMessage("The result is uninitialized.", StringComparison.Ordinal); } @@ -245,10 +245,10 @@ public async Task Map_WhenUninitialized_ShouldThrow() { Result result = default; - Result act() => result.Map(value => value.Length); + Result Act() => result.Map(value => value.Length); await Assert - .That(act) + .That(Act) .ThrowsExactly() .WithMessage("The result is uninitialized.", StringComparison.Ordinal); } @@ -355,10 +355,10 @@ public async Task Bind_WhenUninitialized_ShouldThrow() { Result result = default; - Result act() => result.Bind(value => Result.Success(value.Length)); + Result Act() => result.Bind(value => Result.Success(value.Length)); await Assert - .That(act) + .That(Act) .ThrowsExactly() .WithMessage("The result is uninitialized.", StringComparison.Ordinal); } diff --git a/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs b/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs index eb5d102..59ab3ae 100644 --- a/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs +++ b/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs @@ -1,5 +1,3 @@ -using Purview.Results.SourceGeneration; - namespace Purview.Results.SourceGenerator; // Union declarations used by the generated-code runtime tests. They live in the test project so the diff --git a/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs b/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs index d1541e9..4d1ee8b 100644 --- a/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs +++ b/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs @@ -1,6 +1,6 @@ using System.Globalization; using Microsoft.CodeAnalysis; -using Purview.Results.SourceGeneration.Analyzers; +using Purview.Results.SourceGenerator.Analyzers; namespace Purview.Results.SourceGenerator; diff --git a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorCacheTests.cs b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorCacheTests.cs index c3600eb..037e3c0 100644 --- a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorCacheTests.cs +++ b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorCacheTests.cs @@ -1,5 +1,4 @@ using Microsoft.CodeAnalysis; -using Purview.Results.SourceGeneration; namespace Purview.Results.SourceGenerator; diff --git a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorDiagnosticsTests.cs b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorDiagnosticsTests.cs index e57f505..29813b8 100644 --- a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorDiagnosticsTests.cs +++ b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorDiagnosticsTests.cs @@ -1,5 +1,4 @@ -using Purview.Results.SourceGeneration; -using Purview.Results.SourceGeneration.Analyzers; +using Purview.Results.SourceGenerator.Analyzers; namespace Purview.Results.SourceGenerator; diff --git a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTestOptions.cs b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTestOptions.cs index f974864..213f1c3 100644 --- a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTestOptions.cs +++ b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTestOptions.cs @@ -38,7 +38,7 @@ public ResultsSourceGeneratorTestOptions() LanguageVersion = Microsoft.CodeAnalysis.CSharp.LanguageVersion.Preview; AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange(typeof(Result<,>)); - AdditionalNamespaces = AdditionalNamespaces.Add("Purview.Results").Add("Purview.Results.SourceGeneration"); + AdditionalNamespaces = AdditionalNamespaces.Add("Purview.Results").Add("Purview.Results.SourceGenerator"); DisableSourceGeneratorPropertyName = DisableGeneratorProperty; ExcludeGeneratedSourceHintNames = [.. PostInitializationHintNames]; diff --git a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs index 82ffb20..36bce37 100644 --- a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs @@ -1,5 +1,3 @@ -using Purview.Results.SourceGeneration; - namespace Purview.Results.SourceGenerator; /// diff --git a/src/tests/SourceGenerator.UnitTests/TestSources.cs b/src/tests/SourceGenerator.UnitTests/TestSources.cs index 75e80eb..c3958fa 100644 --- a/src/tests/SourceGenerator.UnitTests/TestSources.cs +++ b/src/tests/SourceGenerator.UnitTests/TestSources.cs @@ -10,7 +10,7 @@ static class TestSources /// running the generator (which is what emits the attribute in a real compilation). /// public const string GenerateResultAttributeDeclaration = """ - namespace Purview.Results.SourceGeneration + namespace Purview.Results.SourceGenerator { [System.AttributeUsage( System.AttributeTargets.Class diff --git a/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs b/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs index c824f13..8cdd221 100644 --- a/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs +++ b/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs @@ -4,7 +4,7 @@ public sealed class UnionCaseResultCodeFixProviderTests { const string UnionSource = """ using Purview.Results; - using Purview.Results.SourceGeneration; + using Purview.Results.SourceGenerator; namespace Test { @@ -172,7 +172,7 @@ CancellationToken cancellationToken namespace Domain { - using Purview.Results.SourceGeneration; + using Purview.Results.SourceGenerator; public readonly record struct TenantId(string Value); diff --git a/src/tests/SourceGenerator.UnitTests/UnionCodeFixTestHarness.cs b/src/tests/SourceGenerator.UnitTests/UnionCodeFixTestHarness.cs index a78de69..b4e8501 100644 --- a/src/tests/SourceGenerator.UnitTests/UnionCodeFixTestHarness.cs +++ b/src/tests/SourceGenerator.UnitTests/UnionCodeFixTestHarness.cs @@ -1,11 +1,10 @@ -using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CodeActions; using Microsoft.CodeAnalysis.CodeFixes; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Formatting; -using Purview.Results.SourceGeneration; -using Purview.Results.SourceGeneration.CodeFixes; +using Purview.Results.SourceGenerator.CodeFixes; +using System.Collections.Immutable; namespace Purview.Results.SourceGenerator; diff --git a/src/tests/SourceGenerator.UnitTests/UnionCompilerBehaviourTests.cs b/src/tests/SourceGenerator.UnitTests/UnionCompilerBehaviourTests.cs index b235132..65c737d 100644 --- a/src/tests/SourceGenerator.UnitTests/UnionCompilerBehaviourTests.cs +++ b/src/tests/SourceGenerator.UnitTests/UnionCompilerBehaviourTests.cs @@ -1,5 +1,4 @@ using Microsoft.CodeAnalysis; -using Purview.Results.SourceGeneration; // The language's union support is a preview feature of C# 15; ITypeSymbol.IsUnion is marked // [Experimental] until the feature ships, and these tests deliberately exercise it. diff --git a/src/tests/SourceGenerator.UnitTests/UnionEqualityDiagnosticSuppressorTests.cs b/src/tests/SourceGenerator.UnitTests/UnionEqualityDiagnosticSuppressorTests.cs index 2268116..6645501 100644 --- a/src/tests/SourceGenerator.UnitTests/UnionEqualityDiagnosticSuppressorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/UnionEqualityDiagnosticSuppressorTests.cs @@ -1,9 +1,8 @@ -using System.Collections.Immutable; -using System.Globalization; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; -using Purview.Results.SourceGeneration; -using Purview.Results.SourceGeneration.Suppressors; +using Purview.Results.SourceGenerator.Suppressors; +using System.Collections.Immutable; +using System.Globalization; namespace Purview.Results.SourceGenerator; diff --git a/src/tests/ZodSharp.AspNetCore.UnitTests/ValidationTestUnions.cs b/src/tests/ZodSharp.AspNetCore.UnitTests/ValidationTestUnions.cs index 96a3d2a..089e7f9 100644 --- a/src/tests/ZodSharp.AspNetCore.UnitTests/ValidationTestUnions.cs +++ b/src/tests/ZodSharp.AspNetCore.UnitTests/ValidationTestUnions.cs @@ -1,4 +1,4 @@ -using Purview.Results.SourceGeneration; +using Purview.Results.SourceGenerator; using System.Collections.Immutable; using ZodSharp.Core; diff --git a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodSharpResultsHttpRegistrationTests.cs b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodSharpResultsHttpRegistrationTests.cs index 6306aab..a571eff 100644 --- a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodSharpResultsHttpRegistrationTests.cs +++ b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodSharpResultsHttpRegistrationTests.cs @@ -1,7 +1,7 @@ -using System.Collections.Immutable; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Purview.Results.AspNetCore; +using System.Collections.Immutable; using ZodSharp.Core; namespace Purview.Results.ZodSharp.AspNetCore; @@ -19,15 +19,15 @@ public async Task Map_GivenValidationErrorCarrier_ReturnsValidationProblemThroug var context = CreateContext(services); // Act - var (StatusCode, Body) = await ExecuteAsync( + var (statusCode, body) = await ExecuteAsync( mapper.Map(new InputRejected(7, CreateErrors()).AsFailure(), context), context ); // Assert - await Assert.That(StatusCode).IsEqualTo(StatusCodes.Status400BadRequest); - await Assert.That(Body).Contains(InvalidNameCode); - await Assert.That(Body).Contains(context.TraceIdentifier); + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status400BadRequest); + await Assert.That(body).Contains(InvalidNameCode); + await Assert.That(body).Contains(context.TraceIdentifier); } [Test] @@ -39,11 +39,11 @@ public async Task Map_GivenErrorThatIsNotAValidationCarrier_ReturnsTheUnmappedPr var context = CreateContext(services); // Act - var (StatusCode, Body) = await ExecuteAsync(mapper.Map(new ItemMissing(7).AsFailure(), context), context); + var (statusCode, body) = await ExecuteAsync(mapper.Map(new ItemMissing(7).AsFailure(), context), context); // Assert - await Assert.That(StatusCode).IsEqualTo(StatusCodes.Status500InternalServerError); - await Assert.That(Body).Contains(nameof(ItemMissing)); + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status500InternalServerError); + await Assert.That(body).Contains(nameof(ItemMissing)); } [Test] @@ -59,14 +59,14 @@ public async Task Map_GivenMappingForTheCase_ReturnsTheMappedResponseInsteadOfTh var context = CreateContext(services); // Act - var (StatusCode, Body) = await ExecuteAsync( + var (statusCode, body) = await ExecuteAsync( mapper.Map(new InputRejected(7, CreateErrors()).AsFailure(), context), context ); // Assert - await Assert.That(StatusCode).IsEqualTo(StatusCodes.Status409Conflict); - await Assert.That(Body).Contains("rejected 7"); + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status409Conflict); + await Assert.That(body).Contains("rejected 7"); } [Test] @@ -78,14 +78,14 @@ public async Task Map_GivenAggregateRejectedCase_ReturnsValidationProblem() var context = CreateContext(services); // Act - var (StatusCode, Body) = await ExecuteAsync( + var (statusCode, body) = await ExecuteAsync( mapper.Map(new AggregateRejected("operation", CreateErrors()).AsFailure(), context), context ); // Assert - await Assert.That(StatusCode).IsEqualTo(StatusCodes.Status400BadRequest); - await Assert.That(Body).Contains(InvalidNameCode); + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status400BadRequest); + await Assert.That(body).Contains(InvalidNameCode); } static ServiceProvider CreateServices(Action? configure = null) diff --git a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodValidationProblemsTests.cs b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodValidationProblemsTests.cs index 184e292..8774db6 100644 --- a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodValidationProblemsTests.cs +++ b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodValidationProblemsTests.cs @@ -1,7 +1,7 @@ -using System.Collections.Immutable; -using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using System.Collections.Immutable; +using System.Text.Json; using ZodSharp; using ZodSharp.AspNetCore; using ZodSharp.Core; @@ -19,16 +19,16 @@ public async Task ToProblem_GivenValidationErrors_ReturnsValidationProblemWithTh var errors = CreateErrors(); // Act - var (StatusCode, Body, ContentType) = await ExecuteAsync( + var (statusCode, body, contentType) = await ExecuteAsync( ZodValidationProblems.ToProblem(errors, CreateOptions()), CreateContext() ); // Assert - await Assert.That(StatusCode).IsEqualTo(StatusCodes.Status400BadRequest); - await Assert.That(ContentType).Contains("application/problem+json"); - await Assert.That(Body).Contains(InvalidNameCode); - await Assert.That(Body).Contains("issues"); + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status400BadRequest); + await Assert.That(contentType).Contains("application/problem+json"); + await Assert.That(body).Contains(InvalidNameCode); + await Assert.That(body).Contains("issues"); } [Test] @@ -49,14 +49,14 @@ public async Task ToProblem_GivenRegisteredErrorType_ReturnsItsStatusAndTitle() ZodProblemDetailsOptions options = new() { Registry = registry }; // Act - var (StatusCode, Body, _) = await ExecuteAsync( + var (statusCode, body, _) = await ExecuteAsync( ZodValidationProblems.ToProblem(CreateErrors(), options), CreateContext() ); // Assert - await Assert.That(StatusCode).IsEqualTo(StatusCodes.Status422UnprocessableEntity); - await Assert.That(Body).Contains("The name is not valid."); + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status422UnprocessableEntity); + await Assert.That(body).Contains("The name is not valid."); } [Test] @@ -66,13 +66,13 @@ public async Task ToProblem_GivenStatusCodeSelector_ReturnsItsStatus() ZodProblemDetailsOptions options = new() { StatusCodeSelector = _ => StatusCodes.Status409Conflict }; // Act - var (StatusCode, Body, ContentType) = await ExecuteAsync( + var (statusCode, body, contentType) = await ExecuteAsync( ZodValidationProblems.ToProblem(CreateErrors(), options), CreateContext() ); // Assert - await Assert.That(StatusCode).IsEqualTo(StatusCodes.Status409Conflict); + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status409Conflict); } [Test] @@ -82,13 +82,13 @@ public async Task ToProblem_GivenTraceId_IncludesItInTheExtensions() var context = CreateContext(); // Act - var (_, Body, _) = await ExecuteAsync( + var (_, body, _) = await ExecuteAsync( ZodValidationProblems.ToProblem(CreateErrors(), CreateOptions(), traceId: context.TraceIdentifier), context ); // Assert - await Assert.That(Body).Contains(context.TraceIdentifier); + await Assert.That(body).Contains(context.TraceIdentifier); } [Test] @@ -101,7 +101,8 @@ public async Task ToProblem_GivenTheSameErrorsAShownException_ProducesTheSameRes var context = CreateContext(); // Act - var (StatusCode, Body, ContentType) = await ExecuteAsync( + var (statusCode, + body, contentType) = await ExecuteAsync( ZodValidationProblems.ToProblem(errors, options), context ); @@ -111,10 +112,10 @@ public async Task ToProblem_GivenTheSameErrorsAShownException_ProducesTheSameRes ); // Assert - await Assert.That(StatusCode).IsEqualTo(exceptionProblem.Status); - await Assert.That(ContentType).Contains("application/problem+json"); + await Assert.That(statusCode).IsEqualTo(exceptionProblem.Status); + await Assert.That(contentType).Contains("application/problem+json"); - using var document = JsonDocument.Parse(Body); + using var document = JsonDocument.Parse(body); var root = document.RootElement; await Assert.That(root.GetProperty("title").GetString()).IsEqualTo(exceptionProblem.Title); @@ -132,14 +133,14 @@ public async Task ToValidationProblem_GivenValidationErrorCarrier_ReturnsValidat var context = CreateContext(); // Act - var (StatusCode, Body, _) = await ExecuteAsync( + var (statusCode, body, _) = await ExecuteAsync( new InputRejected(7, CreateErrors()).ToValidationProblem(context), context ); // Assert - await Assert.That(StatusCode).IsEqualTo(StatusCodes.Status400BadRequest); - await Assert.That(Body).Contains(InvalidNameCode); + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status400BadRequest); + await Assert.That(body).Contains(InvalidNameCode); } [Test] @@ -154,13 +155,13 @@ public async Task ToValidationProblem_GivenTraceIdDisabled_OmitsTheTraceIdentifi var context = CreateContext(provider); // Act - var (StatusCode, Body, ContentType) = await ExecuteAsync( + var (statusCode, body, contentType) = await ExecuteAsync( new InputRejected(7, CreateErrors()).ToValidationProblem(context), context ); // Assert - await Assert.That(Body).DoesNotContain(context.TraceIdentifier); + await Assert.That(body).DoesNotContain(context.TraceIdentifier); } static ZodProblemDetailsOptions CreateOptions() => new(); diff --git a/src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs b/src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs index 2e3249d..4a8dd99 100644 --- a/src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs +++ b/src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs @@ -1,4 +1,4 @@ -using Purview.Results.SourceGeneration; +using Purview.Results.SourceGenerator; using System.Collections.Immutable; using ZodSharp.Core; From d5d4c8a8728380bd66f5bab00835a25067c397a5 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 2 Oct 2026 16:07:45 +0100 Subject: [PATCH 02/11] refactor: namespace change --- Directory.Packages.props | 3 +++ src/Directory.Build.props | 1 - src/src/Examples.AspNetCore.Zod/TenantError.cs | 1 - src/src/Examples.AspNetCore/TenantError.cs | 2 -- src/src/Examples.Basic/TenantError.cs | 2 -- src/src/Examples.Zod/TenantError.cs | 1 - .../UnionCaseResultCodeFixProvider.cs | 3 +-- .../Analyzers/ResultsDiagnosticAnalyzer.cs | 2 +- .../Emit/GenerateResultAttributeEmitter.cs | 2 +- .../SourceGenerator/Helpers/PropertyLibrary.cs | 6 ------ .../Helpers/ResultsTypeLibrary.cs | 18 ++++++------------ .../ResultsHttpTestUnions.cs | 2 -- .../ResultsSourceGeneratorTestOptions.cs | 2 +- .../SourceGenerator.UnitTests/TestSources.cs | 2 +- .../UnionCaseResultCodeFixProviderTests.cs | 3 --- .../UnionCodeFixTestHarness.cs | 2 +- .../UnionEqualityDiagnosticSuppressorTests.cs | 4 ++-- .../ValidationTestUnions.cs | 1 - .../ZodSharpResultsHttpRegistrationTests.cs | 2 +- .../ZodValidationProblemsTests.cs | 7 +++---- .../ZodSharp.UnitTests/ValidationTestUnions.cs | 1 - 21 files changed, 21 insertions(+), 46 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index ad5e220..39741f9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,4 +1,7 @@ + + true + 11.0.0-rc.1.26425.128 diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 234a151..7808d79 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -15,7 +15,6 @@ are excluded repo-wide. --> true - true diff --git a/src/src/Examples.AspNetCore.Zod/TenantError.cs b/src/src/Examples.AspNetCore.Zod/TenantError.cs index 671b452..e058198 100644 --- a/src/src/Examples.AspNetCore.Zod/TenantError.cs +++ b/src/src/Examples.AspNetCore.Zod/TenantError.cs @@ -1,4 +1,3 @@ -using Purview.Results.SourceGenerator; using Purview.Results.ZodSharp; using System.Collections.Immutable; using ZodSharp.Core; diff --git a/src/src/Examples.AspNetCore/TenantError.cs b/src/src/Examples.AspNetCore/TenantError.cs index 43da63b..c534c8c 100644 --- a/src/src/Examples.AspNetCore/TenantError.cs +++ b/src/src/Examples.AspNetCore/TenantError.cs @@ -1,5 +1,3 @@ -using Purview.Results.SourceGenerator; - namespace Purview.Results.Examples.AspNetCore; /// diff --git a/src/src/Examples.Basic/TenantError.cs b/src/src/Examples.Basic/TenantError.cs index 0364de7..6a23074 100644 --- a/src/src/Examples.Basic/TenantError.cs +++ b/src/src/Examples.Basic/TenantError.cs @@ -1,5 +1,3 @@ -using Purview.Results.SourceGenerator; - namespace Purview.Results.Examples.Basic; /// diff --git a/src/src/Examples.Zod/TenantError.cs b/src/src/Examples.Zod/TenantError.cs index 3d4a914..13a07bb 100644 --- a/src/src/Examples.Zod/TenantError.cs +++ b/src/src/Examples.Zod/TenantError.cs @@ -1,4 +1,3 @@ -using Purview.Results.SourceGenerator; using Purview.Results.ZodSharp; using System.Collections.Immutable; using ZodSharp.Core; diff --git a/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs b/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs index cf37a98..375b80a 100644 --- a/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs +++ b/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs @@ -40,7 +40,6 @@ public sealed class UnionCaseResultCodeFixProvider : CodeFixProvider const string EquivalenceKey = "PurviewResultsUseAsFailure"; const string ResultNamespace = "Purview.Results"; const string ResultMetadataName = "Result`2"; - const string SourceGenerationNamespace = "Purview.Results.SourceGenerator"; const string GenerateResultAttributeMetadataName = "GenerateResultAttribute"; const string FailureHelperName = "AsFailure"; @@ -126,7 +125,7 @@ static bool HasGenerateResultAttribute(INamedTypeSymbol union) { if ( attribute.AttributeClass is { } attributeClass - && HasMetadataName(attributeClass, SourceGenerationNamespace, GenerateResultAttributeMetadataName) + && HasMetadataName(attributeClass, ResultNamespace, GenerateResultAttributeMetadataName) ) { return true; diff --git a/src/src/SourceGenerator/Analyzers/ResultsDiagnosticAnalyzer.cs b/src/src/SourceGenerator/Analyzers/ResultsDiagnosticAnalyzer.cs index 65c97c7..90a4ff3 100644 --- a/src/src/SourceGenerator/Analyzers/ResultsDiagnosticAnalyzer.cs +++ b/src/src/SourceGenerator/Analyzers/ResultsDiagnosticAnalyzer.cs @@ -1,7 +1,7 @@ +using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; using Purview.Results.SourceGenerator.Diagnostics; -using System.Collections.Immutable; namespace Purview.Results.SourceGenerator.Analyzers; diff --git a/src/src/SourceGenerator/Emit/GenerateResultAttributeEmitter.cs b/src/src/SourceGenerator/Emit/GenerateResultAttributeEmitter.cs index 6705c2b..0bf05d2 100644 --- a/src/src/SourceGenerator/Emit/GenerateResultAttributeEmitter.cs +++ b/src/src/SourceGenerator/Emit/GenerateResultAttributeEmitter.cs @@ -20,7 +20,7 @@ public static void EmitAttribute(IncrementalGeneratorPostInitializationContext c CodeWriter writer = new(GenerationSettings.Create()); writer.AutoGeneratedHeader(); - writer.FileScopedNamespace(PropertyLibrary.SourceGenerationNamespace); + writer.FileScopedNamespace(PropertyLibrary.ResultsNamespace); writer.XmlSummary( "Opts a C# 15 union declaration into result helper generation.", diff --git a/src/src/SourceGenerator/Helpers/PropertyLibrary.cs b/src/src/SourceGenerator/Helpers/PropertyLibrary.cs index acde104..1a1d716 100644 --- a/src/src/SourceGenerator/Helpers/PropertyLibrary.cs +++ b/src/src/SourceGenerator/Helpers/PropertyLibrary.cs @@ -11,12 +11,6 @@ static class PropertyLibrary /// public const string ResultsNamespace = "Purview.Results"; - /// - /// The namespace of the GenerateResultAttribute opt-in attribute generated into consuming - /// assemblies. - /// - public const string SourceGenerationNamespace = "Purview.Results.SourceGenerator"; - /// /// The metadata name of the generated opt-in attribute. /// diff --git a/src/src/SourceGenerator/Helpers/ResultsTypeLibrary.cs b/src/src/SourceGenerator/Helpers/ResultsTypeLibrary.cs index 96b96e5..d5a99a3 100644 --- a/src/src/SourceGenerator/Helpers/ResultsTypeLibrary.cs +++ b/src/src/SourceGenerator/Helpers/ResultsTypeLibrary.cs @@ -10,16 +10,6 @@ namespace Purview.Results.SourceGenerator.Helpers; /// static class ResultsTypeLibrary { - /// - /// The namespace of the runtime Purview.Results library. - /// - public const string ResultsNamespace = PropertyLibrary.ResultsNamespace; - - /// - /// The namespace of the generated opt-in attribute. - /// - public const string SourceGenerationNamespace = PropertyLibrary.SourceGenerationNamespace; - /// /// The namespace that contains the union contracts the language uses to describe unions. /// @@ -30,14 +20,18 @@ static class ResultsTypeLibrary /// public static readonly TypeIdentity GenerateResultAttribute = new( PropertyLibrary.GenerateResultAttributeName, - SourceGenerationNamespace + PropertyLibrary.ResultsNamespace ); /// /// The runtime Result<TValue, TError> result type. Callers apply the generic arguments /// for a specific union. /// - public static readonly TypeIdentity Result = new(typeName: "Result", @namespace: ResultsNamespace, arity: 2); + public static readonly TypeIdentity Result = new( + typeName: "Result", + @namespace: PropertyLibrary.ResultsNamespace, + arity: 2 + ); /// /// The language-specific System.Runtime.CompilerServices.IUnion interface implemented by diff --git a/src/tests/AspNetCore.UnitTests/ResultsHttpTestUnions.cs b/src/tests/AspNetCore.UnitTests/ResultsHttpTestUnions.cs index a287eab..284e464 100644 --- a/src/tests/AspNetCore.UnitTests/ResultsHttpTestUnions.cs +++ b/src/tests/AspNetCore.UnitTests/ResultsHttpTestUnions.cs @@ -1,5 +1,3 @@ -using Purview.Results.SourceGenerator; - namespace Purview.Results.AspNetCore; // CA1815 is answered by the package's suppressor for every union opted in with [GenerateResult]. diff --git a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTestOptions.cs b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTestOptions.cs index 213f1c3..d280b40 100644 --- a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTestOptions.cs +++ b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTestOptions.cs @@ -38,7 +38,7 @@ public ResultsSourceGeneratorTestOptions() LanguageVersion = Microsoft.CodeAnalysis.CSharp.LanguageVersion.Preview; AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange(typeof(Result<,>)); - AdditionalNamespaces = AdditionalNamespaces.Add("Purview.Results").Add("Purview.Results.SourceGenerator"); + AdditionalNamespaces = AdditionalNamespaces.Add("Purview.Results"); DisableSourceGeneratorPropertyName = DisableGeneratorProperty; ExcludeGeneratedSourceHintNames = [.. PostInitializationHintNames]; diff --git a/src/tests/SourceGenerator.UnitTests/TestSources.cs b/src/tests/SourceGenerator.UnitTests/TestSources.cs index c3958fa..663d1aa 100644 --- a/src/tests/SourceGenerator.UnitTests/TestSources.cs +++ b/src/tests/SourceGenerator.UnitTests/TestSources.cs @@ -10,7 +10,7 @@ static class TestSources /// running the generator (which is what emits the attribute in a real compilation). /// public const string GenerateResultAttributeDeclaration = """ - namespace Purview.Results.SourceGenerator + namespace Purview.Results { [System.AttributeUsage( System.AttributeTargets.Class diff --git a/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs b/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs index 8cdd221..ae45da7 100644 --- a/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs +++ b/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs @@ -4,7 +4,6 @@ public sealed class UnionCaseResultCodeFixProviderTests { const string UnionSource = """ using Purview.Results; - using Purview.Results.SourceGenerator; namespace Test { @@ -172,8 +171,6 @@ CancellationToken cancellationToken namespace Domain { - using Purview.Results.SourceGenerator; - public readonly record struct TenantId(string Value); public readonly record struct Tenant(Domain.TenantId TenantId); diff --git a/src/tests/SourceGenerator.UnitTests/UnionCodeFixTestHarness.cs b/src/tests/SourceGenerator.UnitTests/UnionCodeFixTestHarness.cs index b4e8501..7d879d2 100644 --- a/src/tests/SourceGenerator.UnitTests/UnionCodeFixTestHarness.cs +++ b/src/tests/SourceGenerator.UnitTests/UnionCodeFixTestHarness.cs @@ -1,10 +1,10 @@ +using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CodeActions; using Microsoft.CodeAnalysis.CodeFixes; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Formatting; using Purview.Results.SourceGenerator.CodeFixes; -using System.Collections.Immutable; namespace Purview.Results.SourceGenerator; diff --git a/src/tests/SourceGenerator.UnitTests/UnionEqualityDiagnosticSuppressorTests.cs b/src/tests/SourceGenerator.UnitTests/UnionEqualityDiagnosticSuppressorTests.cs index 6645501..38ea6d9 100644 --- a/src/tests/SourceGenerator.UnitTests/UnionEqualityDiagnosticSuppressorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/UnionEqualityDiagnosticSuppressorTests.cs @@ -1,8 +1,8 @@ +using System.Collections.Immutable; +using System.Globalization; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; using Purview.Results.SourceGenerator.Suppressors; -using System.Collections.Immutable; -using System.Globalization; namespace Purview.Results.SourceGenerator; diff --git a/src/tests/ZodSharp.AspNetCore.UnitTests/ValidationTestUnions.cs b/src/tests/ZodSharp.AspNetCore.UnitTests/ValidationTestUnions.cs index 089e7f9..d3ac76c 100644 --- a/src/tests/ZodSharp.AspNetCore.UnitTests/ValidationTestUnions.cs +++ b/src/tests/ZodSharp.AspNetCore.UnitTests/ValidationTestUnions.cs @@ -1,4 +1,3 @@ -using Purview.Results.SourceGenerator; using System.Collections.Immutable; using ZodSharp.Core; diff --git a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodSharpResultsHttpRegistrationTests.cs b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodSharpResultsHttpRegistrationTests.cs index a571eff..3a7cc65 100644 --- a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodSharpResultsHttpRegistrationTests.cs +++ b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodSharpResultsHttpRegistrationTests.cs @@ -1,7 +1,7 @@ +using System.Collections.Immutable; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Purview.Results.AspNetCore; -using System.Collections.Immutable; using ZodSharp.Core; namespace Purview.Results.ZodSharp.AspNetCore; diff --git a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodValidationProblemsTests.cs b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodValidationProblemsTests.cs index 8774db6..bd09331 100644 --- a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodValidationProblemsTests.cs +++ b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodValidationProblemsTests.cs @@ -1,7 +1,7 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; using System.Collections.Immutable; using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; using ZodSharp; using ZodSharp.AspNetCore; using ZodSharp.Core; @@ -101,8 +101,7 @@ public async Task ToProblem_GivenTheSameErrorsAShownException_ProducesTheSameRes var context = CreateContext(); // Act - var (statusCode, - body, contentType) = await ExecuteAsync( + var (statusCode, body, contentType) = await ExecuteAsync( ZodValidationProblems.ToProblem(errors, options), context ); diff --git a/src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs b/src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs index 4a8dd99..5410339 100644 --- a/src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs +++ b/src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs @@ -1,4 +1,3 @@ -using Purview.Results.SourceGenerator; using System.Collections.Immutable; using ZodSharp.Core; From c5a3ccd43bcb8bca13c4818e828a7fbbfbd91ab8 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sat, 3 Oct 2026 13:18:38 +0100 Subject: [PATCH 03/11] refactor: added Value Objects and Zod IZodRule integration --- .csharpierignore | 1 + AGENTS.md | 21 +- Directory.Packages.props | 2 + README.md | 32 +-- docs/wiki/AspNetCore-Integration.md | 2 +- docs/wiki/Getting-Started.md | 11 +- docs/wiki/Guarantees-and-Limitations.md | 8 +- docs/wiki/Value-Objects-Composition.md | 133 +++++++++++ docs/wiki/ZodSharp-Integration.md | 6 +- docs/wiki/ZodSharp-ProblemDetails.md | 33 ++- docs/wiki/_Sidebar.md | 1 + src/Results.slnx | 1 + src/src/AspNetCore/Sdk/README.md | 4 +- src/src/Examples.AspNetCore.Zod/Program.cs | 6 +- src/src/Examples.AspNetCore/Program.cs | 2 +- src/src/Examples.Basic/Program.cs | 2 +- .../Examples.ValueObjects.Zod.csproj | 51 +++++ src/src/Examples.ValueObjects.Zod/Program.cs | 87 ++++++++ src/src/Examples.ValueObjects.Zod/Tenant.cs | 9 + .../Examples.ValueObjects.Zod/TenantError.cs | 36 +++ src/src/Examples.ValueObjects.Zod/TenantId.cs | 85 +++++++ .../TenantRegistrationService.cs | 36 +++ src/src/Examples.Zod/Program.cs | 2 +- src/src/Results/Sdk/README.md | 8 +- src/src/SourceGenerator/Sdk/README.md | 2 +- ...SharpResultsServiceCollectionExtensions.cs | 8 +- .../SKILL.md | 21 +- src/src/ZodSharp.AspNetCore/Sdk/README.md | 39 ++-- .../ZodResultsFailureMapper.cs | 16 +- .../ZodResultsHttpOptions.cs | 90 ++++++-- src/src/ZodSharp/IValidationErrorCarrier.cs | 2 +- .../SKILL.md | 4 +- src/src/ZodSharp/Sdk/README.md | 8 +- .../ValueObjectRuleTestModels.cs | 76 +++++++ .../ZodResultsFailureMapperTests.cs | 209 +++++++++++++++++- .../ZodSharp.AspNetCore.UnitTests.csproj | 11 + 36 files changed, 952 insertions(+), 113 deletions(-) create mode 100644 docs/wiki/Value-Objects-Composition.md create mode 100644 src/src/Examples.ValueObjects.Zod/Examples.ValueObjects.Zod.csproj create mode 100644 src/src/Examples.ValueObjects.Zod/Program.cs create mode 100644 src/src/Examples.ValueObjects.Zod/Tenant.cs create mode 100644 src/src/Examples.ValueObjects.Zod/TenantError.cs create mode 100644 src/src/Examples.ValueObjects.Zod/TenantId.cs create mode 100644 src/src/Examples.ValueObjects.Zod/TenantRegistrationService.cs create mode 100644 src/tests/ZodSharp.AspNetCore.UnitTests/ValueObjectRuleTestModels.cs diff --git a/.csharpierignore b/.csharpierignore index b9178ab..312db68 100644 --- a/.csharpierignore +++ b/.csharpierignore @@ -10,5 +10,6 @@ src/tests/ZodSharp.AspNetCore.UnitTests/ValidationTestUnions.cs src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs src/src/Examples.Basic/TenantError.cs src/src/Examples.Zod/TenantError.cs +src/src/Examples.ValueObjects.Zod/TenantError.cs src/src/Examples.AspNetCore/TenantError.cs src/src/Examples.AspNetCore.Zod/TenantError.cs diff --git a/AGENTS.md b/AGENTS.md index 1a00e57..069f2e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ makes C# 15 union error cases ergonomic, and the ZodSharp and ASP.NET Core integ | `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/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 | +| `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 | | `Directory.Packages.props` | Centrally managed NuGet versions | | `src/Directory.Build.props` / `src/Directory.Build.targets` | Solution-wide SDK, package and build behaviour | @@ -145,11 +145,12 @@ including the diagnostics table, build properties and activation rules. wins, and so does a host failure mapper registered before `AddResultsZodSharpHttp`. It must reuse the ZodSharp problem mapper (`ZodValidationProblems.ToProblem`) rather than reimplementing error-to-problem mapping, so a result-carried validation failure and a thrown `ZodException` produce identical responses. -- Keep the ZodSharp code/category rules' precedence structural: **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 exactly the kind of gap this repository surfaces rather than hides — and a factory - returns `null` to decline, with matching continuing. Factories see the failure's whole error set. +- Keep the ZodSharp code/category/origin rules' precedence structural: **code** rules are consulted before + **category** rules, which are consulted before **origin** 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, category or origin — a rule a schema can silently never reach is exactly the + kind of gap this repository surfaces rather than hides — and a factory returns `null` to decline, with matching + continuing. Factories see the failure's whole error set. ## Packaging rules @@ -218,10 +219,12 @@ including the diagnostics table, build properties and activation rules. ## Examples -`src/examples` holds one runnable example per integration aspect, all on the Tenant* domain the READMEs +`src/src/Examples.*` holds one runnable example per integration aspect, all on the Tenant* domain the READMEs document: `Examples.Basic` (the result type and the generated helpers), `Examples.Zod` -(`Purview.Results.ZodSharp`), `Examples.AspNetCore` (`Purview.Results.AspNetCore`) and `Examples.AspNetCore.Zod` -(`Purview.Results.ZodSharp.AspNetCore`). +(`Purview.Results.ZodSharp`), `Examples.AspNetCore` (`Purview.Results.AspNetCore`), +`Examples.AspNetCore.Zod` (`Purview.Results.ZodSharp.AspNetCore`) and `Examples.ValueObjects.Zod` +(`Purview.Results.ZodSharp` + `Purview.ValueObjects`, a type-level `[ZodRule]` whose code and origin flow into the +result and the HTTP mapping). - Examples are **documentation that compiles**: keep them non-packable (never declare `true`), keep them out of test discovery (no `*Tests` suffix, and they are not under diff --git a/Directory.Packages.props b/Directory.Packages.props index 39741f9..b263bec 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,7 @@ 2.0.1-prerelease.1 + 1.0.0-prerelease.12 1.0.0-prerelease.54 @@ -20,6 +21,7 @@ + diff --git a/README.md b/README.md index 38d5493..43e8b22 100644 --- a/README.md +++ b/README.md @@ -66,21 +66,23 @@ app.MapGet("/tenants/{id:int}", (int id) => GetTenant(id)).WithResultsHttp(); ## Examples -Every example is a runnable, non-packable project under [`src/examples`](src/examples), built on the same -Tenancy domain the Quick start uses, so the one `TenantError` union drives all four integration aspects. +Every example is a runnable, non-packable project under `src/src/Examples.*`, built on the same +Tenancy domain the Quick start uses, so the one `TenantError` union drives every integration aspect. | Example | Packages | Demonstrates | | --- | --- | --- | -| [`Examples.Basic`](src/examples/Examples.Basic) | `Purview.Results`, `Purview.Results.SourceGenerator` | States, `Match`/`Map`/`Bind`/`MapError`/`Ensure`, probing, the throw-on-misuse contract, and the generated `AsFailure()` helper | -| [`Examples.Zod`](src/examples/Examples.Zod) | + `Purview.Results.ZodSharp` | A `[ZodSchema]` input validated into a result, where the rejection carries its `ValidationError`s | -| [`Examples.AspNetCore`](src/examples/Examples.AspNetCore) | + `Purview.Results.AspNetCore` | `AddResultsHttp`/`Map`/`WithResultsHttp`, including the mapping-gap and uninitialized-result paths | -| [`Examples.AspNetCore.Zod`](src/examples/Examples.AspNetCore.Zod) | + `Purview.Results.ZodSharp.AspNetCore` | A validation-carrying failure rendered as `HttpValidationProblemDetails`, with a case mapping winning over the fallback | +| [`Examples.Basic`](src/src/Examples.Basic) | `Purview.Results`, `Purview.Results.SourceGenerator` | States, `Match`/`Map`/`Bind`/`MapError`/`Ensure`, probing, the throw-on-misuse contract, and the generated `AsFailure()` helper | +| [`Examples.Zod`](src/src/Examples.Zod) | + `Purview.Results.ZodSharp` | A `[ZodSchema]` input validated into a result, where the rejection carries its `ValidationError`s | +| [`Examples.AspNetCore`](src/src/Examples.AspNetCore) | + `Purview.Results.AspNetCore` | `AddResultsHttp`/`Map`/`WithResultsHttp`, including 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 | ```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 +dotnet run --project src/src/Examples.Basic +dotnet run --project src/src/Examples.Zod +dotnet run --project src/src/Examples.AspNetCore --urls http://localhost:5215 +dotnet run --project src/src/Examples.AspNetCore.Zod --urls http://localhost:5216 +dotnet run --project src/src/Examples.ValueObjects.Zod ``` ### Basic @@ -178,8 +180,8 @@ builder.Services.AddResultsHttp(options => options ### ASP.NET Core + Zod Register the validation mapping last, so any mapping or failure mapper the host declared earlier always wins, and -reuse the ZodSharp problem mapper rather than reimplementing error-to-problem mapping. Per-code and per-category -rules answer particular validation failures with a response of their own: +reuse the ZodSharp problem mapper rather than reimplementing error-to-problem mapping. Per-code, per-category and +per-origin rules answer particular validation failures with a response of their own: ```csharp builder.Services.AddZodSharpProblemDetails(); @@ -188,11 +190,12 @@ builder.Services.AddResultsHttp(options => options ); builder.Services.AddResultsZodSharpHttp(options => options .MapCode("tenant_id_matches_name", StatusCodes.Status422UnprocessableEntity) + .MapOrigin("value_object", StatusCodes.Status422UnprocessableEntity) ); ``` A `TenantInputInvalid` failure implements `IValidationErrorCarrier`, so it becomes a validation problem without -the host mapping it — unless a rule answers one of its codes or categories: +the host mapping it — unless a rule answers one of its codes, categories or origins: ```json { @@ -214,6 +217,7 @@ The full documentation suite lives in [`docs/wiki`](docs/wiki/Getting-Started.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) +- [Value objects composition](docs/wiki/Value-Objects-Composition.md) - [Guarantees and limitations](docs/wiki/Guarantees-and-Limitations.md) ## Requirements @@ -235,7 +239,7 @@ The full documentation suite lives in [`docs/wiki`](docs/wiki/Getting-Started.md | `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/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 | +| `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 | | `Directory.Packages.props` | Centrally managed NuGet versions | | `src/Directory.Build.props` / `src/Directory.Build.targets` | Solution-wide SDK, package and build behaviour | diff --git a/docs/wiki/AspNetCore-Integration.md b/docs/wiki/AspNetCore-Integration.md index 32509b0..f5acfe3 100644 --- a/docs/wiki/AspNetCore-Integration.md +++ b/docs/wiki/AspNetCore-Integration.md @@ -138,7 +138,7 @@ Running `Examples.AspNetCore`: | `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 +dotnet run --project src/src/Examples.AspNetCore --urls http://localhost:5215 ``` ## Related diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index 2295fad..ed68d48 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -127,13 +127,14 @@ See [ZodSharp Integration](ZodSharp-Integration.md) and, for ProblemDetails rend ## Runnable examples -Every example is a non-packable project under `src/examples`, built on the same Tenancy domain: +Every example is a non-packable project under `src/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 +dotnet run --project src/src/Examples.Basic +dotnet run --project src/src/Examples.Zod +dotnet run --project src/src/Examples.AspNetCore --urls http://localhost:5215 +dotnet run --project src/src/Examples.AspNetCore.Zod --urls http://localhost:5216 +dotnet run --project src/src/Examples.ValueObjects.Zod ``` ## Next steps diff --git a/docs/wiki/Guarantees-and-Limitations.md b/docs/wiki/Guarantees-and-Limitations.md index dec615b..9ff4917 100644 --- a/docs/wiki/Guarantees-and-Limitations.md +++ b/docs/wiki/Guarantees-and-Limitations.md @@ -48,10 +48,10 @@ change must not break. ## 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. +- Code rules are consulted before category rules, which are consulted before origin 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, category or origin: 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 diff --git a/docs/wiki/Value-Objects-Composition.md b/docs/wiki/Value-Objects-Composition.md new file mode 100644 index 0000000..826015a --- /dev/null +++ b/docs/wiki/Value-Objects-Composition.md @@ -0,0 +1,133 @@ +# Composing with Value Objects and ZodSharp + +`Purview.Results` composes with [Purview.ValueObjects](https://www.nuget.org/packages/Purview.ValueObjects) and +[Purview.ZodSharp](https://www.nuget.org/packages/Purview.ZodSharp) without depending on either. This page shows +the end-to-end shape: a value object's invariants are validated by ZodSharp, the outcome becomes an ordinary +result failure, and the failure is rendered by the ZodSharp.AspNetCore mapping. + +## Why the composition works + +- **ZodSharp owns the rules and the validation result.** `Validate` never throws; it returns a + `ValidationResult` carrying the value or every `ValidationError`. +- **ValueObjects owns the invariants.** A `[Scalar]` or `[ValueObject]` type is the only place its own + construction is guarded. +- **Purview.Results owns the outcome.** `Purview.Results.ZodSharp.ToResult` turns the validation result into a + `Result` whose error is one of the union's cases, so a rejected input flows through the same + pipeline as every other expected outcome. + +`Purview.Results` stays dependency-free: the integration packages depend on it, never the reverse, so a +consumer opts in by referencing the packages it needs. + +## Type-level rules and the error identity + +ZodSharp rules own their error identity. A rule that validates a value object *as a unit* can implement +`ZodSharp.Core.IZodRule` to report its own `Code` and `Origin` — which is what lets the same attribute produce a +different code per annotated scalar, and what makes an **origin** a rule the HTTP layer can key on: + +```csharp +using ZodSharp.Core; +using Purview.ValueObjects; // IScalarValueObject + +public readonly record struct NonEmptyRule(string? Code = null, string? Message = null) + : IValidationRule, IZodRule + where TSelf : IScalarValueObject +{ + public const string ErrorCode = "invalid_value"; + public const string MessageFormat = "Value must not be empty."; + + public bool IsValid(in TSelf value) => value.Value != Guid.Empty; + + public string GetErrorMessage(in TSelf value) => Message ?? MessageFormat; + + string IValidationRule.Code => Code ?? ErrorCode; + + string? IZodRule.Code => Code; + string? IZodRule.Origin => "value_object"; +} + +[ZodRule(typeof(NonEmptyRule<>))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public sealed class NonEmptyAttribute : ValidationAttribute +{ + public string? Code { get; set; } + public string? Message { get; set; } +} +``` + +Applying the attribute to a scalar makes its generated `Create` reject the sentinel value, reporting the rule's own +code and origin (`Code = "invalid_asset_id"`, `Origin = "value_object"`, empty path), while `Hydrate` stays +replay-safe: + +```csharp +[Scalar] +[ZodSchema] +[NonEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")] +public readonly partial record struct AssetId +{ + public Guid Value { get; } +} +``` + +## The rejection as a result failure + +The case that carries the errors implements `IValidationErrorCarrier`, so the HTTP layer can render it without +knowing the error type: + +```csharp +[GenerateResult] +readonly union TenantError(TenantInputInvalid, TenantAlreadyExists); + +readonly record struct TenantInputInvalid(TenantInput Input, ImmutableArray Errors) + : IValidationErrorCarrier +{ + ImmutableArray IValidationErrorCarrier.ValidationErrors => Errors; +} +``` + +`ToResult` maps the validation outcome into that union, naming the error type explicitly because a union case does +not carry the union that contains it: + +```csharp +return TenantInputSchema + .Validate(input) + .ToResult(errors => new TenantInputInvalid(input, errors)) + .Bind(RegisterValidated); +``` + +## Rendering it, keyed by the rule's identity + +`Purview.Results.ZodSharp.AspNetCore` renders the carrier as a validation problem, and the rules registered on +`ZodResultsHttpOptions` can answer by code, category **or origin**: + +```csharp +builder.Services.AddZodSharpProblemDetails(); +builder.Services.AddResultsHttp(); +builder.Services.AddResultsZodSharpHttp(options => options + // One code a schema can report. + .MapCode("invalid_asset_id", StatusCodes.Status422UnprocessableEntity) + // Every type-level rule failure, whatever its code, because the rule owns Origin = "value_object". + .MapOrigin("value_object", StatusCodes.Status422UnprocessableEntity) +); +``` + +Because the origin is the axis a rule owns, `MapOrigin("value_object", …)` answers every type-level rule failure — +including future rules — while the `MapCode` rule still narrows the one code that deserves its own status. A code +is narrower than a category, which is narrower than an origin, so precedence is structural rather than +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 +`Code = "invalid_tenant_id"` and `Origin = "value_object"`, validated into a `Result` +failure and answerable by `MapOrigin("value_object", …)`. + +```bash +dotnet run --project src/src/Examples.ValueObjects.Zod +``` + +## Related + +- [ZodSharp Integration](ZodSharp-Integration.md) — producing results that carry validation errors. +- [ZodSharp Problem Details](ZodSharp-ProblemDetails.md) — the per-code, per-category and per-origin rules. +- [Union Errors](Union-Errors.md) — the error union the rejected input becomes a case of. \ No newline at end of file diff --git a/docs/wiki/ZodSharp-Integration.md b/docs/wiki/ZodSharp-Integration.md index 2db2362..71dab41 100644 --- a/docs/wiki/ZodSharp-Integration.md +++ b/docs/wiki/ZodSharp-Integration.md @@ -62,7 +62,9 @@ public readonly record struct TenantInputInvalid(TenantInput Input, ImmutableArr ``` `IValidationErrorCarrier` exposes a single `ValidationErrors` member, preserving each `ValidationError`'s code, -category, path and parameters. +origin, category, path and parameters. The **origin** is the structured origin a rule owns (`"value_object"` for a +type-level rule, `"array"` for a collection rule), which is how an HTTP layer can answer a whole family of rules +without naming each code. [ZodSharp Problem Details](ZodSharp-ProblemDetails.md) consumes the interface; without it, a host would have to map every validation-carrying error individually. @@ -81,7 +83,7 @@ Naming the error type explicitly keeps the intent unambiguous. ## Example ```bash -dotnet run --project src/examples/Examples.Zod +dotnet run --project src/src/Examples.Zod ``` `Examples.Zod` validates a `[ZodSchema] TenantInput` and turns the outcome into a `Result`, diff --git a/docs/wiki/ZodSharp-ProblemDetails.md b/docs/wiki/ZodSharp-ProblemDetails.md index defd72f..7804525 100644 --- a/docs/wiki/ZodSharp-ProblemDetails.md +++ b/docs/wiki/ZodSharp-ProblemDetails.md @@ -22,8 +22,8 @@ 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. +// Every validation-carrying failure becomes one validation problem, except the codes, categories and origins +// that a rule answers with something else. builder.Services.AddResultsZodSharpHttp(options => options .MapCode("tenant_not_found", StatusCodes.Status404NotFound) .MapCategory("invalid_value", StatusCodes.Status422UnprocessableEntity) @@ -42,7 +42,7 @@ app.MapPost("/reconcile", (ReconciliationRequest request) => Reconcile(request)) Register it **last**, therefore, so everything the host declared earlier takes precedence. -## Answering by validation error code or category +## Answering by validation error code, category or origin | Member | Purpose | | --- | --- | @@ -50,12 +50,19 @@ Register it **last**, therefore, so everything the host declared earlier takes p | `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 | +| `MapOrigin(string origin, int statusCode)` | The same, for an origin a rule owns (`"value_object"`, `"array"`, …) | +| `MapOrigin(string origin, 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 +The three axes are the identity a `ValidationError` carries: **code** is the specific rule that failed, +**category** is a broad grouping many codes share, and **origin** is the structured origin a rule owns. Use +`MapOrigin` when the answer is keyed by the *kind* of rule rather than one code — for example rendering every +type-level rule failure, which reports `"value_object"`, as `422` whatever its code. + +A rule applies when **any** of the failure's errors carries its code, category or origin, so a registered 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: +rules, then the **origin** rules, each in registration order, and finally the default validation problem — a code is +narrower than a category, which is narrower than an origin, so a code wins however the rules were registered. A +factory that wants stricter semantics returns `null` to decline the failure, and matching continues: ```csharp options @@ -65,12 +72,14 @@ options ? TypedResults.NotFound() : null) // A category rule that answers the failures the code rule declined. - .MapCategory("invalid_value", StatusCodes.Status422UnprocessableEntity); + .MapCategory("invalid_value", StatusCodes.Status422UnprocessableEntity) + // An origin rule: every type-level rule failure, whatever its code. + .MapOrigin("value_object", 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. +fix, and the `int statusCode` overloads still render every error as an `HttpValidationProblemDetails`. A code, +category or origin 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. @@ -80,7 +89,7 @@ apply — this mapper never answers a failure it does not understand. | 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 | +| `ZodResultsHttpOptions.MapCode` / `.MapCategory` / `.MapOrigin` | The per-code, per-category and per-origin 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 | @@ -97,7 +106,7 @@ detail and the `issues` extension. ## Example ```bash -dotnet run --project src/examples/Examples.AspNetCore.Zod --urls http://localhost:5216 +dotnet run --project src/src/Examples.AspNetCore.Zod --urls http://localhost:5216 ``` `Examples.AspNetCore.Zod` shows a `TenantInputInvalid` failure carrying ZodSharp errors becoming a `400` diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 44dc81b..1885bb2 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -8,6 +8,7 @@ - [ASP.NET Core Integration](AspNetCore-Integration.md) - [ZodSharp Integration](ZodSharp-Integration.md) - [ZodSharp Problem Details](ZodSharp-ProblemDetails.md) +- [Value Objects Composition](Value-Objects-Composition.md) - [Guarantees and Limitations](Guarantees-and-Limitations.md) - [Testing](Testing.md) - [Agent Skills](Agent-Skills.md) diff --git a/src/Results.slnx b/src/Results.slnx index a27dd9e..78ac03a 100644 --- a/src/Results.slnx +++ b/src/Results.slnx @@ -23,6 +23,7 @@ + diff --git a/src/src/AspNetCore/Sdk/README.md b/src/src/AspNetCore/Sdk/README.md index c5ae0f7..025e5ee 100644 --- a/src/src/AspNetCore/Sdk/README.md +++ b/src/src/AspNetCore/Sdk/README.md @@ -126,13 +126,13 @@ the defaults entirely. A host that replaces it also bypasses `ResultsHttpOptions ## Examples -[`src/examples/Examples.AspNetCore`](https://github.com/purview-dev/results/tree/main/src/examples/Examples.AspNetCore) +[`src/src/Examples.AspNetCore`](https://github.com/purview-dev/results/tree/main/src/src/Examples.AspNetCore) is a runnable minimal-API example that maps the `TenantNotFound` case to `404`, the `TenantDisabled` case to `403` and the `TenantError` error type to `409`, and shows the response an endpoint that returns `default` receives. ```bash -dotnet run --project src/examples/Examples.AspNetCore --urls http://localhost:5215 +dotnet run --project src/src/Examples.AspNetCore --urls http://localhost:5215 ``` The [repository README](https://github.com/purview-dev/results#examples) lists the Basic, ZodSharp and diff --git a/src/src/Examples.AspNetCore.Zod/Program.cs b/src/src/Examples.AspNetCore.Zod/Program.cs index 4edc427..9fcd2be 100644 --- a/src/src/Examples.AspNetCore.Zod/Program.cs +++ b/src/src/Examples.AspNetCore.Zod/Program.cs @@ -3,7 +3,7 @@ // exception handler uses, so a validation failure carried by a result and the same failure thrown as a // ZodException produce identical responses. // -// dotnet run --project src/examples/Examples.AspNetCore.Zod --urls http://localhost:5216 +// dotnet run --project src/src/Examples.AspNetCore.Zod --urls http://localhost:5216 // // curl -i -X POST http://localhost:5216/tenants -H "Content-Type: application/json" -d "{\"tenantId\":\"newco\",\"name\":\"Newco\"}" -> 200 OK // curl -i -X POST http://localhost:5216/tenants -H "Content-Type: application/json" -d "{\"name\":\"Nameless\"}" -> 400 validation problem @@ -15,6 +15,10 @@ // // The 409 shows the precedence: the mapping registered for a specific case always wins over the validation // mapping, and a mapping registered for the error type would still win over it too. +// +// This example reports DataAnnotations-shaped errors, which carry no origin. The third axis — a rule that owns an +// origin, such as `MapOrigin("value_object", ...)` for a type-level rule on a value object — is demonstrated in +// docs/wiki/Value-Objects-Composition.md. var builder = WebApplication.CreateBuilder(args); diff --git a/src/src/Examples.AspNetCore/Program.cs b/src/src/Examples.AspNetCore/Program.cs index c90df12..60a4388 100644 --- a/src/src/Examples.AspNetCore/Program.cs +++ b/src/src/Examples.AspNetCore/Program.cs @@ -1,7 +1,7 @@ // Demonstrates Purview.Results.AspNetCore: an endpoint returns Result and the host decides // what each error case looks like on the wire. // -// dotnet run --project src/examples/Examples.AspNetCore --urls http://localhost:5215 +// dotnet run --project src/src/Examples.AspNetCore --urls http://localhost:5215 // // curl -i http://localhost:5215/tenants/acme -> 200 OK // curl -i http://localhost:5215/tenants/initech -> 404 Not Found (case mapping) diff --git a/src/src/Examples.Basic/Program.cs b/src/src/Examples.Basic/Program.cs index 3e5cfbe..9245a9d 100644 --- a/src/src/Examples.Basic/Program.cs +++ b/src/src/Examples.Basic/Program.cs @@ -1,6 +1,6 @@ // A guided tour of Purview.Results over the Tenancy domain the package README documents. // -// dotnet run --project src/examples/Examples.Basic +// dotnet run --project src/src/Examples.Basic // // Every expected outcome below is a value. Nothing throws except the deliberate misuse at the end, which // demonstrates the throw-on-misuse contract that keeps an uninitialized result a bug rather than a state. diff --git a/src/src/Examples.ValueObjects.Zod/Examples.ValueObjects.Zod.csproj b/src/src/Examples.ValueObjects.Zod/Examples.ValueObjects.Zod.csproj new file mode 100644 index 0000000..8864eb7 --- /dev/null +++ b/src/src/Examples.ValueObjects.Zod/Examples.ValueObjects.Zod.csproj @@ -0,0 +1,51 @@ + + + + Exe + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/src/Examples.ValueObjects.Zod/Program.cs b/src/src/Examples.ValueObjects.Zod/Program.cs new file mode 100644 index 0000000..8fce660 --- /dev/null +++ b/src/src/Examples.ValueObjects.Zod/Program.cs @@ -0,0 +1,87 @@ +// Demonstrates composing Purview.ValueObjects with Purview.Results.ZodSharp: a scalar value object's type-level +// ZodSharp rule reports its own code and origin, and the outcome flows through the result pipeline as a value +// instead of an exception. +// +// dotnet run --project src/src/Examples.ValueObjects.Zod +// +// TenantId is a [Scalar] + [ZodSchema] value object carrying a type-level rule ([NonEmptyTenantId]) whose rule +// implements IZodRule, so it owns the reported code (invalid_tenant_id) and origin (value_object). That identity +// is what lets a host answer the failure by code or by origin without parsing a message. + +TenantRegistrationService service = new(); +var acmeId = Guid.NewGuid(); + +Heading("1. A valid identifier registers, carrying the value the method produces"); + +ShowResult("Register(acmeId, 'Acme')", service.Register(acmeId, "Acme")); + +Heading("2. An empty identifier is the value object's sentinel, reported by its type-level rule"); + +var rejected = service.Register(Guid.Empty, "Nameless"); + +ShowResult("Register(Guid.Empty, 'Nameless')", rejected); +PrintErrors(rejected); + +Heading("3. The rule owns its identity, so a host can answer it by code or by origin"); + +PrintIdentity(rejected); +Show("code rule", "MapCode(\"invalid_tenant_id\", 422)"); +Show("origin rule", "MapOrigin(\"value_object\", 422)"); + +Heading("4. A duplicate fails through exactly the same pipeline"); + +ShowResult("Register(acmeId, 'Acme Copy')", service.Register(acmeId, "Acme Copy")); + +static void Heading(string title) +{ + Console.WriteLine(); + Console.WriteLine(title); + Console.WriteLine(new string('-', title.Length)); +} + +static void Show(string label, object? value) => Console.WriteLine($" {label, -30} -> {value}"); + +static void ShowResult(string label, Result result) +{ + var text = result.Match(value => $"Success({value})", DescribeError); + + Console.WriteLine($" {label, -30} -> {text}"); +} + +static string DescribeError(TenantError error) => + error switch + { + TenantInputInvalid invalid => $"TenantInputInvalid({invalid.Errors.Length} error(s))", + TenantAlreadyExists exists => $"TenantAlreadyExists({exists.TenantId.Value})", + _ => nameof(TenantError), + }; + +static void PrintErrors(Result result) +{ + if (!result.TryGetError(out var error) || error is not TenantInputInvalid invalid) + return; + + foreach (var validationError in invalid.Errors) + { + var path = string.Join(".", validationError.Path); + + Console.WriteLine($" '{path}': {validationError.Message} [{validationError.Code}]"); + } +} + +static void PrintIdentity(Result result) +{ + if (!result.TryGetError(out var error) || error is not TenantInputInvalid invalid) + return; + + foreach (var validationError in invalid.Errors) + { + // A type-level rule validates the value object as a unit, so the path is empty and the origin is the + // one the rule owns. + var path = string.Join(".", validationError.Path); + + Console.WriteLine( + $" code={validationError.Code}, origin={validationError.Origin ?? ""}, path=[{path}]" + ); + } +} diff --git a/src/src/Examples.ValueObjects.Zod/Tenant.cs b/src/src/Examples.ValueObjects.Zod/Tenant.cs new file mode 100644 index 0000000..efa7fc3 --- /dev/null +++ b/src/src/Examples.ValueObjects.Zod/Tenant.cs @@ -0,0 +1,9 @@ +namespace Purview.Results.Examples.ValueObjects.Zod; + +/// +/// A tenant. +/// +/// The identifier of the tenant. +/// The display name of the tenant. +/// Whether the tenant may be used. +readonly record struct Tenant(TenantId Id, string Name, bool Enabled); diff --git a/src/src/Examples.ValueObjects.Zod/TenantError.cs b/src/src/Examples.ValueObjects.Zod/TenantError.cs new file mode 100644 index 0000000..cfd20ad --- /dev/null +++ b/src/src/Examples.ValueObjects.Zod/TenantError.cs @@ -0,0 +1,36 @@ +using System.Collections.Immutable; +using Purview.Results.ZodSharp; +using ZodSharp.Core; + +namespace Purview.Results.Examples.ValueObjects.Zod; + +/// +/// The expected failures of the tenant registration operation. +/// +/// +/// The failure that carries ZodSharp validation errors is a union case like any other, so a rejected value object +/// flows through the same result pipeline as a duplicate tenant. +/// +[GenerateResult] +readonly union TenantError(TenantInputInvalid, TenantAlreadyExists); + +/// +/// The supplied tenant id did not satisfy the value object's type-level rule. +/// +/// The rejected identifier. +/// The validation errors the tenant id's schema reported. +/// +/// Implementing lets an HTTP layer turn the failure into a validation +/// problem without knowing the error type — including the rule's code and origin. +/// +readonly record struct TenantInputInvalid(Guid TenantId, ImmutableArray Errors) + : IValidationErrorCarrier +{ + ImmutableArray IValidationErrorCarrier.ValidationErrors => Errors; +} + +/// +/// A tenant with the requested identifier already exists. +/// +/// The identifier of the tenant. +readonly record struct TenantAlreadyExists(TenantId TenantId); \ No newline at end of file diff --git a/src/src/Examples.ValueObjects.Zod/TenantId.cs b/src/src/Examples.ValueObjects.Zod/TenantId.cs new file mode 100644 index 0000000..f1adeb9 --- /dev/null +++ b/src/src/Examples.ValueObjects.Zod/TenantId.cs @@ -0,0 +1,85 @@ +using System.ComponentModel.DataAnnotations; +using Purview.ValueObjects; +using Purview.ValueObjects.Serialization; +using ZodSharp; +using ZodSharp.Core; + +namespace Purview.Results.Examples.ValueObjects.Zod; + +/// +/// A custom rule that validates a scalar value object as a unit, reading it through its +/// contract. +/// +/// The scalar value object the rule validates. +/// An optional code that overrides for one annotated scalar. +/// An optional message that overrides . +/// +/// Implementing lets the rule own the reported error code and origin, which is what makes +/// one attribute able to report a domain code (invalid_tenant_id) and mark the failure as coming from the +/// value object (value_object) at the same time. +/// +readonly record struct NonEmptyTenantIdRule(string? Code = null, string? Message = null) + : IValidationRule, + IZodRule + where TSelf : IScalarValueObject +{ + /// Gets the error code reported when the rule fails. + public const string ErrorCode = "invalid_value"; + + /// Gets the message format used when the rule reports its own message. + public const string MessageFormat = "Value must not be empty."; + + /// Validates that the scalar does not hold the sentinel . + /// The value to validate. + /// when the value is not the empty identifier. + public bool IsValid(in TSelf value) => value.Value != Guid.Empty; + + /// Gets the error message for a failed validation. + /// The value that failed validation. + /// The configured message, or the rule's default. + public string GetErrorMessage(in TSelf value) => Message ?? MessageFormat; + + // IValidationRule.Code is the rule's own default; IZodRule.Code is the optional per-use override, so the + // generator prefers a code supplied where the attribute is applied and falls back to ErrorCode. + string IValidationRule.Code => Code ?? ErrorCode; + + /// + string? IZodRule.Code => Code; + + /// + string? IZodRule.Origin => "value_object"; +} + +/// +/// Maps to a type-level attribute. The ZodSharp generator closes the +/// open generic with the annotated scalar, so the rule validates the value object as a unit and reports an empty +/// path. +/// +[ZodRule(typeof(NonEmptyTenantIdRule<>))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +sealed class NonEmptyTenantIdAttribute : ValidationAttribute +{ + /// Gets or sets the error code reported for this application of the rule. + public string? Code { get; set; } + + /// Gets or sets the error message reported for this application of the rule. + public string? Message { get; set; } +} + +/// +/// 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] +[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; } +} diff --git a/src/src/Examples.ValueObjects.Zod/TenantRegistrationService.cs b/src/src/Examples.ValueObjects.Zod/TenantRegistrationService.cs new file mode 100644 index 0000000..44536bc --- /dev/null +++ b/src/src/Examples.ValueObjects.Zod/TenantRegistrationService.cs @@ -0,0 +1,36 @@ +namespace Purview.Results.Examples.ValueObjects.Zod; + +/// +/// Registers tenants, validating every identifier through its value object's schema. +/// +sealed class TenantRegistrationService +{ + readonly Dictionary _tenants = []; + + /// + /// Registers a tenant from a raw identifier. + /// + /// The raw identifier to validate and register. + /// The display name of the new tenant. + /// The registered tenant, or a describing why it was rejected. + public Result Register(Guid tenantId, string name) + { + // Hydrate builds the value object without running its rule; Validate reports the rule instead of + // throwing, so a rejected identifier is an expected outcome rather than an exception. + var candidate = TenantId.Hydrate(tenantId); + var validation = TenantIdSchema.Validate(candidate); + + if (!validation.IsSuccess) + return new TenantInputInvalid(tenantId, validation.Errors).AsFailure(); + + var id = validation.Value; + + if (_tenants.ContainsKey(id)) + return new TenantAlreadyExists(id).AsFailure(); + + Tenant tenant = new(id, name, Enabled: true); + _tenants[id] = tenant; + + return Result.Success(tenant); + } +} diff --git a/src/src/Examples.Zod/Program.cs b/src/src/Examples.Zod/Program.cs index 0445943..87ca8bd 100644 --- a/src/src/Examples.Zod/Program.cs +++ b/src/src/Examples.Zod/Program.cs @@ -1,7 +1,7 @@ // Demonstrates Purview.Results.ZodSharp: a ZodSharp validation outcome flows through the same result pipeline // as every other expected outcome, as a value instead of an exception. // -// dotnet run --project src/examples/Examples.Zod +// dotnet run --project src/src/Examples.Zod TenantRegistrationService service = new(); diff --git a/src/src/Results/Sdk/README.md b/src/src/Results/Sdk/README.md index f511514..e369566 100644 --- a/src/src/Results/Sdk/README.md +++ b/src/src/Results/Sdk/README.md @@ -92,16 +92,16 @@ Result GetTenant(TenantId tenantId) => (TenantError)new Ten ## Examples -[`src/examples/Examples.Basic`](https://github.com/purview-dev/results/tree/main/src/examples/Examples.Basic) is a +[`src/src/Examples.Basic`](https://github.com/purview-dev/results/tree/main/src/src/Examples.Basic) is a runnable console example over the Tenant* domain this README documents: the three result states, the combinators, probing, the throw-on-misuse contract and the generated `AsFailure()` helper. ```bash -dotnet run --project src/examples/Examples.Basic +dotnet run --project src/src/Examples.Basic ``` -The [repository README](https://github.com/purview-dev/results#examples) lists the ZodSharp, ASP.NET Core and -ASP.NET Core + Zod examples too. +The [repository README](https://github.com/purview-dev/results#examples) lists the ZodSharp, ASP.NET Core, +ASP.NET Core + Zod and value-objects composition examples too. ## Related packages diff --git a/src/src/SourceGenerator/Sdk/README.md b/src/src/SourceGenerator/Sdk/README.md index c21842e..7a6a339 100644 --- a/src/src/SourceGenerator/Sdk/README.md +++ b/src/src/SourceGenerator/Sdk/README.md @@ -254,7 +254,7 @@ rewritten source to prove it compiles. ## Examples -[`src/examples/Examples.Basic`](https://github.com/purview-dev/results/tree/main/src/examples/Examples.Basic) +[`src/src/Examples.Basic`](https://github.com/purview-dev/results/tree/main/src/src/Examples.Basic) declares a `[GenerateResult]` union over the Tenant* domain the README uses and calls the helper this generator emits for each of its cases: diff --git a/src/src/ZodSharp.AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpResultsServiceCollectionExtensions.cs b/src/src/ZodSharp.AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpResultsServiceCollectionExtensions.cs index 43eae1b..b357281 100644 --- a/src/src/ZodSharp.AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpResultsServiceCollectionExtensions.cs +++ b/src/src/ZodSharp.AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpResultsServiceCollectionExtensions.cs @@ -15,12 +15,12 @@ public static class ZodSharpResultsServiceCollectionExtensions { /// /// Makes a result failure that carries ZodSharp validation errors produce an - /// HttpValidationProblemDetails response, with optional rules per validation error code and - /// category. + /// HttpValidationProblemDetails response, with optional rules per validation error code, + /// category and origin. /// /// - /// Configures which responses particular validation error codes and categories produce. Without it, every - /// failure that carries validation errors is rendered as one validation problem. + /// Configures which responses particular validation error codes, categories and origins produce. Without + /// it, every failure that carries validation errors is rendered as one validation problem. /// /// /// diff --git a/src/src/ZodSharp.AspNetCore/Sdk/.agents/skills/purview-results-zodsharp-problems/SKILL.md b/src/src/ZodSharp.AspNetCore/Sdk/.agents/skills/purview-results-zodsharp-problems/SKILL.md index 020cbf2..ed0dd93 100644 --- a/src/src/ZodSharp.AspNetCore/Sdk/.agents/skills/purview-results-zodsharp-problems/SKILL.md +++ b/src/src/ZodSharp.AspNetCore/Sdk/.agents/skills/purview-results-zodsharp-problems/SKILL.md @@ -21,33 +21,40 @@ builder.Services.AddResultsZodSharpHttp(); // the bridge: validation-carryi - Because it is a failure mapper, **a mapping the host registered for a specific error case always wins**, a mapping registered for the error type wins too, and a host failure mapper registered *before* `AddResultsZodSharpHttp` wins as well. That is the escape hatch: when a case deserves its own response, register - it — and when a validation **code** deserves one, use the rules below. + it — and when a validation **code**, **category** or **origin** deserves one, use the rules below. - Pair it with `AddZodSharpProblemDetails()` so a thrown `ZodException` and a result-carried rejection are rendered by the same mapper and therefore produce identical responses. -## Rules per validation error code and category +## Rules per validation error code, category and origin -Pass a callback to answer particular codes or categories with a response of their own: +Pass a callback to answer particular codes, categories or origins with a response of their own: ```csharp builder.Services.AddResultsZodSharpHttp(options => options .MapCode("tenant_not_found", StatusCodes.Status404NotFound) // validation problem, 404 default .MapCode("tenant_id_matches_name", (errors, context) => TypedResults.Conflict()) .MapCategory("invalid_value", StatusCodes.Status422UnprocessableEntity) + .MapOrigin("value_object", StatusCodes.Status422UnprocessableEntity) // every type-level rule failure ); ``` +The three axes are the identity a `ValidationError` carries: **code** (the specific rule), **category** (a broad +grouping many codes share) and **origin** (the structured origin a rule owns — `"value_object"` for a type-level +rule, `"array"` for a collection rule). Reach for `MapOrigin` when the answer is keyed by the *kind* of rule +rather than one code. + Matching and precedence: 1. every matching **code** rule in registration order, 2. then every matching **category** rule in registration order, -3. then the default validation problem — so rules only ever narrow what the host already gets. +3. then every matching **origin** rule in registration order, +4. then the default validation problem — so rules only ever narrow what the host already gets. -- A rule matches when **any** of the failure's errors carries its code/category, so a registered rule is always - reachable; a factory that wants stricter semantics returns `null` to decline and matching continues. +- A rule matches when **any** of the failure's errors carries its code/category/origin, so a registered rule is + always reachable; a factory that wants stricter semantics returns `null` to decline and matching continues. - A factory receives the failure's **whole error set**, so a rule never hides the other problems the caller has to fix; the `int statusCode` overloads still render every error. -- A code or category registered twice with different behaviour throws at configuration time. +- A code, category or origin registered twice with different behaviour throws at configuration time. ## Rendering by hand diff --git a/src/src/ZodSharp.AspNetCore/Sdk/README.md b/src/src/ZodSharp.AspNetCore/Sdk/README.md index e93b23e..0b1f06f 100644 --- a/src/src/ZodSharp.AspNetCore/Sdk/README.md +++ b/src/src/ZodSharp.AspNetCore/Sdk/README.md @@ -25,8 +25,8 @@ 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. +// Every validation-carrying failure becomes one validation problem, except the codes, categories and origins +// that a rule answers with something else. builder.Services.AddResultsZodSharpHttp(options => options .MapCode("tenant_not_found", StatusCodes.Status404NotFound) .MapCategory("invalid_value", StatusCodes.Status422UnprocessableEntity) @@ -41,7 +41,7 @@ app.MapPost("/reconcile", (ReconciliationRequest request) => Reconcile(request)) specific error case always wins, a mapping registered for the error type wins, and a host failure mapper registered before it wins too. -## Answering by validation error code or category +## Answering by validation error code, category or origin | Member | Purpose | | --- | --- | @@ -49,12 +49,19 @@ registered before it wins too. | `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 | +| `MapOrigin(string origin, int statusCode)` | The same, for an origin a rule owns (`"value_object"`, `"array"`, …) | +| `MapOrigin(string origin, 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 +The three axes are the identity a `ValidationError` carries: **code** is the specific rule that failed, +**category** is a broad grouping many codes share, and **origin** is the structured origin a rule owns +(`"value_object"` for a type-level rule, `"array"` for a collection rule). Use `MapOrigin` when the answer is +keyed by the kind of rule rather than one code — for example to render every `"value_object"` failure as `422`. + +A rule applies when **any** of the failure's errors carries its code, category or origin, so a registered 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: +rules, then the **origin** rules, each in registration order, and finally the default validation problem — a code is +narrower than a category, which is narrower than an origin, so a code wins however the rules were registered. A +factory that wants stricter semantics returns `null` to decline the failure, and matching continues: ```csharp options @@ -64,19 +71,21 @@ options ? TypedResults.NotFound() : null) // A category rule that answers the failures the code rule declined. - .MapCategory("invalid_value", StatusCodes.Status422UnprocessableEntity); + .MapCategory("invalid_value", StatusCodes.Status422UnprocessableEntity) + // An origin rule: every type-level rule failure, whatever its code. + .MapOrigin("value_object", 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. +fix, and the `int statusCode` overloads still render every error as an `HttpValidationProblemDetails`. A code, +category or origin registered twice with different behaviour is rejected at configuration time. ## 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 | +| `ZodResultsHttpOptions.MapCode` / `.MapCategory` / `.MapOrigin` | The per-code, per-category and per-origin 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 | @@ -88,16 +97,18 @@ trace identifier is included when `ResultsHttpOptions.IncludeTraceId` is `true` ## Examples -[`src/examples/Examples.AspNetCore.Zod`](https://github.com/purview-dev/results/tree/main/src/examples/Examples.AspNetCore.Zod) +[`src/src/Examples.AspNetCore.Zod`](https://github.com/purview-dev/results/tree/main/src/src/Examples.AspNetCore.Zod) is a runnable minimal-API example where a `TenantInputInvalid` failure carrying ZodSharp errors becomes a `400` validation problem, while the host's own `TenantAlreadyExists` mapping still returns `409`. ```bash -dotnet run --project src/examples/Examples.AspNetCore.Zod --urls http://localhost:5216 +dotnet run --project src/src/Examples.AspNetCore.Zod --urls http://localhost:5216 ``` The [repository README](https://github.com/purview-dev/results#examples) lists the Basic, ZodSharp and -ASP.NET Core examples too. +ASP.NET Core examples too. The +[value-objects composition example](https://github.com/purview-dev/results/tree/main/src/src/Examples.ValueObjects.Zod) +shows a type-level `[ZodRule]` whose `Origin = "value_object"` is answered by `MapOrigin`. ## Related packages diff --git a/src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs b/src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs index eade1d6..4c46c52 100644 --- a/src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs +++ b/src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs @@ -9,17 +9,17 @@ namespace Purview.Results.ZodSharp.AspNetCore; /// -/// Renders a result failure that carries ZodSharp validation errors, honouring the code and category rules -/// registered on . +/// Renders a result failure that carries ZodSharp validation errors, honouring the code, category and origin +/// rules registered on . /// /// /// -/// The failure's errors are matched against the registered rules — codes first, then categories, each in -/// registration order — and the first rule that answers wins. A rule with a status code renders the standard -/// validation problem with that default status; a rule with a factory renders whatever the factory returns, and a -/// factory returning declines the failure so matching continues. When no rule answers, the -/// failure is rendered as the standard validation problem, so registering rules only ever narrows what a host -/// already gets. +/// The failure's errors are matched against the registered rules — codes first, then categories, then origins, +/// each in registration order — and the first rule that answers wins. A rule with a status code renders the +/// standard validation problem with that default status; a rule with a factory renders whatever the factory +/// returns, and a factory returning declines the failure so matching continues. When no +/// rule answers, the failure is rendered as the standard validation problem, so registering rules only ever +/// narrows what a host already gets. /// /// /// A failure that does not carry validation errors is declined (), so other mappers and diff --git a/src/src/ZodSharp.AspNetCore/ZodResultsHttpOptions.cs b/src/src/ZodSharp.AspNetCore/ZodResultsHttpOptions.cs index 272389f..ef13f20 100644 --- a/src/src/ZodSharp.AspNetCore/ZodResultsHttpOptions.cs +++ b/src/src/ZodSharp.AspNetCore/ZodResultsHttpOptions.cs @@ -7,32 +7,43 @@ namespace Purview.Results.ZodSharp.AspNetCore; /// /// Configures how a result failure that carries ZodSharp validation errors is rendered, by validation error -/// code and by category. +/// code, by category and by origin. /// /// /// /// Without a rule, a failure that carries validation errors is rendered as one HttpValidationProblemDetails, /// produced by the same mapping a thrown ZodException uses. A rule answers a failure whose errors include -/// its code (or category) with a response of its own, which is what lets one error code be a plain 404 -/// while the rest stay validation problems. +/// its code (or category, or origin) with a response of its own, which is what lets one error code be a plain +/// 404 while the rest stay validation problems. +/// +/// +/// The three axes are the identity a carries: is +/// the specific rule that failed, is a broad grouping many codes share, and +/// is the structured origin a rule owns ("value_object" for a +/// type-level rule, "array" for a collection rule, and so on). A code, a category and an origin can each +/// answer one failure, so a rule that must key off something other than a code — a whole family of rules sharing +/// an origin — is expressible here. /// /// /// Rules are matched against the failure's whole error set, in this order: every code rule in registration -/// order, then every category rule in registration order, then the default validation problem. A rule -/// therefore applies when any of the failure's errors carries its code or category, so registering a code -/// rule guarantees it is reachable whatever else the schema reported. A factory that wants stricter semantics -/// returns to decline the failure, and matching continues. +/// order, then every category rule in registration order, then every origin rule in registration +/// order, then the default validation problem. A rule therefore applies when any of the failure's errors +/// carries its code, category or origin, so registering a code rule guarantees it is reachable whatever else the +/// schema reported. A factory that wants stricter semantics returns to decline the failure, +/// and matching continues. The order is fixed rather than registration-ordered across axes, because a code is +/// narrower than a category, which is narrower than an origin: a rule can only narrow what the host already gets. /// /// /// A factory is handed the failure's full error set, so a rule never hides the other problems the caller has to -/// fix. Register one rule per code or category; registering the same value twice with different behaviour is -/// rejected at configuration time. +/// fix. Register one rule per code, category or origin; registering the same value twice with different behaviour +/// is rejected at configuration time. /// /// public sealed class ZodResultsHttpOptions { readonly List _codeRules = []; readonly List _categoryRules = []; + readonly List _originRules = []; /// /// Renders a validation failure whose errors include as the standard validation @@ -109,11 +120,58 @@ public ZodResultsHttpOptions MapCategory( return AddCategory(category, new(null, map)); } + /// + /// Renders a validation failure whose errors include the as the standard validation + /// problem with a different default status code. + /// + /// + /// The the rule applies to — the origin a rule owns, such as + /// "value_object" for a type-level rule or "array" for a collection rule. + /// + /// + /// The status code used when and the resolved error + /// type do not define one. + /// + /// The options instance for chaining. + /// + /// Thrown when is empty, or already has a rule with different behaviour. + /// + public ZodResultsHttpOptions MapOrigin(string origin, int statusCode) => AddOrigin(origin, new(statusCode, null)); + + /// + /// Renders a validation failure whose errors include the with a response of your own. + /// + /// + /// The the rule applies to — the origin a rule owns, such as + /// "value_object" for a type-level rule or "array" for a collection rule. + /// + /// + /// Creates the response from the failure's validation errors, or returns to decline the + /// failure and let matching continue. + /// + /// The options instance for chaining. + /// + /// Thrown when is empty, or already has a rule with different behaviour. + /// + /// Thrown when is null. + public ZodResultsHttpOptions MapOrigin( + string origin, + Func, HttpContext, IResult?> map + ) + { + ArgumentNullException.ThrowIfNull(map); + + return AddOrigin(origin, new(null, map)); + } + /// /// Finds the rules that could answer a failure, in the order they are consulted. /// /// The validation errors the failure carries. - /// Every matching code rule in registration order, then every matching category rule. + /// + /// Every matching code rule in registration order, then every matching category rule, then every matching + /// origin rule. + /// internal IEnumerable Candidates(ImmutableArray errors) { foreach (var rule in Matches(_codeRules, errors, static error => error.Code)) @@ -121,6 +179,9 @@ internal IEnumerable Candidates(ImmutableArray er foreach (var rule in Matches(_categoryRules, errors, static error => error.Category)) yield return rule; + + foreach (var rule in Matches(_originRules, errors, static error => error.Origin)) + yield return rule; } ZodResultsHttpOptions AddCode(string code, ZodErrorRuleBehaviour behaviour) => @@ -129,6 +190,9 @@ ZodResultsHttpOptions AddCode(string code, ZodErrorRuleBehaviour behaviour) => ZodResultsHttpOptions AddCategory(string category, ZodErrorRuleBehaviour behaviour) => Add(_categoryRules, category, "category", behaviour); + ZodResultsHttpOptions AddOrigin(string origin, ZodErrorRuleBehaviour behaviour) => + Add(_originRules, origin, "origin", behaviour); + ZodResultsHttpOptions Add(List rules, string value, string kind, ZodErrorRuleBehaviour behaviour) { ArgumentException.ThrowIfNullOrWhiteSpace(value); @@ -177,7 +241,7 @@ static IEnumerable Matches( } } -/// How a code or category rule answers the failures that mention it. +/// How a code, category or origin rule answers the failures that mention it. /// /// The default status code of the validation problem, or when the rule supplies a factory. /// @@ -189,7 +253,7 @@ readonly record struct ZodErrorRuleBehaviour( Func, HttpContext, IResult?>? Map ); -/// One registered code or category rule. -/// The error code or category the rule applies to. +/// One registered code, category or origin rule. +/// The error code, category or origin the rule applies to. /// How the rule answers a failure that mentions the value. sealed record ZodErrorRule(string Value, ZodErrorRuleBehaviour Behaviour); diff --git a/src/src/ZodSharp/IValidationErrorCarrier.cs b/src/src/ZodSharp/IValidationErrorCarrier.cs index e820e81..e242950 100644 --- a/src/src/ZodSharp/IValidationErrorCarrier.cs +++ b/src/src/ZodSharp/IValidationErrorCarrier.cs @@ -9,7 +9,7 @@ namespace Purview.Results.ZodSharp; /// /// A union case that carries the errors of a rejected input implements this interface, so an HTTP layer can turn /// it into a validation problem without knowing the error type. The errors preserve each -/// 's code, category, path and parameters. +/// 's code, origin, category, path and parameters. /// public interface IValidationErrorCarrier { diff --git a/src/src/ZodSharp/Sdk/.agents/skills/purview-results-zodsharp-validation/SKILL.md b/src/src/ZodSharp/Sdk/.agents/skills/purview-results-zodsharp-validation/SKILL.md index 7f15cba..24d2b51 100644 --- a/src/src/ZodSharp/Sdk/.agents/skills/purview-results-zodsharp-validation/SKILL.md +++ b/src/src/ZodSharp/Sdk/.agents/skills/purview-results-zodsharp-validation/SKILL.md @@ -61,8 +61,8 @@ public readonly record struct ProviderConnectionInvalid( ``` Implement `IValidationErrorCarrier` on the case (or error) that carries ZodSharp `ValidationError` values. -`ValidationErrors` preserves each error's code, category, path and parameters, so an HTTP layer can render a -validation problem **without knowing the error type** — which is exactly how +`ValidationErrors` preserves each error's code, origin, category, path and parameters, so an HTTP layer can render +a validation problem **without knowing the error type** — which is exactly how `Purview.Results.ZodSharp.AspNetCore` maps it (see the `purview-results-zodsharp-problems` skill). Guidance: diff --git a/src/src/ZodSharp/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md index fe2c3bc..3e8e02f 100644 --- a/src/src/ZodSharp/Sdk/README.md +++ b/src/src/ZodSharp/Sdk/README.md @@ -58,16 +58,16 @@ results. Naming the error type explicitly keeps the intent unambiguous. ## Examples -[`src/examples/Examples.Zod`](https://github.com/purview-dev/results/tree/main/src/examples/Examples.Zod) is a +[`src/src/Examples.Zod`](https://github.com/purview-dev/results/tree/main/src/src/Examples.Zod) is a runnable console example that validates a `[ZodSchema] TenantInput` and turns the outcome into a `Result`, with the rejection carrying its reported `ValidationError`s. ```bash -dotnet run --project src/examples/Examples.Zod +dotnet run --project src/src/Examples.Zod ``` -The [repository README](https://github.com/purview-dev/results#examples) lists the Basic, ASP.NET Core and -ASP.NET Core + Zod examples too. +The [repository README](https://github.com/purview-dev/results#examples) lists the Basic, ASP.NET Core, +ASP.NET Core + Zod and value-objects composition examples too. ## Related packages diff --git a/src/tests/ZodSharp.AspNetCore.UnitTests/ValueObjectRuleTestModels.cs b/src/tests/ZodSharp.AspNetCore.UnitTests/ValueObjectRuleTestModels.cs new file mode 100644 index 0000000..e271e89 --- /dev/null +++ b/src/tests/ZodSharp.AspNetCore.UnitTests/ValueObjectRuleTestModels.cs @@ -0,0 +1,76 @@ +using System.ComponentModel.DataAnnotations; +using Purview.ValueObjects; +using Purview.ValueObjects.Serialization; +using ZodSharp; +using ZodSharp.Core; + +namespace Purview.Results.ZodSharp.AspNetCore; + +/// +/// A type-level ZodSharp rule that validates a scalar value object as a unit, reading it through its +/// contract and owning the reported code and origin. +/// +/// +/// Implementing is what lets one attribute report a domain code per annotated scalar, and +/// what makes the axis the HTTP layer can key on. +/// +/// The scalar value object the rule validates. +/// An optional code that overrides for one annotated scalar. +/// An optional message that overrides . +readonly record struct NonEmptyValidatedIdRule(string? Code = null, string? Message = null) + : IValidationRule, + IZodRule + where TSelf : IScalarValueObject +{ + /// Gets the error code reported when the rule fails. + public const string ErrorCode = "invalid_value"; + + /// Gets the message format used when the rule reports its own message. + public const string MessageFormat = "Value must not be empty."; + + /// Validates that the scalar does not hold the sentinel . + /// The value to validate. + /// when the value is not the empty identifier. + public bool IsValid(in TSelf value) => value.Value != Guid.Empty; + + /// Gets the error message for a failed validation. + /// The value that failed validation. + /// The configured message, or the rule's default. + public string GetErrorMessage(in TSelf value) => Message ?? MessageFormat; + + string IValidationRule.Code => Code ?? ErrorCode; + + /// + string? IZodRule.Code => Code; + + /// + string? IZodRule.Origin => "value_object"; +} + +/// +/// Maps to a type-level attribute, so the ZodSharp generator closes +/// the open generic with the annotated scalar and the rule validates the value object as a unit. +/// +[ZodRule(typeof(NonEmptyValidatedIdRule<>))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +sealed class NonEmptyValidatedIdAttribute : ValidationAttribute +{ + /// Gets or sets the error code reported for this application of the rule. + public string? Code { get; set; } + + /// Gets or sets the error message reported for this application of the rule. + public string? Message { get; set; } +} + +/// +/// 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] +[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; } +} diff --git a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs index b5dae6f..7a10d11 100644 --- a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs +++ b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs @@ -8,13 +8,14 @@ namespace Purview.Results.ZodSharp.AspNetCore; /// /// Tests for and : the rules a host -/// registers per validation error code and category, and how they sit in the failure resolution order. +/// registers per validation error code, category and origin, and how they sit in the failure resolution order. /// public sealed class ZodResultsFailureMapperTests { const string InvalidNameCode = "invalid_name"; const string MissingCode = "missing"; const string InvalidValueCategory = "invalid_value"; + const string ValueObjectOrigin = "value_object"; [Test] public async Task Map_GivenCodeRuleWithStatus_ReturnsTheValidationProblemWithThatStatus() @@ -106,6 +107,190 @@ public async Task Map_GivenCodeAndCategoryRulesForTheSameFailure_PrefersTheCodeR await Assert.That(statusCode).IsEqualTo(StatusCodes.Status422UnprocessableEntity); } + [Test] + public async Task Map_GivenOriginRule_ReturnsTheValidationProblemWithThatStatus() + { + // Arrange + // An origin is the structured origin a rule owns, so one origin rule answers a whole family of codes + // that a type-level rule reports as "value_object". + await using var services = CreateServices(zod => + zod.MapOrigin(ValueObjectOrigin, StatusCodes.Status422UnprocessableEntity) + ); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + // Act + var (statusCode, body) = await ExecuteAsync( + mapper.Map(new InputRejected(7, CreateOriginErrors(ValueObjectOrigin)).AsFailure(), context), + context + ); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status422UnprocessableEntity); + await Assert.That(body).Contains(InvalidNameCode); + } + + [Test] + public async Task Map_GivenOriginRuleFactory_ReturnsThatFactoryResponse() + { + // Arrange + await using var services = CreateServices(zod => + zod.MapOrigin(ValueObjectOrigin, (_, _) => TypedResults.Conflict()) + ); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + // Act + var (statusCode, _) = await ExecuteAsync( + mapper.Map(new InputRejected(7, CreateOriginErrors(ValueObjectOrigin)).AsFailure(), context), + context + ); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status409Conflict); + } + + [Test] + public async Task Map_GivenOriginAndCategoryRulesForTheSameFailure_PrefersTheCategoryRule() + { + // Arrange + // An origin is the broadest of the three axes, so it is only consulted after the code and category + // rules, however the host registered the rules. + await using var services = CreateServices(zod => + zod.MapOrigin(ValueObjectOrigin, StatusCodes.Status400BadRequest) + .MapCategory(InvalidValueCategory, StatusCodes.Status409Conflict) + ); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + // Act + var (statusCode, _) = await ExecuteAsync( + mapper.Map( + new InputRejected( + 7, + CreateErrorsWithCategoryAndOrigin(InvalidValueCategory, ValueObjectOrigin) + ).AsFailure(), + context + ), + context + ); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status409Conflict); + } + + [Test] + public async Task Map_GivenCodeAndOriginRulesForTheSameFailure_PrefersTheCodeRule() + { + // Arrange + await using var services = CreateServices(zod => + zod.MapOrigin(ValueObjectOrigin, StatusCodes.Status409Conflict) + .MapCode(InvalidNameCode, StatusCodes.Status422UnprocessableEntity) + ); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + // Act + var (statusCode, _) = await ExecuteAsync( + mapper.Map(new InputRejected(7, CreateOriginErrors(ValueObjectOrigin)).AsFailure(), context), + context + ); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status422UnprocessableEntity); + } + + [Test] + public async Task Map_GivenDecliningCodeRuleAndMatchingOriginRule_FallsThroughToTheOriginRule() + { + // Arrange + // The strict code rule declines a mixed failure, so the origin rule registered for the family of rules + // answers it instead of the failure losing its validation response. + await using var services = CreateServices(zod => + zod.MapCode(InvalidNameCode, (errors, _) => errors.Length == 1 ? TypedResults.NotFound() : null) + .MapOrigin(ValueObjectOrigin, StatusCodes.Status422UnprocessableEntity) + ); + 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.Status422UnprocessableEntity); + await Assert.That(body).Contains(MissingCode); + } + + [Test] + public async Task Map_GivenAFailureFromAValueObjectRule_AnswersByOrigin() + { + // Arrange + // The origin is the axis a rule owns, so an origin rule answers every type-level rule failure without + // naming a code. The code and origin come from the real generated schema, not a hand-built error, so this + // proves the identity a value-object rule reports reaches the HTTP mapping end to end. + await using var services = CreateServices(zod => + zod.MapOrigin(ValueObjectOrigin, StatusCodes.Status422UnprocessableEntity) + ); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + var validation = ValidatedTenantIdSchema.Validate(ValidatedTenantId.Hydrate(Guid.Empty)); + 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("invalid_tenant_id"); + await Assert.That(body).Contains(ValueObjectOrigin); + } + + [Test] + public async Task MapOrigin_GivenEmptyOrigin_Throws() + { + // Arrange + ZodResultsHttpOptions options = new(); + + // Act + var exception = CaptureArgumentException(() => + options.MapOrigin(string.Empty, StatusCodes.Status400BadRequest) + ); + + // Assert + await Assert.That(exception).IsNotNull(); + } + + [Test] + public async Task MapOrigin_GivenTheSameOriginTwiceWithDifferentBehaviour_Throws() + { + // Arrange + ZodResultsHttpOptions options = new(); + options.MapOrigin(ValueObjectOrigin, StatusCodes.Status400BadRequest); + + // Act + var exception = CaptureArgumentException(() => + options.MapOrigin(ValueObjectOrigin, StatusCodes.Status409Conflict) + ); + + // Assert + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Message).Contains(ValueObjectOrigin); + } + [Test] public async Task Map_GivenNoMatchingRule_ReturnsTheDefaultValidationProblem() { @@ -228,7 +413,7 @@ public async Task Map_GivenHostFailureMapperRegisteredFirst_AnswersBeforeTheVali { // Arrange // A host mapper registered in AddResultsHttp runs before the ZodSharp mapping, which is how a host that - // does not want the code/category API answers validation carriers itself. + // does not want the code/category/origin API answers validation carriers itself. await using var services = CreateServices( results: results => results.AddFailureMapper(), configureServices: collection => collection.AddSingleton() @@ -349,6 +534,26 @@ .. codes.Select(code => ), ]; + /// Creates a failure's errors with an origin, which is what an origin rule looks for. + static ImmutableArray CreateOriginErrors(string origin, params string[] codes) => + [ + .. (codes.Length > 0 ? codes : [InvalidNameCode]).Select(code => + ValidationError.Create(code, $"The {code} check failed.", ["Name"], origin: origin) + ), + ]; + + /// Creates errors that carry both a category and an origin, so precedence can be exercised. + static ImmutableArray CreateErrorsWithCategoryAndOrigin(string category, string origin) => + [ + ValidationError.Create( + InvalidNameCode, + "The name check failed.", + ["Name"], + origin: origin, + category: category + ), + ]; + /// Runs an action that must reject the host's configuration. static ArgumentException? CaptureArgumentException(Action action) { diff --git a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodSharp.AspNetCore.UnitTests.csproj b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodSharp.AspNetCore.UnitTests.csproj index 18a3ce8..faf53fe 100644 --- a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodSharp.AspNetCore.UnitTests.csproj +++ b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodSharp.AspNetCore.UnitTests.csproj @@ -9,6 +9,17 @@ + + + + + + Date: Mon, 5 Oct 2026 12:18:02 +0100 Subject: [PATCH 04/11] fix: sgen now bundled --- AGENTS.md | 46 ++++++++++-------- README.md | 30 ++++++------ docs/wiki/Agent-Skills.md | 4 +- docs/wiki/Getting-Started.md | 7 ++- docs/wiki/Home.md | 3 +- docs/wiki/Release-Flow.md | 13 ++--- docs/wiki/Source-Generator.md | 33 +++++++------ docs/wiki/Union-Errors.md | 6 +-- purview-build.json | 19 +++----- src/src/Results/ResultT.cs | 2 + src/src/Results/Results.csproj | 47 +++++++++++++++++++ .../purview-results-union-author.agent.md | 0 ...e-error-returns-to-result-unions.prompt.md | 0 .../purview-results-union-errors/SKILL.md | 7 ++- src/src/Results/Sdk/README.md | 18 ++++--- .../buildTransitive/Purview.Results.props} | 12 ++--- .../SourceGenerator.CodeFixes.csproj | 8 ++-- .../Helpers/PropertyLibrary.cs | 2 +- src/src/SourceGenerator/{Sdk => }/README.md | 26 +++++----- .../SourceGenerator/SourceGenerator.csproj | 32 +++++-------- .../ResultsSourceGeneratorTestOptions.cs | 2 +- .../SourceGeneratorPackageAssetsTests.cs | 34 +++++++++++--- 22 files changed, 205 insertions(+), 146 deletions(-) rename src/src/{SourceGenerator => Results}/Sdk/.agents/agents/purview-results-union-author.agent.md (100%) rename src/src/{SourceGenerator => Results}/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md (100%) rename src/src/{SourceGenerator => Results}/Sdk/.agents/skills/purview-results-union-errors/SKILL.md (95%) rename src/src/{SourceGenerator/Sdk/buildTransitive/Purview.Results.SourceGenerator.props => Results/Sdk/buildTransitive/Purview.Results.props} (53%) rename src/src/SourceGenerator/{Sdk => }/README.md (93%) diff --git a/AGENTS.md b/AGENTS.md index 069f2e0..76d68be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,8 @@ ## Purpose and authority -This repository contains the Purview result types for .NET: `Purview.Results`, a Roslyn source generator that -makes C# 15 union error cases ergonomic, and the ZodSharp and ASP.NET Core integrations. +This repository contains the Purview result types for .NET: `Purview.Results` (which bundles the Roslyn source +generator that makes C# 15 union error cases ergonomic), and the ZodSharp and ASP.NET Core integrations. - This file is the repository-wide source of truth for AI agents. A more-specific `AGENTS.md` in a subtree, if one is ever added, takes precedence for that subtree. @@ -20,7 +20,7 @@ makes C# 15 union error cases ergonomic, and the ZodSharp and ASP.NET Core integ | --- | --- | | `src/Results.slnx` | Canonical solution for restore, build, test and pack | | `src/src/Results` | `Result`, the `Result` factories, `IResultValue` | -| `src/src/SourceGenerator` | Roslyn incremental generator, diagnostic analyzer, `[GenerateResult]` attribute | +| `src/src/SourceGenerator` | Roslyn incremental generator, diagnostic analyzer and `[GenerateResult]` attribute; not a package of its own, packed into `Purview.Results` | | `src/src/SourceGenerator.CodeFixes` | IDE code fix for `CS0029`: rewrites a returned union case into the generated `AsFailure()` | | `src/src/AspNetCore` | Result-to-response mapping, endpoint filter, DI registration | | `src/src/ZodSharp` | ZodSharp `ValidationResult` bridge | @@ -70,7 +70,7 @@ makes C# 15 union error cases ergonomic, and the ZodSharp and ASP.NET Core integ ## Source generator rules -`src/src/SourceGenerator/Sdk/README.md` is the authoritative design document — keep it in sync with behaviour, +`src/src/SourceGenerator/README.md` is the authoritative design document — keep it in sync with behaviour, including the diagnostics table, build properties and activation rules. - **One analyzer, one generator, one diagnostics library.** `Diagnostics/DiagnosticLibrary.cs`, @@ -98,11 +98,12 @@ including the diagnostics table, build properties and activation rules. have. - Create the `CodeWriter` inside the `RegisterSourceOutput` callback; never store it in incremental pipeline state or a custom context. -- `ResultsSourceGenerator_Disable` must stay declared as both a `PurviewGeneratorVisibleProperty` and a +- `DisableResultsSourceGenerator` must stay declared as both a `PurviewGeneratorVisibleProperty` and a `CompilerVisibleProperty`, and shipped to consumers by - `src/src/SourceGenerator/Sdk/buildTransitive/Purview.Results.SourceGenerator.props`. The framework's PSGF0003 - validation fails the build when a declared generator-read property is neither compiler-visible nor declared by - the package's own `Sdk/build*` assets, which is what keeps the packaged switch honest. + `src/src/Results/Sdk/buildTransitive/Purview.Results.props` (the package that carries the generator). The + framework's PSGF0003 validation fails the build when a declared generator-read property is neither + compiler-visible nor declared by a package's own `Sdk/build*` assets, which is what keeps the packaged switch + honest. - `[GenerateResult]` is supported on union declarations only. Generic unions and `IUnionMembers` member providers are deliberately unsupported and reported as `RSG1002`/`RSG1007`. - **Do not attempt to generate implicit conversions.** C# blocks every route: an operator is illegal in a static @@ -166,22 +167,28 @@ including the diagnostics table, build properties and activation rules. into the package root, so `Sdk/README.md` becomes the `.nupkg` README and takes precedence over the repository-root `README.md`. Update it for any user-visible change, and keep the repository-root `README.md` consistent with it. -- The packed shapes that must not regress: the Roslyn component ships its analyzer assembly under - `analyzers/dotnet/cs/` with no `lib/` folder and **no PDB** (`PurviewPackAnalyzerPdb=false`), and the library - packages ship `lib//.dll` plus the XML documentation file and a symbol package. -- The component opts out of the analyzer PDB because the packaged analyzer is the framework's merged (ILRepack) +- The packed shapes that must not regress: `Purview.Results` ships the merged Roslyn component and its code fix + under `analyzers/dotnet/cs/` with no `lib/` folder and **no PDB**, and every library package ships + `lib//.dll` plus the XML documentation file and a symbol package. +- The generator ships **no analyzer PDB** because the packaged analyzer is the framework's merged (ILRepack) assembly: the merge tool's rewritten PDB carries no Roslyn compiler-flags record, and the pipeline's pack validation asserts `optimization=release` for every assembly that ships a PDB, with no way to scope that check - to one package. Telemetry and the other component packages opt out the same way. Revisit if the framework's - merge tool starts preserving the compiler-flags record. + to one package. `Purview.Results` packs only the merged assembly (through the framework's + `GetPurviewMergedAnalyzerFile` target) and the code fix, so no analyzer PDB is contributed. Telemetry and the + other component packages opt out the same way. Revisit if the framework's merge tool starts preserving the + compiler-flags record. - `purview-build.json`'s `PackValidation.RequiredContent` is the exhaustive declaration of what each package ships (`RequireExplicitContent` defaults to `true`, so an undeclared entry fails too). Update it whenever package content changes — a new asset, a removed PDB, a renamed analyzer — and verify with `just pipeline-pack-validate`, which runs restore, build, lint, tests, pack and the validation. -- `Purview.Results.SourceGenerator` therefore carries **two** analyzer assemblies in `analyzers/dotnet/cs/`: - the merged generator and `Purview.Results.SourceGenerator.CodeFixes.dll`. The code fix is packed by the SDK's - `PackProjectReferencedSourceGenerators` from the analyzer project reference in `SourceGenerator.csproj`, and it - is never IL-merged into the generator. Verify both are present whenever analyzers or packaging change. +- `Purview.Results` therefore carries **two** analyzer assemblies in `analyzers/dotnet/cs/`: the merged + generator and `Purview.Results.SourceGenerator.CodeFixes.dll`. The code fix is packed by the + `PackResultsSourceGenerator` target in `Results.csproj`, which also calls the framework's + `GetPurviewMergedAnalyzerFile` for the generator, and it is never IL-merged into the generator. The generator + is deliberately **not** referenced as an analyzer by `Purview.Results`: it emits its opt-in attribute into + every compilation it runs on, so running it on the library would bake a conflicting + `Purview.Results.GenerateResultAttribute` into the package and raise `CS0436` in every consumer. Verify both + assemblies are present whenever analyzers or packaging change. - Verify packaging by packing and inspecting the output, for example `dotnet pack src/Results.slnx -o ` followed by opening the `.nupkg`. The PR and release pipelines run the same check through `Purview.Build`'s pack validation. @@ -267,8 +274,7 @@ maintained here. | Content | Package | Path | | --- | --- | --- | -| `skills/purview-results-core` | `Purview.Results` | `src/src/Results/Sdk/.agents` | -| `skills/purview-results-union-errors`, `agents/purview-results-union-author.agent.md`, `prompts/migrate-error-returns-to-result-unions.prompt.md` | `Purview.Results.SourceGenerator` | `src/src/SourceGenerator/Sdk/.agents` | +| `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` | diff --git a/README.md b/README.md index 43e8b22..3b6dfe9 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,16 @@ Exceptional circumstances still throw; expected outcomes are values. | Package | Purpose | Targets | | --- | --- | --- | -| [`Purview.Results`](src/src/Results/Sdk/README.md) | `Result` and the `Result` factories. No dependencies. | `net11.0` | -| [`Purview.Results.SourceGenerator`](src/src/SourceGenerator/Sdk/README.md) | Generates `AsFailure()` helpers for `[GenerateResult]` unions. | `netstandard2.0` | +| [`Purview.Results`](src/src/Results/Sdk/README.md) | `Result`, the `Result` factories, and the bundled source generator that emits `AsFailure()` helpers for `[GenerateResult]` unions. No runtime dependencies. | `net11.0` | | [`Purview.Results.ZodSharp`](src/src/ZodSharp/Sdk/README.md) | Bridges ZodSharp `ValidationResult` values into results. | `net11.0` | | [`Purview.Results.AspNetCore`](src/src/AspNetCore/Sdk/README.md) | Maps results onto ASP.NET Core responses (`IResult`, `ProblemDetails`). | `net11.0` | | [`Purview.Results.ZodSharp.AspNetCore`](src/src/ZodSharp.AspNetCore/Sdk/README.md) | Maps result failures that carry validation errors onto `HttpValidationProblemDetails`. | `net11.0` | Each package README is the same file that ships inside the `.nupkg` (project `Sdk/README.md`), so this table -links straight to the package documentation. Every package also ships agent skills under `.agents/` (union -modelling and the result core, HTTP mapping, and the two ZodSharp bridges), which `Purview.BuildSdk` mirrors -into a consuming repository's own `.agents/` folder on the next restore or build. +links straight to the package documentation. The source generator is not a separate package: it ships inside +`Purview.Results` under `analyzers/dotnet/cs`, together with its IDE code fix. Every package also ships agent +skills under `.agents/` (union modelling and the result core, HTTP mapping, and the two ZodSharp bridges), which +`Purview.BuildSdk` mirrors into a consuming repository's own `.agents/` folder on the next restore or build. ## Quick start @@ -33,8 +33,8 @@ Result GetTenant(TenantId tenantId) => : new TenantNotFound(tenantId).AsFailure(); ``` -`AsFailure()` is generated by `Purview.Results.SourceGenerator` for every case of a union opted in with -`[GenerateResult]`: +`AsFailure()` is generated by the source generator bundled in `Purview.Results` for every case of a +union opted in with `[GenerateResult]`: ```csharp [GenerateResult] @@ -47,11 +47,11 @@ public readonly record struct TenantAlreadyExists(TenantId TenantId); The generator cannot make a bare case value convert implicitly — C# forbids operators in a static class, conversion operators in extension members, and more than one user-defined conversion per sequence — so the -per-case helper is the ergonomics the language allows. The generator package also ships a code fix for the -IDE and a diagnostic suppressor that answers `CA1815` for opted-in unions, so a `[GenerateResult]` union needs -no `#pragma warning disable CA1815`, and returning the union itself +per-case helper is the ergonomics the language allows. `Purview.Results` also ships a code fix for the IDE and +a diagnostic suppressor that answers `CA1815` for opted-in unions, so a `[GenerateResult]` union needs no +`#pragma warning disable CA1815`, and returning the union itself (`(TenantError)new TenantNotFound(id)`) is the only helper-free form; see -the [generator package README](src/src/SourceGenerator/Sdk/README.md) for the compiler evidence. +the [source generator README](src/src/SourceGenerator/README.md) for the compiler evidence. Expose the result over HTTP with the ASP.NET Core package, which maps each error case to a response: @@ -71,7 +71,7 @@ Tenancy domain the Quick start uses, so the one `TenantError` union drives every | Example | Packages | Demonstrates | | --- | --- | --- | -| [`Examples.Basic`](src/src/Examples.Basic) | `Purview.Results`, `Purview.Results.SourceGenerator` | States, `Match`/`Map`/`Bind`/`MapError`/`Ensure`, probing, the throw-on-misuse contract, and the generated `AsFailure()` helper | +| [`Examples.Basic`](src/src/Examples.Basic) | `Purview.Results` | States, `Match`/`Map`/`Bind`/`MapError`/`Ensure`, probing, the throw-on-misuse contract, and the generated `AsFailure()` helper | | [`Examples.Zod`](src/src/Examples.Zod) | + `Purview.Results.ZodSharp` | A `[ZodSchema]` input validated into a result, where the rejection carries its `ValidationError`s | | [`Examples.AspNetCore`](src/src/Examples.AspNetCore) | + `Purview.Results.AspNetCore` | `AddResultsHttp`/`Map`/`WithResultsHttp`, including 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 | @@ -222,8 +222,8 @@ The full documentation suite lives in [`docs/wiki`](docs/wiki/Getting-Started.md ## 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. +- **.NET 11 SDK or later** — the runtime packages target `net11.0`; the source generator bundled in + `Purview.Results` targets `netstandard2.0` so any compiler host can load it. - **C# 15 preview** — union declarations are a preview language feature, so consuming projects need an SDK with union support and `LangVersion=preview` (the repository sets both). @@ -255,7 +255,7 @@ dotnet tool restore just build # build the solution (Debug) just test-unit # run the unit tests just lint-check # CSharpier + .slnx GUID validation -just pack # pack all five packages to ./artifacts +just pack # pack all four packages to ./artifacts just pipeline-pr # the full PR pipeline (restore, build, lint, test, pack) ``` diff --git a/docs/wiki/Agent-Skills.md b/docs/wiki/Agent-Skills.md index 6519f3e..d06087b 100644 --- a/docs/wiki/Agent-Skills.md +++ b/docs/wiki/Agent-Skills.md @@ -10,12 +10,12 @@ automatically. The content is authored in this repository under `src/src/`, the `Result` factories and `IResultValue`. No dependencies. | -| `Purview.Results.SourceGenerator` | Generates `AsFailure()` helpers for `[GenerateResult]` unions, plus the analyzer and code fix. | +| `Purview.Results` | `Result`, the `Result` factories and `IResultValue`, plus the bundled source generator for `[GenerateResult]` unions. No runtime dependencies. | | `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`. | diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index 8be52b8..2b84548 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -43,10 +43,11 @@ 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. +- `Purview.Results` ships the merged source generator under `analyzers/dotnet/cs/` with **no `lib/` folder** + and **no PDB**, 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 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`. @@ -55,8 +56,8 @@ The framework assembly must never appear loose beside the merged analyzer — `F ## 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 +The generator bundled in `Purview.Results` 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. diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md index f7af625..5b95404 100644 --- a/docs/wiki/Source-Generator.md +++ b/docs/wiki/Source-Generator.md @@ -1,19 +1,19 @@ # 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. +The [`Purview.Results`](https://www.nuget.org/packages/Purview.Results) package ships 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. It is not published as a separate package. ## 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`. +The component targets `netstandard2.0` (with the SDK's Roslyn defaults) so any compiler host can load it, and it +is applied to the compilation automatically from the package's `analyzers/dotnet/cs` folder. Requirements: a +C# 15 compiler with union declaration support (the .NET 11 SDK or later) and `LangVersion=preview`. ## Activation and design @@ -77,20 +77,19 @@ not change, wrap or replace `Result`. | Property | Default | Purpose | | --- | --- | --- | -| `ResultsSourceGenerator_Disable` | `false` | Disables generation while still emitting the opt-in attribute | +| `DisableResultsSourceGenerator` | `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`. +The switch reaches `PackageReference` consumers through `buildTransitive/Purview.Results.props`, so +`-p:DisableResultsSourceGenerator=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. +The generator is never packed loose: `Purview.Results` ships its merged analyzer under `analyzers/dotnet/cs/` +with no `lib/` folder and **no PDB**, 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. +The generator is not a separate NuGet package. ## Related diff --git a/docs/wiki/Union-Errors.md b/docs/wiki/Union-Errors.md index d4047cc..25e73f9 100644 --- a/docs/wiki/Union-Errors.md +++ b/docs/wiki/Union-Errors.md @@ -17,9 +17,9 @@ 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. +`[GenerateResult]` is generated by the source generator bundled in `Purview.Results` 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 diff --git a/purview-build.json b/purview-build.json index 91d907b..1eea3a4 100644 --- a/purview-build.json +++ b/purview-build.json @@ -7,10 +7,10 @@ "TestFilter": "/*/*/*/*[Category=Unit]" }, "PackValidation": { - // Purview.Results.SourceGenerator is a Roslyn component: the SDK's Roslyn defaults set - // IsRoslynComponentOnly, so it produces no .snupkg. It also sets PurviewPackAnalyzerPdb=false, so it - // ships no PDB either; the four library packages produce symbol packages, and RequireSymbolFiles and - // the assembly checks below all apply to them. + // 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 + // source generator is no longer published as its own package. "RequireSymbolPackage": false, // RequireExplicitContent defaults to true, so this is the exhaustive declaration of what each // package ships: a package with no rule here, or an entry matching none of its globs, fails. @@ -18,6 +18,9 @@ "purview.results": [ ".agents/**/*", "README.md", + "analyzers/dotnet/cs/Purview.Results.SourceGenerator.CodeFixes.dll", + "analyzers/dotnet/cs/Purview.Results.SourceGenerator.dll", + "buildTransitive/Purview.Results.props", "lib/$(TFM)/Purview.Results.dll", "lib/$(TFM)/Purview.Results.xml", "purview-logo-light.png" @@ -29,14 +32,6 @@ "lib/$(TFM)/Purview.Results.AspNetCore.xml", "purview-logo-light.png" ], - "purview.results.sourcegenerator": [ - ".agents/**/*", - "README.md", - "analyzers/dotnet/cs/Purview.Results.SourceGenerator.CodeFixes.dll", - "analyzers/dotnet/cs/Purview.Results.SourceGenerator.dll", - "buildTransitive/Purview.Results.SourceGenerator.props", - "purview-logo-light.png" - ], "purview.results.zodsharp": [ ".agents/**/*", "README.md", diff --git a/src/src/Results/ResultT.cs b/src/src/Results/ResultT.cs index 8962eb7..f79bc97 100644 --- a/src/src/Results/ResultT.cs +++ b/src/src/Results/ResultT.cs @@ -193,11 +193,13 @@ public Result MapError(Func /// Creates a successful result. /// + [SuppressMessage("Design", "CA1000:Do not declare static members on generic types")] public static Result Success(TValue value) => new(value); /// /// Creates a failed result. /// + [SuppressMessage("Design", "CA1000:Do not declare static members on generic types")] public static Result Failure(TError error) => new(error); enum ResultState : byte diff --git a/src/src/Results/Results.csproj b/src/src/Results/Results.csproj index 51e5730..145b856 100644 --- a/src/src/Results/Results.csproj +++ b/src/src/Results/Results.csproj @@ -4,5 +4,52 @@ Purview Results Dependency-free Result<TValue, TError> for .NET, with C# 15 union ergonomics for its error cases and integrations that let expected failures flow through a value instead of an exception. results;result;railway;error-handling;union;unions;csharp-15;net11;purview; + + $(TargetsForTfmSpecificContentInPackage);PackResultsSourceGenerator + + + + + + + + + + + + + + + diff --git a/src/src/SourceGenerator/Sdk/.agents/agents/purview-results-union-author.agent.md b/src/src/Results/Sdk/.agents/agents/purview-results-union-author.agent.md similarity index 100% rename from src/src/SourceGenerator/Sdk/.agents/agents/purview-results-union-author.agent.md rename to src/src/Results/Sdk/.agents/agents/purview-results-union-author.agent.md diff --git a/src/src/SourceGenerator/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md b/src/src/Results/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md similarity index 100% rename from src/src/SourceGenerator/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md rename to src/src/Results/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md diff --git a/src/src/SourceGenerator/Sdk/.agents/skills/purview-results-union-errors/SKILL.md b/src/src/Results/Sdk/.agents/skills/purview-results-union-errors/SKILL.md similarity index 95% rename from src/src/SourceGenerator/Sdk/.agents/skills/purview-results-union-errors/SKILL.md rename to src/src/Results/Sdk/.agents/skills/purview-results-union-errors/SKILL.md index b11d454..f144f5f 100644 --- a/src/src/SourceGenerator/Sdk/.agents/skills/purview-results-union-errors/SKILL.md +++ b/src/src/Results/Sdk/.agents/skills/purview-results-union-errors/SKILL.md @@ -12,7 +12,6 @@ Use this skill when a codebase models expected failures as a **union** and retur ```csharp using Purview.Results; -using Purview.Results.SourceGeneration; // One small, focused type per case. public readonly record struct TenantNotFound(TenantId TenantId); @@ -54,7 +53,7 @@ A helper-free form exists when a cast reads better, because the cast closes the Result Get(TenantId id) => (TenantError)new TenantNotFound(id); ``` -`Purview.Results.SourceGenerator` also ships an IDE code fix: when you write +`Purview.Results` also ships an IDE code fix: when you write `return new TenantNotFound(id);` in a `Result`-returning member, the lightbulb offers the `AsFailure()` rewrite. The fix is only offered when the rewrite will bind (the converted type is a result, the error type is a `[GenerateResult]` union, the value is one of its cases, and the union is in scope @@ -109,8 +108,8 @@ compared by value. ## Requirements and switches - **.NET 11 SDK or later** with `LangVersion=preview` — union declarations are a preview language feature. -- A reference to `Purview.Results` for the result type the helpers build. -- `-p:ResultsSourceGenerator_Disable=true` (or a `Directory.Build.props` setting) disables the helpers while +- A reference to `Purview.Results`, which provides both the result type and this generator. +- `-p:DisableResultsSourceGenerator=true` (or a `Directory.Build.props` setting) disables the helpers while the opt-in attribute is still emitted, which is useful when the generated code has to be inspected in isolation. diff --git a/src/src/Results/Sdk/README.md b/src/src/Results/Sdk/README.md index e369566..1d55d1f 100644 --- a/src/src/Results/Sdk/README.md +++ b/src/src/Results/Sdk/README.md @@ -69,9 +69,8 @@ returns `null`. ## Union error types A C# 15 union is a natural `TError`: the error cases stay strongly typed. Because C# never composes the union -conversion with the result's own conversion, a case value cannot be returned directly — the -[`Purview.Results.SourceGenerator`](https://www.nuget.org/packages/Purview.Results.SourceGenerator) package -generates a per-case `AsFailure()` helper for `[GenerateResult]` unions: +conversion with the result's own conversion, a case value cannot be returned directly — the source generator +bundled in this package generates a per-case `AsFailure()` helper for `[GenerateResult]` unions: ```csharp Result GetTenant(TenantId tenantId) => new TenantNotFound(tenantId).AsFailure(); @@ -79,9 +78,8 @@ Result GetTenant(TenantId tenantId) => new TenantNotFound(t The generator cannot declare that conversion for you: C# forbids user-defined operators in a static class, forbids conversion operators in extension members, and permits only one user-defined conversion per conversion -sequence. The -[`Purview.Results.SourceGenerator`](https://www.nuget.org/packages/Purview.Results.SourceGenerator) package -ships a code fix that offers the rewrite in the IDE when a case value is returned where a result is expected. +sequence. The same package ships a code fix that offers the rewrite in the IDE when a case value is returned +where a result is expected. If you prefer to avoid the helper entirely, the cast form needs no generated code, because the cast closes the case → union conversion so only the library's union → result conversion remains: @@ -107,16 +105,16 @@ ASP.NET Core + Zod and value-objects composition examples too. | Package | Purpose | | --- | --- | -| [`Purview.Results.SourceGenerator`](https://www.nuget.org/packages/Purview.Results.SourceGenerator) | Generates `AsFailure()` helpers for `[GenerateResult]` unions | | [`Purview.Results.AspNetCore`](https://www.nuget.org/packages/Purview.Results.AspNetCore) | Maps results onto ASP.NET Core responses, including `ProblemDetails` | | [`Purview.Results.ZodSharp`](https://www.nuget.org/packages/Purview.Results.ZodSharp) | Bridges ZodSharp `ValidationResult` into results | | [`Purview.Results.ZodSharp.AspNetCore`](https://www.nuget.org/packages/Purview.Results.ZodSharp.AspNetCore) | Maps validation-carrying failures onto `HttpValidationProblemDetails` | ## Agent skills -This package ships the `purview-results-core` agent skill under `.agents/`. Repositories that import -`Purview.BuildSdk` get it mirrored into their own `.agents/` folder on the next restore or build, so AI agents -working there receive the guidance automatically. +This package ships the `purview-results-core` and `purview-results-union-errors` agent skills, the +`purview-results-union-author` agent and the `migrate-error-returns-to-result-unions` prompt under `.agents/`. +Repositories that import `Purview.BuildSdk` get them mirrored into their own `.agents/` folder on the next +restore or build, so AI agents working there receive the guidance automatically. ## License diff --git a/src/src/SourceGenerator/Sdk/buildTransitive/Purview.Results.SourceGenerator.props b/src/src/Results/Sdk/buildTransitive/Purview.Results.props similarity index 53% rename from src/src/SourceGenerator/Sdk/buildTransitive/Purview.Results.SourceGenerator.props rename to src/src/Results/Sdk/buildTransitive/Purview.Results.props index eef90f2..2d96c29 100644 --- a/src/src/SourceGenerator/Sdk/buildTransitive/Purview.Results.SourceGenerator.props +++ b/src/src/Results/Sdk/buildTransitive/Purview.Results.props @@ -1,17 +1,17 @@ - + True to disable the generated result helpers while still emitting the opt-in [GenerateResult] attribute. diff --git a/src/src/SourceGenerator.CodeFixes/SourceGenerator.CodeFixes.csproj b/src/src/SourceGenerator.CodeFixes/SourceGenerator.CodeFixes.csproj index c33107c..912473e 100644 --- a/src/src/SourceGenerator.CodeFixes/SourceGenerator.CodeFixes.csproj +++ b/src/src/SourceGenerator.CodeFixes/SourceGenerator.CodeFixes.csproj @@ -1,10 +1,10 @@ - true true - - false - ResultsSourceGenerator_Disable + DisableResultsSourceGenerator @@ -64,11 +56,11 @@ /// The MSBuild/analyzer-config property that disables the generator. /// - public const string DisableGeneratorProperty = "ResultsSourceGenerator_Disable"; + public const string DisableGeneratorProperty = "DisableResultsSourceGenerator"; /// /// The hint names of the sources emitted during post-initialization, which are excluded from the diff --git a/src/tests/SourceGenerator.UnitTests/SourceGeneratorPackageAssetsTests.cs b/src/tests/SourceGenerator.UnitTests/SourceGeneratorPackageAssetsTests.cs index 78885bc..b5fb7fb 100644 --- a/src/tests/SourceGenerator.UnitTests/SourceGeneratorPackageAssetsTests.cs +++ b/src/tests/SourceGenerator.UnitTests/SourceGeneratorPackageAssetsTests.cs @@ -5,16 +5,17 @@ namespace Purview.Results.SourceGenerator; /// consumers. /// /// +/// The generator ships inside the Purview.Results package, so its build switches are reachable only +/// when that package ships a buildTransitive/*.props asset declaring a CompilerVisibleProperty. /// A property declared only inside this repository never reaches a consumer's compiler, so the documented -/// ResultsSourceGenerator_Disable switch silently does nothing unless the package ships it as a -/// buildTransitive/*.props asset declaring a CompilerVisibleProperty. These tests fail if that -/// asset is deleted, renamed, or drifts away from the property the project declares. +/// DisableResultsSourceGenerator switch silently does nothing without that asset. These tests fail if +/// the asset is deleted, renamed, or drifts away from the property the project declares. /// public sealed class SourceGeneratorPackageAssetsTests { - const string DisablePropertyName = "ResultsSourceGenerator_Disable"; - const string PackagedPropsPath = - "src/src/SourceGenerator/Sdk/buildTransitive/Purview.Results.SourceGenerator.props"; + const string DisablePropertyName = "DisableResultsSourceGenerator"; + const string PackagedPropsPath = "src/src/Results/Sdk/buildTransitive/Purview.Results.props"; + const string ResultsProjectPath = "src/src/Results/Results.csproj"; const string ProjectPath = "src/src/SourceGenerator/SourceGenerator.csproj"; [Test] @@ -47,6 +48,27 @@ await Assert await Assert.That(content).Contains(DisablePropertyName); } + [Test] + public async Task ResultsProject_ShouldBundleTheMergedGeneratorAndCodeFix() + { + // The generator is retired as its own package, so Purview.Results must pack the merged analyzer and + // the code fix under analyzers/dotnet/cs without running the generator on this library's compilation. + var project = await File.ReadAllTextAsync(Path.Combine(RepositoryRoot(), ResultsProjectPath)); + + await Assert.That(project).Contains("PackResultsSourceGenerator"); + await Assert.That(project).Contains("GetPurviewMergedAnalyzerFile"); + await Assert.That(project).Contains("..\\SourceGenerator.CodeFixes\\SourceGenerator.CodeFixes.csproj"); + await Assert.That(project).Contains("analyzers/dotnet/cs/"); + } + + [Test] + public async Task SourceGeneratorProject_ShouldNotBePackable() + { + var project = await File.ReadAllTextAsync(Path.Combine(RepositoryRoot(), ProjectPath)); + + await Assert.That(project).DoesNotContain("true"); + } + static string RepositoryRoot() { DirectoryInfo? directory = new(AppContext.BaseDirectory); From 0004df1b1eeada91a967608ede85c6abaccfb7ec Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 5 Oct 2026 14:53:55 +0100 Subject: [PATCH 05/11] feat: added support for non-result-based return Results (i.e. error only) --- .csharpierignore | 2 + AGENTS.md | 49 +- README.md | 55 +- docs/wiki/AspNetCore-Integration.md | 40 +- docs/wiki/Combinators.md | 58 +- docs/wiki/Contributing.md | 2 +- docs/wiki/Core-Concepts.md | 28 + docs/wiki/Getting-Started.md | 8 +- docs/wiki/Guarantees-and-Limitations.md | 24 +- docs/wiki/Home.md | 8 +- docs/wiki/Source-Generator.md | 19 +- docs/wiki/Testing.md | 2 +- docs/wiki/Union-Errors.md | 67 +- docs/wiki/ZodSharp-Integration.md | 16 + docs/wiki/index.md | 5 +- .../AspNetCore/DefaultResultsHttpMapper.cs | 58 +- src/src/AspNetCore/ResultsEndpointFilter.cs | 3 +- src/src/AspNetCore/ResultsFailureContext.cs | 10 +- src/src/AspNetCore/ResultsHttpOptions.cs | 15 +- .../purview-results-http-mapping/SKILL.md | 29 +- src/src/AspNetCore/Sdk/README.md | 31 +- src/src/Examples.AspNetCore/BillingError.cs | 41 ++ src/src/Examples.AspNetCore/BillingService.cs | 20 + src/src/Examples.AspNetCore/Program.cs | 32 +- .../TenantBillingService.cs | 25 + src/src/Examples.AspNetCore/TenantStore.cs | 12 + src/src/Examples.Basic/BillingError.cs | 42 ++ src/src/Examples.Basic/BillingService.cs | 21 + src/src/Examples.Basic/Program.cs | 66 +- .../TenantRegistrationService.cs | 47 ++ src/src/Examples.Basic/TenantStore.cs | 11 + src/src/Examples.Zod/Program.cs | 17 + .../Examples.Zod/TenantRegistrationService.cs | 18 + .../Purview/Results/ResultExtensions.cs | 516 ++++++++++++++ src/src/Results/IResultValue.cs | 5 + src/src/Results/Result.cs | 15 + src/src/Results/ResultException.cs | 83 +++ src/src/Results/ResultTError.cs | 242 +++++++ .../purview-results-union-author.agent.md | 13 +- ...e-error-returns-to-result-unions.prompt.md | 7 +- .../skills/purview-results-core/SKILL.md | 47 +- .../purview-results-union-errors/SKILL.md | 53 +- src/src/Results/Sdk/README.md | 56 +- src/src/Results/Success.cs | 26 + .../UnionCaseResultCodeFixProvider.cs | 73 +- .../Emit/ResultUnionEmitter.cs | 52 +- .../Helpers/PropertyLibrary.cs | 7 + .../Helpers/ResultsTypeLibrary.cs | 10 + src/src/SourceGenerator/README.md | 40 +- .../SKILL.md | 23 +- src/src/ZodSharp/Sdk/README.md | 4 +- .../ZodSharp/ZodValidationResultExtensions.cs | 35 + .../DefaultResultsHttpMapperTests.cs | 195 ++++++ .../ResultsHttpTestUnions.cs | 12 + .../ResultExtensionsTests.cs | 633 ++++++++++++++++++ .../Results.UnitTests/ResultTErrorTests.cs | 356 ++++++++++ src/tests/Results.UnitTests/SuccessTests.cs | 16 + .../GeneratedResultsIntegrationTests.cs | 78 +++ .../GeneratedResultsRuntimeTests.cs | 32 + .../GeneratedTestUnions.cs | 28 + .../ResultsSourceGeneratorTests.cs | 24 + .../UnionCaseResultCodeFixProviderTests.cs | 29 + .../ZodValidationResultExtensionsTests.cs | 51 ++ 63 files changed, 3437 insertions(+), 205 deletions(-) create mode 100644 src/src/Examples.AspNetCore/BillingError.cs create mode 100644 src/src/Examples.AspNetCore/BillingService.cs create mode 100644 src/src/Examples.AspNetCore/TenantBillingService.cs create mode 100644 src/src/Examples.Basic/BillingError.cs create mode 100644 src/src/Examples.Basic/BillingService.cs create mode 100644 src/src/Examples.Basic/TenantRegistrationService.cs create mode 100644 src/src/Results/ResultException.cs create mode 100644 src/src/Results/ResultTError.cs create mode 100644 src/src/Results/Success.cs create mode 100644 src/tests/Results.UnitTests/ResultTErrorTests.cs create mode 100644 src/tests/Results.UnitTests/SuccessTests.cs diff --git a/.csharpierignore b/.csharpierignore index 312db68..a816241 100644 --- a/.csharpierignore +++ b/.csharpierignore @@ -9,7 +9,9 @@ src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs src/tests/ZodSharp.AspNetCore.UnitTests/ValidationTestUnions.cs src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs src/src/Examples.Basic/TenantError.cs +src/src/Examples.Basic/BillingError.cs src/src/Examples.Zod/TenantError.cs src/src/Examples.ValueObjects.Zod/TenantError.cs src/src/Examples.AspNetCore/TenantError.cs +src/src/Examples.AspNetCore/BillingError.cs src/src/Examples.AspNetCore.Zod/TenantError.cs diff --git a/AGENTS.md b/AGENTS.md index 76d68be..4c7a5c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,9 +19,9 @@ generator that makes C# 15 union error cases ergonomic), and the ZodSharp and AS | Path | Purpose | | --- | --- | | `src/Results.slnx` | Canonical solution for restore, build, test and pack | -| `src/src/Results` | `Result`, the `Result` factories, `IResultValue` | +| `src/src/Results` | `Result`, the value-less `Result`, the `Success` marker, the `Result` factories, `IResultValue` | | `src/src/SourceGenerator` | Roslyn incremental generator, diagnostic analyzer and `[GenerateResult]` attribute; not a package of its own, packed into `Purview.Results` | -| `src/src/SourceGenerator.CodeFixes` | IDE code fix for `CS0029`: rewrites a returned union case into the generated `AsFailure()` | +| `src/src/SourceGenerator.CodeFixes` | IDE code fix for `CS0029`: rewrites a returned union case into the generated `AsFailure()` (value result) or `AsFailure()` (unit result) | | `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 | @@ -53,14 +53,32 @@ generator that makes C# 15 union error cases ergonomic), and the ZodSharp and AS - `Result` is a `readonly record struct` with three states: `Uninitialized` (the `default` value), `Success` and `Failure`. Keep all three observable through `IsInitialized`, `IsSuccess` and `IsFailure`. +- `Result` is the value-less counterpart: the same `readonly record struct` state machine, the same + throw-on-misuse contract and the same three states, but a success holds the `Success` marker (a stateless + `readonly record struct` in `Purview.Results`) instead of a value. Its success callbacks take no argument + (`Match`, `Switch`, `Tap`, `Ensure`), `Error` throws in the wrong state, `ToString()` is `Success` / + `Failure(error)` / `Uninitialized`, and `IResultValue.SuccessValue` is the marker. Do not merge the two types or + let one silently convert into the other. - Keep the throw-on-misuse contract: `Value` and `Error` throw `InvalidOperationException` with the existing messages in the wrong state, and `Match`, `Map`, `Bind` and `MapError` throw `"The result is uninitialized."` for `default`. Never silently coerce an uninitialized result into a success or a failure. -- `ToString()` stays `Success(value)` / `Failure(error)` / `Uninitialized`. +- `ToString()` stays `Success(value)` / `Failure(error)` / `Uninitialized`; for `Result` a success is + `Success` with no value. - `IResultValue` is the non-generic view used by infrastructure. Its accessors must never throw; the accessor that does not describe the current state returns `null`. -- Implicit conversions from `TValue` and `TError`, and the `Result.Success`/`Result.Failure` and - `Result.Success`/`.Failure` factories, are public contract. +- Implicit conversions from `TValue`, `TError` and `Success`, and the `Result.Success`/`Result.Failure`, + `Result.Success`/`.Failure` and `Result.Success`/`.Failure` factories, are public + contract. `Result.DiscardValue()` (value result → unit result) is the value-style bridge between + the two shapes. +- `Bind` preserves `TError`. Chaining a step that fails with a different error is the widening + `Bind(bind, mapError)` / `BindAsync(bind, mapError)` overload (equivalent to `MapError(mapError).Bind(bind)`), or + a `TryGetError` guard with the generated `AsFailure()`. A caller union that includes the upstream error + union as a case keeps the lift to a single union conversion. +- `Throw()` returns the successful value — or, for `Result`, the `Success` marker — throws + `ResultException` (with the error on `Error`, and a non-generic `ResultException` base carrying it as + `object?` for a catch-all) on failure, and throws `InvalidOperationException` + (`"The result is uninitialized."`) for `default`. It is the only deliberate result-to-exception escape hatch and + must never coerce `default`. - `Purview.Results` stays dependency-free. The ZodSharp and ASP.NET Core packages depend on it, never the reverse, and `Purview.Results.AspNetCore` deliberately knows nothing about ZodSharp. - Keep runtime packages free of reflection, `dynamic` and runtime type discovery. Union structure is inspected @@ -112,6 +130,10 @@ including the diagnostics table, build properties and activation rules. is unavailable for a non-generic case type (`CS0246`), and only one user-defined conversion may participate in a sequence (`CS0029`). All five are recorded in `UnionCompilerBehaviourTests.cs`. Improve call-site ergonomics through the code fix in `src/src/SourceGenerator.CodeFixes` instead. +- Emit **two helpers per case**: the value-producing `AsFailure()` (`Result`) and the + non-generic `AsFailure()` (`Result` unit result). Keep them overloads of the same name in the one + generated extension class, keep the bodies pure `Result<...>.Failure(error)` calls, and keep case ordering + deterministic. - Adding a code fix means adding it to `src/src/SourceGenerator.CodeFixes`, not to the generator project: the code fix needs `Microsoft.CodeAnalysis.*.Workspaces`, which must not enter the generator's analyzer closure. The two projects stay independent (no project reference, no `InternalsVisibleTo`) so no reference cycle can @@ -121,9 +143,15 @@ including the diagnostics table, build properties and activation rules. ## ASP.NET Core integration invariants -- Keep the failure resolution order in `DefaultResultsHttpMapper`: the mapping for the error **case** type, - then the mapping for the **error** type (which covers every case without its own mapping), then the - registered fallbacks in order, then the unmapped-failure response. +- A successful value result is serialized with `SuccessStatusCode`; a successful unit `Result` carries no + payload and answers `204 No Content`. `SuccessMapper` overrides both, and a successful value that is itself an + `IResult` still passes through untouched. +- Keep the failure resolution order in `DefaultResultsHttpMapper`: the mapping for the most specific **case** type, + then the mapping for each enclosing **union** type from the inside out, then the mapping for the **error** type + (which covers every case without its own mapping), then the registered fallbacks in order, then the + unmapped-failure response. A union error resolves to its innermost active case, so a nested union resolves to its + leaf while a mapping for an enclosing union still applies when the leaf has none; the fallback stage receives that + leaf case. - **One fallback stage, one ordered list.** `ResultsHttpOptions.AddFallback(...)` delegates and `AddFailureMapper()` failure mappers append to the *same* list, in the order they are called, and an entry defers by returning `null`. `IResultsFailureMapper` (with `ResultsFailureContext`, which carries the case, @@ -227,8 +255,9 @@ including the diagnostics table, build properties and activation rules. ## Examples `src/src/Examples.*` holds one runnable example per integration aspect, all on the Tenant* domain the READMEs -document: `Examples.Basic` (the result type and the generated helpers), `Examples.Zod` -(`Purview.Results.ZodSharp`), `Examples.AspNetCore` (`Purview.Results.AspNetCore`), +document: `Examples.Basic` (the result type, the value-less `Result` and the generated `AsFailure()` +and `AsFailure()` helpers), `Examples.Zod` (`Purview.Results.ZodSharp`, including `ToUnitResult`), +`Examples.AspNetCore` (`Purview.Results.AspNetCore`, including a unit result answering `204`), `Examples.AspNetCore.Zod` (`Purview.Results.ZodSharp.AspNetCore`) and `Examples.ValueObjects.Zod` (`Purview.Results.ZodSharp` + `Purview.ValueObjects`, a type-level `[ZodRule]` whose code and origin flow into the result and the HTTP mapping). diff --git a/README.md b/README.md index 3b6dfe9..850768f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Exceptional circumstances still throw; expected outcomes are values. | Package | Purpose | Targets | | --- | --- | --- | -| [`Purview.Results`](src/src/Results/Sdk/README.md) | `Result`, the `Result` factories, and the bundled source generator that emits `AsFailure()` helpers for `[GenerateResult]` unions. No runtime dependencies. | `net11.0` | +| [`Purview.Results`](src/src/Results/Sdk/README.md) | `Result` and its value-less counterpart `Result`, the `Result` factories, and the bundled source generator that emits `AsFailure()` and `AsFailure()` helpers for `[GenerateResult]` unions. No runtime dependencies. | `net11.0` | | [`Purview.Results.ZodSharp`](src/src/ZodSharp/Sdk/README.md) | Bridges ZodSharp `ValidationResult` values into results. | `net11.0` | | [`Purview.Results.AspNetCore`](src/src/AspNetCore/Sdk/README.md) | Maps results onto ASP.NET Core responses (`IResult`, `ProblemDetails`). | `net11.0` | | [`Purview.Results.ZodSharp.AspNetCore`](src/src/ZodSharp.AspNetCore/Sdk/README.md) | Maps result failures that carry validation errors onto `HttpValidationProblemDetails`. | `net11.0` | @@ -67,13 +67,13 @@ app.MapGet("/tenants/{id:int}", (int id) => GetTenant(id)).WithResultsHttp(); ## Examples Every example is a runnable, non-packable project under `src/src/Examples.*`, built on the same -Tenancy domain the Quick start uses, so the one `TenantError` union drives every integration aspect. +Tenancy domain the Quick start uses, so one error-union vocabulary drives every integration aspect. | Example | Packages | Demonstrates | | --- | --- | --- | -| [`Examples.Basic`](src/src/Examples.Basic) | `Purview.Results` | States, `Match`/`Map`/`Bind`/`MapError`/`Ensure`, probing, the throw-on-misuse contract, and the generated `AsFailure()` helper | -| [`Examples.Zod`](src/src/Examples.Zod) | + `Purview.Results.ZodSharp` | A `[ZodSchema]` input validated into a result, where the rejection carries its `ValidationError`s | -| [`Examples.AspNetCore`](src/src/Examples.AspNetCore) | + `Purview.Results.AspNetCore` | `AddResultsHttp`/`Map`/`WithResultsHttp`, including the mapping-gap and uninitialized-result paths | +| [`Examples.Basic`](src/src/Examples.Basic) | `Purview.Results` | States, `Match`/`Map`/`Bind`/`MapError`/`Ensure`, probing, the throw-on-misuse contract, the generated `AsFailure()` and `AsFailure()` helpers, cross-service error-union widening, value-less `Result` commands, and `Throw()` | +| [`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 | @@ -106,6 +106,39 @@ loaded.TryGetError(out var error); // probing never throws, even for `default` loaded.Value; // throws InvalidOperationException unless the result is a success ``` +When one service calls another, the caller's operation-family union includes the callee's error union as a case, +and the widening `Bind` (or a `TryGetError` guard with the generated helper) lifts the callee's failure into it. +`Throw()` is the escape hatch for a boundary that must throw: + +```csharp +[GenerateResult] +public readonly union RegisterTenantError(TenantError, BillingError); + +Result Register(TenantId id, string name) => + billing.ReserveQuota(id) // Result + .Bind(quota => CreateTenant(quota, name), error => error); + +store.GetTenant(id).Throw(); // returns the value, or throws ResultException +``` + +When an operation has nothing to return on success, use the value-less `Result`. It carries the same +three states and the same throw-on-misuse contract, but succeeds with the `Success` marker instead of a value, +and the generator emits a non-generic `AsFailure()` for the same cases: + +```csharp +Result DeleteTenant(TenantId tenantId) => + _tenants.Remove(tenantId) + ? Result.Success() + : new TenantNotFound(tenantId).AsFailure(); + +var deleted = DeleteTenant(tenantId); + +deleted.Match(() => "deleted", error => $"could not delete: {error}"); // the success arm takes no value +deleted.Map(() => 1); // attach a value to a success +Result loaded = deleted.Bind(() => GetTenant(tenantId)); +loaded.DiscardValue(); // and back to a unit result +``` + ### Zod ZodSharp validation never throws: `Validate` returns a `ValidationResult` carrying the validated @@ -144,8 +177,9 @@ return RegisterValidated(validation.Value); ### ASP.NET Core -The host decides what each error case looks like on the wire. A mapping registered for the **case** type wins; -the mapping registered for the **error** type covers every case without one: +The host decides what each error case looks like on the wire. A mapping registered for the most specific **case** +type wins, then a mapping for an enclosing **union** type, then the mapping registered for the **error** type, +which covers every case without one. A nested union resolves to its leaf: ```csharp builder.Services.AddResultsHttp(options => options @@ -165,6 +199,11 @@ Running `Examples.AspNetCore` answers as follows: | `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 | +| `DELETE /tenants/hooli` | `204 No Content` — a successful unit `Result` carries no payload | +| `DELETE /tenants/initech` | `404 Not Found` — the unit result's `TenantNotFound` failure maps by case | +| `GET /tenants/acme/billing` | `503 Service Unavailable` — the leaf of the nested `TenantOperationError` union | +| `GET /tenants/hooli/billing` | `402 Payment Required` — another leaf of the nested union | +| `GET /tenants/initech/billing` | `404 Not Found` — a nested tenant failure resolves to its leaf | | `GET /tenants/broken` | `500` with an `errorType` extension, because an endpoint returning `default` is a host bug | **Case** and **error** mappings are keyed by type. When the answer depends on the *value* a failure carries — a @@ -232,7 +271,7 @@ The full documentation suite lives in [`docs/wiki`](docs/wiki/Getting-Started.md | Path | Purpose | | --- | --- | | `src/Results.slnx` | Canonical solution for restore, build, test and pack | -| `src/src/Results` | `Result`, `Result` factories, `IResultValue` | +| `src/src/Results` | `Result` and `Result`, `Success`, the `Result` factories, `IResultValue` | | `src/src/SourceGenerator` | Roslyn incremental generator + diagnostic analyzer for `[GenerateResult]` | | `src/src/AspNetCore` | Result-to-response mapping, endpoint filter and DI registration | | `src/src/ZodSharp` | ZodSharp `ValidationResult` bridge | diff --git a/docs/wiki/AspNetCore-Integration.md b/docs/wiki/AspNetCore-Integration.md index f5acfe3..cbc661c 100644 --- a/docs/wiki/AspNetCore-Integration.md +++ b/docs/wiki/AspNetCore-Integration.md @@ -1,7 +1,7 @@ # 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. +`Purview.Results.AspNetCore` maps a `Result` or a value-less `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 @@ -39,17 +39,21 @@ non-invasive: a handler that returns something other than an `IResultValue` is l ## 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. +value is itself an `IResult` is passed through untouched, and `SuccessMapper` overrides both when set. A +successful unit `Result` carries no payload, so it answers `204 No Content` unless `SuccessMapper` is 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: +**Failure** — the error is resolved to its *case* value (the innermost active case of a union error, so a nested +union resolves to its leaf; 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 +1. a mapping registered for the most specific **case** type — `Map(...)` +2. a mapping registered for each enclosing **union** type, from the inside out — `Map(...)` handles a + whole nested union when its leaf has no mapping of its own +3. 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 +4. the **fallback stage**, in registration order: `AddFallback(...)` delegates and `AddFailureMapper()` + mappers share one ordered list; a fallback or mapper receives the leaf case and returns `null` to defer to the + next entry +5. 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` @@ -98,11 +102,11 @@ builder.Services.AddResultsHttp(options => options .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. +`ResultsFailureContext` carries the failure's **case** (the innermost active case of a union error, so a nested +union resolves to its leaf; 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 @@ -135,6 +139,12 @@ Running `Examples.AspNetCore`: | `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 | +| `DELETE /tenants/hooli` | `204 No Content` — a successful unit `Result` carries no payload | +| `DELETE /tenants/initech` | `404 Not Found` — the unit result's `TenantNotFound` failure maps by case | +| `GET /tenants/acme/billing` | `503 Service Unavailable` — the leaf `BillingServiceUnavailable` case of the nested `TenantOperationError` union | +| `GET /tenants/hooli/billing` | `402 Payment Required` — the leaf `BillingAccountMissing` case | +| `GET /tenants/initech/billing` | `404 Not Found` — a nested tenant failure resolves to the `TenantNotFound` leaf | +| `GET /tenants/globex/billing` | `403 Forbidden` — a nested tenant failure resolves to the `TenantDisabled` leaf | | `GET /tenants/broken` | `500` with an `errorType` extension, because an endpoint returning `default` is a host bug | ```bash diff --git a/docs/wiki/Combinators.md b/docs/wiki/Combinators.md index 8ce8cb1..fd39a0e 100644 --- a/docs/wiki/Combinators.md +++ b/docs/wiki/Combinators.md @@ -11,6 +11,7 @@ throwing input check for a `null` delegate is `ArgumentNullException`. | `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 | +| `Bind(bind, mapError)` | the result `bind` returns | `Result.Failure(mapError(error))` | throws | | `MapError(map)` | the value, unchanged | `Result.Failure(map(error))` | throws | ```csharp @@ -26,6 +27,25 @@ Result renamed = GetTenant(tenantId) `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. +## Chaining across error types + +`Bind` preserves the error type, so a step that fails with a **different** error needs the widening overload: it +maps the existing error into the next step's error type and binds in one call. It is equivalent to +`MapError(mapError).Bind(bind)`, and `BindAsync(bind, mapError)` is the asynchronous counterpart. + +```csharp +Result Register(TenantId id, string name) => + billing.ReserveQuota(id) // Result + .Bind( + quota => CreateTenant(quota, name), // Result + error => error); // BillingError -> RegisterTenantError +``` + +The target union is the operation family's error, and the upstream service's error union is one of its cases, so +`error => error` relies on the implicit union conversion. When the target union flattens the services' leaf cases +instead, map them explicitly with `MapError`. [Union Errors](Union-Errors.md) covers the modelling choice and its +HTTP-mapping consequence. + ## Constraining a success `Ensure(predicate, errorFactory)` turns a successful value that fails the predicate into a failure: @@ -83,8 +103,42 @@ 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. +`MapAsync`, `BindAsync` and the widening `BindAsync(bind, mapError)` behave exactly like their synchronous +counterparts, and all throw for `default`. They are extension methods in `ResultExtensions`, not members on the +result type. + +## Unwrapping with `Throw` + +`Throw()` returns the successful value and throws a `ResultException` carrying the error when the result +represents failure: + +```csharp +var tenant = store.GetTenant(tenantId).Throw(); // throws ResultException on failure +``` + +It is the deliberate result-to-exception escape hatch — reach for it at a boundary that must throw, or in a test, +not to handle an expected failure, which is normally a value. The thrown exception exposes the error with its +original type through `ResultException.Error`; a non-generic `ResultException` base carries the same error +as `object?` for a catch-all. An uninitialized result is a bug, so `Throw()` reports it with +`InvalidOperationException` (`"The result is uninitialized."`) rather than `ResultException`. + +## Value-less results + +`Result` supports the same operations, but its success callbacks take no argument because there is no +value to pass. `Match`, `Switch`, `Tap` and `Ensure` therefore use a no-argument success delegate: + +```csharp +Result deleted = DeleteTenant(tenantId); + +deleted.Match(() => "deleted", error => $"could not delete: {error}"); +deleted.Ensure(() => _tenants.Count > 0, () => new TenantError()); +``` + +`Map(() => value)` attaches a value to a success, producing a `Result`, and `Bind` continues with +either another unit operation or a value-producing one. The widening `Bind` and `BindAsync` overloads exist in +both shapes. `Result.DiscardValue()` is the inverse of `Map`: it drops the value and keeps the +error, so a value result can join a unit-result chain. `Throw()` returns the `Success` marker and throws +`ResultException` on failure, and `default` still throws `InvalidOperationException`. ## Related diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index e27a2a5..cc59fe1 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -9,7 +9,7 @@ Contributions are welcome. Open an issue or a pull request against src/ Results.slnx Canonical solution for restore, build, test and pack src/ - Results/ Result, Result factories, IResultValue + Results/ Result, 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 diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md index 6d22d97..ce8b77b 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -28,6 +28,11 @@ An expected failure is a value; a misuse is a bug. The contract is deliberately 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. +`Throw()` is the one member that turns an expected failure back into an exception: it returns the successful value +and throws `ResultException` carrying the error on failure, and for `default` it throws the same +`InvalidOperationException` as the combinators. Treat it as an escape hatch for a boundary that must throw, not as +a way to handle a failure. + ## Probing without throwing `TryGetValue` and `TryGetError` in `ResultExtensions` are the deliberate exception to the throwing contract: they @@ -76,6 +81,29 @@ error types — the ASP.NET Core endpoint filter, for example. 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. +## Value-less results + +An operation that has nothing to return on success — a command, a validation-only step — uses `Result`, +the value-less counterpart of `Result`. It has the same three states and the same throw-on-misuse +contract, but a success holds the `Success` marker rather than a value: + +| State | How it is reached | `ToString()` | +| --- | --- | --- | +| `Uninitialized` | `default(Result)` | `Uninitialized` | +| `Success` | `Result.Success()` | `Success` | +| `Failure` | `Result.Failure(error)` | `Failure(error)` | + +Because there is no success value, the success callbacks take no argument: `Match(() => ..., error => ...)`, +`Switch(() => ..., error => ...)` and `Tap(() => ...)`. `Map` produces a value from a success that had none, +`Bind` continues with another operation, and the value-result extension `DiscardValue()` turns a +`Result` back into a unit result, preserving the error. `Throw()` returns the `Success` marker +and throws `ResultException` on failure, exactly as the value result does. + +The implicit conversions from `Success` and `TError`, and the `Result.Success`/`.Failure` and +`Result.Success()`/`Result.Failure(error)` factories, are public contract. `IResultValue` sees a +successful unit result's `SuccessValue` as the `Success` marker, which is what lets the ASP.NET Core mapper answer +it with `204 No Content`. + ## Where extension methods live Value-style operations that do not belong on the result type itself — probing, fallbacks, side effects, diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index d75b5a1..50289f1 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -35,14 +35,18 @@ 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: +the generator emits a value-producing `AsFailure()` and a non-generic `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 + + public static Result AsFailure(this TenantNotFound error) => + Result.Failure(error); + // ... the same pair per case type } ``` diff --git a/docs/wiki/Guarantees-and-Limitations.md b/docs/wiki/Guarantees-and-Limitations.md index 9ff4917..59a8065 100644 --- a/docs/wiki/Guarantees-and-Limitations.md +++ b/docs/wiki/Guarantees-and-Limitations.md @@ -8,13 +8,22 @@ change must not break. - `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`. +- `Result` is the value-less counterpart: a `readonly record struct` with the same three states and the + same throw-on-misuse contract, whose success holds the `Success` marker instead of a value. `Error` throws in + the wrong state, `Match`, `Map`, `Bind`, `MapError` and the extension operations throw + `"The result is uninitialized."` for `default`, `Throw()` returns the `Success` marker or throws + `ResultException`, and `ToString()` stays `Success` / `Failure(error)` / `Uninitialized`. - 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. +- `Throw()` returns the successful value, throws `ResultException` carrying the error on failure, and + throws `"The result is uninitialized."` for `default`. It is the one deliberate result-to-exception escape hatch. - `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. +- `IResultValue` accessors never throw; the accessor that does not describe the current state returns `null`. A + successful unit result's `SuccessValue` is the `Success` marker. +- The implicit conversions from `TValue`, `TError` and `Success`, and the `Result.Success`/`Result.Failure`, + `Result.Success`/`.Failure` and `Result.Success`/`.Failure` factories, are public + contract. ## Dependency guarantees @@ -30,8 +39,8 @@ change must not break. - `[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. + rules. The per-case `AsFailure()` and `AsFailure()` helpers 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). @@ -56,9 +65,10 @@ change must not break. ## Not in scope -- No exception-to-result conversion: exceptions remain for exceptional circumstances. +- No exception-to-result conversion: exceptions remain for exceptional circumstances. The reverse — turning a + result failure into an exception — is available explicitly through `Throw()`. - 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 replacement for `Result` or `Result`: the generator only adds call-site ergonomics. - No `Task`/`ValueTask`-specific async combinators beyond `MapAsync` and `BindAsync`. ## Related diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index db72e5c..014314c 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -1,7 +1,8 @@ # 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 **expected** failures part of a method's contract instead of an exception, its value-less +`Result` counterpart for operations that have nothing to return on success, 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. @@ -37,7 +38,7 @@ This wiki is the project documentation hub. Packages are published under the `Pu | Package | Purpose | | --- | --- | -| `Purview.Results` | `Result`, the `Result` factories and `IResultValue`, plus the bundled source generator for `[GenerateResult]` unions. No runtime dependencies. | +| `Purview.Results` | `Result` and the value-less `Result`, the `Result` factories and `IResultValue`, plus the bundled source generator for `[GenerateResult]` unions. No runtime dependencies. | | `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`. | @@ -49,7 +50,8 @@ This wiki is the project documentation hub. Packages are published under the `Pu - **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. + value, and the generator supplies the per-case `AsFailure()` and `AsFailure()` helpers 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 diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md index 5b95404..e472513 100644 --- a/docs/wiki/Source-Generator.md +++ b/docs/wiki/Source-Generator.md @@ -1,9 +1,10 @@ # Source Generator The [`Purview.Results`](https://www.nuget.org/packages/Purview.Results) package ships 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. It is not published as a separate package. +source generator that makes C# 15 **union** error cases ergonomic with `Result` and the value-less +`Result`. It generates the `[GenerateResult]` attribute, the `AsFailure()` and `AsFailure()` +helpers for every union case, and the diagnostics that keep unsupported shapes out of a build. It is not published +as a separate package. ## Installation @@ -64,14 +65,16 @@ 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 TenantNotFound error) => + Result.Failure(error); + + // ... the same pair for TenantDisabled and TenantAlreadyExists } ``` -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`. +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` or `Result`. ## Build properties diff --git a/docs/wiki/Testing.md b/docs/wiki/Testing.md index 2aaca59..b41e408 100644 --- a/docs/wiki/Testing.md +++ b/docs/wiki/Testing.md @@ -23,7 +23,7 @@ category, which is what the filter matches. Use `--treenode-filter` for filterin | Project | Covers | | --- | --- | -| `src/tests/Results.UnitTests` | `Result` states, the throw-on-misuse contract, and the extension operations (`ResultsTests`, `ResultExtensionsTests`) | +| `src/tests/Results.UnitTests` | `Result` and `Result` states, the throw-on-misuse contract, and the extension operations (`ResultsTests`, `ResultTErrorTests`, `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 | diff --git a/docs/wiki/Union-Errors.md b/docs/wiki/Union-Errors.md index 25e73f9..8be5308 100644 --- a/docs/wiki/Union-Errors.md +++ b/docs/wiki/Union-Errors.md @@ -23,8 +23,9 @@ 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: +For each case type the generator emits two overloads in a `{Union}ResultExtensions` static class in the union's +own namespace — a value-producing `AsFailure()` and a non-generic `AsFailure()` that produces a unit +result: ```csharp public static class TenantErrorResultExtensions @@ -32,11 +33,10 @@ 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 TenantNotFound error) => + Result.Failure(error); - public static Result AsFailure(this TenantAlreadyExists error) => - Result.Failure(error); + // ... the same pair for TenantDisabled and TenantAlreadyExists } ``` @@ -45,9 +45,14 @@ Result GetTenant(TenantId tenantId) => _tenants.TryGetValue(tenantId, out var tenant) ? Result.Success(tenant) : new TenantNotFound(tenantId).AsFailure(); + +Result DeleteTenant(TenantId tenantId) => + _tenants.Remove(tenantId) + ? Result.Success() + : new TenantNotFound(tenantId).AsFailure(); ``` -One overload is generated **per case type** rather than one for the union, because C# does not apply union +One overload **per case type** is generated 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 @@ -87,13 +92,51 @@ Returning a bare case value where a result is expected is a compiler error (`CS0 `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 +return new TenantNotFound(id); // CS0029 +return new TenantNotFound(id).AsFailure(); // after the fix, for a value result +return new TenantNotFound(id).AsFailure(); // after the fix, for a unit result +``` + +A fix is offered only when the rewrite will bind: the converted type is `Purview.Results.Result` or +the unit `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. A value result gets `AsFailure()`; a unit result gets the non-generic `AsFailure()`. + +## Chaining across services + +When one service calls another, the caller's error type has to include the callee's. Declare the operation +family's union with the callee's error union as a case, then widen with `Bind`'s `mapError` argument or a guard: + +```csharp +[GenerateResult] +public readonly union BillingError(BillingAccountMissing, BillingServiceUnavailable); + +// The caller's operation-family error: its own cases plus the callee's whole error union as one case. +[GenerateResult] +public readonly union RegisterTenantError(TenantError, BillingError); +``` + +```csharp +// Widening Bind: map a billing failure into the registration contract and chain. +Result Register(TenantId id, string name) => + billing.ReserveQuota(id) + .Bind(quota => CreateTenant(quota, name), error => error); + +// Guard idiom: BillingError is a case of RegisterTenantError, so its generated helper produces the composite +// failure directly. +if (reserved.TryGetError(out var billingError)) + return billingError.AsFailure(); ``` -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. +List the callee's **union** as the case, not its leaf cases. A leaf listed in two unions is a shared case, which +raises `RSG1006` and generates the helper for only one of them, so a call site can bind to the wrong union. If the +caller must not expose the callee's types at all, map the callee's cases into the caller's own case types with +`MapError(error => error switch { ... })` instead — the compiler then forces the mapping to stay exhaustive as the +callee's union grows. + +Nesting also works with the HTTP layer: the ASP.NET Core mapper resolves a union error to its innermost case, so a +mapping for the leaf (`Map`) wins, and a mapping for the enclosing union +(`Map` or `Map`) still applies when the leaf has none. ## Union shapes that are supported diff --git a/docs/wiki/ZodSharp-Integration.md b/docs/wiki/ZodSharp-Integration.md index 71dab41..6b7917a 100644 --- a/docs/wiki/ZodSharp-Integration.md +++ b/docs/wiki/ZodSharp-Integration.md @@ -48,6 +48,21 @@ if (!validated.IsSuccess) return await ReconcileCoreAsync(validated.Value, repositories, cancellationToken); ``` +## Validating without producing a value + +When the method reports only why an input was rejected, `ToUnitResult` discards the validated value and returns a +unit `Result`. It still names the validated value type — a union case does not carry its union — but the +success holds the `Success` marker rather than the value: + +```csharp +Result Validate(TenantInput input) => + TenantInputSchema + .Validate(input) + .ToUnitResult(errors => new TenantInputInvalid(input, errors)); +``` + +Use `ToResult` when the success has to carry the validated value, and `ToUnitResult` when it does not. + ## Carrying validation errors in the error value `IValidationErrorCarrier` is implemented by an error value that carries ZodSharp validation errors, so an HTTP @@ -74,6 +89,7 @@ map every validation-carrying error individually. | 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 | +| `ValidationResult.ToUnitResult(Func, TError> onFailure)` | The value-discarding counterpart: success is a unit `Result`, failure carries the created 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 diff --git a/docs/wiki/index.md b/docs/wiki/index.md index 2972bba..7fa753b 100644 --- a/docs/wiki/index.md +++ b/docs/wiki/index.md @@ -1,7 +1,8 @@ # 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. +Result types for .NET — a dependency-light `Result` and its value-less `Result`, C# 15 +union ergonomics for their 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 } diff --git a/src/src/AspNetCore/DefaultResultsHttpMapper.cs b/src/src/AspNetCore/DefaultResultsHttpMapper.cs index 2fa0472..fc5e606 100644 --- a/src/src/AspNetCore/DefaultResultsHttpMapper.cs +++ b/src/src/AspNetCore/DefaultResultsHttpMapper.cs @@ -11,9 +11,11 @@ namespace Purview.Results.AspNetCore; /// The default . /// /// -/// A successful result is serialized with , a failed result is -/// mapped by the registration matching its error case, and a failure with no registration produces a -/// with . +/// A successful result is serialized with , a successful unit +/// result answers 204 No Content because it carries no payload, a failed result is mapped by the +/// registration matching its most specific case, and a failure with no registration produces a +/// with . For a union error the +/// most specific case is the innermost active case, so a nested union resolves to its leaf. /// public sealed class DefaultResultsHttpMapper( IOptions options, @@ -49,6 +51,10 @@ IResult Success(IResultValue result, HttpContext context) if (_options.SuccessMapper is { } successMapper) return successMapper(result.SuccessValue, context); + // A unit result carries no payload, so it answers 204 No Content rather than serializing the marker. + if (result.SuccessValue is Success) + return TypedResults.NoContent(); + // A result that already carries a response passes it through untouched. if (result.SuccessValue is IResult produced) return produced; @@ -63,27 +69,30 @@ IResult Failure(IResultValue result, HttpContext context) { var error = result.ErrorValue; - // The case of a union error, or the error itself when it is not a union. - var caseValue = ResolveCaseValue(error); + // The failure's cases from the outermost error inward to the leaf: a non-union error is its own case, a + // union contributes its active case, and a nested union case contributes its own active case, so a host + // can map the leaf a nested union ultimately carries. + var cases = ResolveCases(error); + var caseValue = cases[^1]; var caseType = caseValue?.GetType(); - var errorType = error?.GetType(); - - if (caseType is not null && _options.TryGetMapper(caseType, out var caseMapper)) - return caseMapper(caseValue!, context); - // A mapping registered for the error type itself handles every case without its own mapping. - if (errorType is not null && errorType != caseType && _options.TryGetMapper(errorType, out var errorMapper)) - return errorMapper(error!, context); + // The most specific case wins: a mapping for the leaf beats one for an enclosing union, which beats one + // for the error type. A mapping registered for the error type therefore covers every case without its own. + for (var index = cases.Count - 1; index >= 0; index--) + { + if (cases[index] is { } candidate && _options.TryGetMapper(candidate.GetType(), out var mapper)) + return mapper(candidate, context); + } - // Fallbacks see the case, so per-error-type behaviour does not have to unwrap a union itself; the context - // also carries the error, so a shape-based mapper can look at the union as a whole. + // Fallbacks see the leaf case, so per-error-type behaviour does not have to unwrap a union itself; the + // context also carries the error, so a shape-based mapper can look at the union as a whole. ResultsFailureContext failure = new(caseValue, error, context); foreach (var fallback in _options.Fallbacks) if (fallback(failure) is { } mapped) return mapped; - var unmappedType = caseType ?? errorType; + var unmappedType = caseType ?? error?.GetType(); if (_options.ThrowOnUnmappedFailure) { @@ -111,12 +120,21 @@ ProblemHttpResult Problem(string title, string errorType) return TypedResults.Problem(problem); } - static object? ResolveCaseValue(object? error) + static List ResolveCases(object? error) { - if (error is IUnion { Value: { } caseValue }) - return caseValue; + if (error is not IUnion { Value: { } caseValue }) + return [error]; + + List cases = [error]; + + while (caseValue is IUnion { Value: { } nestedCase }) + { + cases.Add(caseValue); + caseValue = nestedCase; + } + + cases.Add(caseValue); - // A non-union error is its own case. - return error; + return cases; } } diff --git a/src/src/AspNetCore/ResultsEndpointFilter.cs b/src/src/AspNetCore/ResultsEndpointFilter.cs index 7c7c186..c119d9c 100644 --- a/src/src/AspNetCore/ResultsEndpointFilter.cs +++ b/src/src/AspNetCore/ResultsEndpointFilter.cs @@ -7,7 +7,8 @@ namespace Purview.Results.AspNetCore; /// /// /// Register it on an endpoint or group with WithResultsHttp(). Handlers can then return -/// Result<TValue, TError> directly; a handler that returns something else is left untouched. +/// Result<TValue, TError> or a unit Result<TError> directly; a handler that returns +/// something else is left untouched. /// public sealed class ResultsEndpointFilter(IResultsHttpMapper mapper) : IEndpointFilter { diff --git a/src/src/AspNetCore/ResultsFailureContext.cs b/src/src/AspNetCore/ResultsFailureContext.cs index 5f3ae52..d412dcc 100644 --- a/src/src/AspNetCore/ResultsFailureContext.cs +++ b/src/src/AspNetCore/ResultsFailureContext.cs @@ -7,16 +7,16 @@ namespace Purview.Results.AspNetCore; /// /// /// -/// is the failure's case value: the active case of a union error, or the error -/// itself when the error is not a union. is the error value the result carries, so a mapper -/// that has to look at the union as a whole can, while one that only cares about the payload does not have to -/// unwrap it. +/// is the failure's case value: the innermost active case of a union error (a nested +/// union resolves to its leaf), or the error itself when the error is not a union. is the error +/// value the result carries, so a mapper that has to look at the union as a whole can, while one that only cares +/// about the payload does not have to unwrap it. /// /// /// A context is only built for a failure; a successful result never reaches a mapper that maps failures. /// /// -/// The union case, or the error itself when the error is not a union. +/// The innermost union case, or the error itself when the error is not a union. /// The error value the result carries. /// The current request. public readonly record struct ResultsFailureContext(object? Case, object? Error, HttpContext HttpContext); diff --git a/src/src/AspNetCore/ResultsHttpOptions.cs b/src/src/AspNetCore/ResultsHttpOptions.cs index 570cc4a..93c0e8d 100644 --- a/src/src/AspNetCore/ResultsHttpOptions.cs +++ b/src/src/AspNetCore/ResultsHttpOptions.cs @@ -7,10 +7,12 @@ namespace Purview.Results.AspNetCore; /// Configures how values are mapped onto ASP.NET Core responses. /// /// -/// Mappings are keyed by the runtime type of the error value: for a union error the declared case type is used, -/// so Map<TenantNotFound>(...) handles that case, while Map<TenantError>(...) handles every -/// case that has no mapping of its own. A non-union error type is keyed by the error type itself. Registering the -/// same type twice replaces the earlier mapping. +/// Mappings are keyed by the runtime type of the error value: for a union error the most specific case wins, so +/// Map<TenantNotFound>(...) handles that case, while Map<TenantError>(...) handles every +/// case that has no mapping of its own. A nested union resolves to its innermost case, and each enclosing union +/// type is tried in turn, so Map<BillingError>(...) can handle a whole nested union while +/// Map<BillingUnavailable>(...) handles one of its leaves. A non-union error type is keyed by the error +/// type itself. Registering the same type twice replaces the earlier mapping. /// /// A failure that no mapping handled reaches the fallback stage, which is the single ordered list /// and append to: the order they are called in @@ -25,6 +27,11 @@ public sealed class ResultsHttpOptions /// /// Gets or sets the status code used when a result succeeded. Defaults to 200 OK. /// + /// + /// This applies to a successful value result. A successful unit carries no + /// payload, so it answers 204 No Content regardless of this value; set to + /// override that. + /// public int SuccessStatusCode { get; set; } = StatusCodes.Status200OK; /// diff --git a/src/src/AspNetCore/Sdk/.agents/skills/purview-results-http-mapping/SKILL.md b/src/src/AspNetCore/Sdk/.agents/skills/purview-results-http-mapping/SKILL.md index af3dd13..ac4629e 100644 --- a/src/src/AspNetCore/Sdk/.agents/skills/purview-results-http-mapping/SKILL.md +++ b/src/src/AspNetCore/Sdk/.agents/skills/purview-results-http-mapping/SKILL.md @@ -5,8 +5,8 @@ description: "Use when an ASP.NET Core endpoint returns Purview.Results values # Mapping results onto ASP.NET Core responses -Use this skill when a minimal-API endpoint, or any handler, returns `Result` and the host has to -decide what each error case looks like on the wire. +Use this skill when a minimal-API endpoint, or any handler, returns `Result` or the value-less +`Result` and the host has to decide what each error case looks like on the wire. ## Registration @@ -38,22 +38,25 @@ app.MapGet("/tenants/{id:int}", (int id) => GetTenant(id)).WithResultsHttp(); 1. `SuccessMapper` when set — it replaces the default entirely. 2. A successful value that is itself an `IResult` is passed through untouched. -3. Otherwise the value is serialized: `200 OK` for the default status code, `TypedResults.Json(value, +3. A successful unit `Result` carries no payload, so it answers `204 No Content`. +4. Otherwise the value is serialized: `200 OK` for the default status code, `TypedResults.Json(value, statusCode: …)` when `SuccessStatusCode` was changed. -**Failure** — the error is resolved to its *case* value (the active union case, or the error itself when it is -not a union), then: +**Failure** — the error is resolved to its *case* value (the innermost active union case, so a nested union +resolves to its leaf; or the error itself when it is not a union), then: -1. the mapping registered for the **case** type — `Map(…)`; -2. the mapping registered for the **error** type — `Map(…)`, which covers every case without its +1. the mapping registered for the most specific **case** type — `Map(…)`; +2. the mapping registered for each enclosing **union** type, from the inside out — `Map(…)` handles + a whole nested union when its leaf has no mapping; +3. the mapping registered for the **error** type — `Map(…)`, which covers every case without its own mapping; -3. the **fallback stage**, in registration order — `AddFallback(...)` delegates and +4. the **fallback stage**, in registration order — `AddFallback(...)` delegates and `AddFailureMapper()` mappers share this one list, so whichever was registered first is consulted first; returning `null` defers to the next entry; -4. the **unmapped-failure** response. +5. the **unmapped-failure** response. -Registering the same type twice replaces the earlier mapping. Fallbacks receive the *case* value, so a fallback -never has to unwrap the union itself. +Registering the same type twice replaces the earlier mapping. Fallbacks receive the *leaf* case value, so a +fallback never has to unwrap the union itself. ## Shape-based rules @@ -73,8 +76,8 @@ builder.Services.AddSingleton(); builder.Services.AddResultsHttp(options => options.AddFailureMapper()); ``` -- `ResultsFailureContext` carries `Case` (the active case, or the error itself for a non-union error), `Error` - (the union or error value as a whole) and `HttpContext`. +- `ResultsFailureContext` carries `Case` (the innermost active case, so a nested union resolves to its leaf; or + the error itself for a non-union error), `Error` (the union or error value as a whole) and `HttpContext`. - The mapper is resolved from the request's services, so it may take dependencies in its constructor; register it before the first request or the failure throws an `InvalidOperationException` naming what is missing. - A mapper shares the fallback list with `AddFallback`, so **registering it earlier makes it win** — that is how diff --git a/src/src/AspNetCore/Sdk/README.md b/src/src/AspNetCore/Sdk/README.md index 025e5ee..64f7017 100644 --- a/src/src/AspNetCore/Sdk/README.md +++ b/src/src/AspNetCore/Sdk/README.md @@ -4,8 +4,8 @@ [![Release](https://github.com/purview-dev/results/actions/workflows/release.yml/badge.svg)](https://github.com/purview-dev/results/actions/workflows/release.yml) Maps [`Purview.Results`](https://www.nuget.org/packages/Purview.Results) values onto ASP.NET Core responses, so -an endpoint can return `Result` and let the host decide what each error case looks like on the -wire. +an endpoint can return `Result` or a value-less `Result` and let the host decide what +each error case looks like on the wire. ## Installation @@ -40,18 +40,22 @@ uninitialized-result paths work without further host setup. ## 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. +value is itself an `IResult` is passed through untouched, and `SuccessMapper` overrides both when set. A +successful unit `Result` carries no payload, so it answers `204 No Content` unless `SuccessMapper` is +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: +**Failure** — the error is resolved to its *case* value (the innermost active case of a union error, so a nested +union resolves to its leaf; 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 +1. a mapping registered for the most specific case type — `Map(...)` +2. a mapping registered for each enclosing union type, from the inside out — `Map(...)` handles a + whole nested union when its leaf has no mapping of its own +3. a mapping registered for the error type — `Map(...)`, which handles every case without its own mapping -3. the fallback stage, in registration order: the `AddFallback(...)` delegates and the +4. the fallback stage, in registration order: the `AddFallback(...)` delegates and the `AddFailureMapper()` mappers share one list, and whatever is registered first is consulted first; a - fallback or mapper returns `null` to defer to the next entry -4. a `ProblemDetails` response using `UnmappedStatusCode` (`500`), `UnmappedTitle`, and an `errorType` + fallback or mapper receives the leaf case and returns `null` to defer to the next entry +5. 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`) is logged and answered with the unmapped-failure response, because an @@ -128,8 +132,11 @@ the defaults entirely. A host that replaces it also bypasses `ResultsHttpOptions [`src/src/Examples.AspNetCore`](https://github.com/purview-dev/results/tree/main/src/src/Examples.AspNetCore) is a runnable minimal-API example that maps the `TenantNotFound` case to `404`, the `TenantDisabled` case to -`403` and the `TenantError` error type to `409`, and shows the response an endpoint that returns `default` -receives. +`403` and the `TenantError` error type to `409`. Its billing endpoint returns a union that nests `TenantError` +and `BillingError`, so it also shows a nested union resolving to its leaf (the billing cases map to `503` and +`402`) while the `TenantError` mapping still covers a nested tenant failure. Its `DELETE` endpoint returns a +unit `Result`, so a success answers `204` while a missing tenant still maps to `404`. It shows the +response an endpoint that returns `default` receives too. ```bash dotnet run --project src/src/Examples.AspNetCore --urls http://localhost:5215 diff --git a/src/src/Examples.AspNetCore/BillingError.cs b/src/src/Examples.AspNetCore/BillingError.cs new file mode 100644 index 0000000..a23f2bb --- /dev/null +++ b/src/src/Examples.AspNetCore/BillingError.cs @@ -0,0 +1,41 @@ +namespace Purview.Results.Examples.AspNetCore; + +/// +/// The expected failures of the billing service. +/// +/// +/// This union belongs to the billing service boundary; the tenant billing lookup widens it into +/// rather than leaking its case types to callers. +/// +[GenerateResult] +readonly union BillingError(BillingAccountMissing, BillingServiceUnavailable); + +/// +/// A billing account does not exist. +/// +/// The tenant whose billing account is missing. +readonly record struct BillingAccountMissing(TenantId TenantId); + +/// +/// The billing service is unavailable. +/// +/// The reason the service is unavailable. +readonly record struct BillingServiceUnavailable(string Reason); + +/// +/// The expected failures of a tenant billing lookup: the tenant error union and the billing error union, each as +/// one case. +/// +/// +/// The tenant union is nested, so a tenant failure resolves to its leaf case in the HTTP layer while a mapping +/// registered for still covers it. +/// +[GenerateResult] +readonly union TenantOperationError(TenantError, BillingError); + +/// +/// A tenant's billing account. +/// +/// The tenant the account belongs to. +/// The account number. +readonly record struct BillingAccount(TenantId TenantId, string AccountNumber); diff --git a/src/src/Examples.AspNetCore/BillingService.cs b/src/src/Examples.AspNetCore/BillingService.cs new file mode 100644 index 0000000..87915d6 --- /dev/null +++ b/src/src/Examples.AspNetCore/BillingService.cs @@ -0,0 +1,20 @@ +namespace Purview.Results.Examples.AspNetCore; + +/// +/// An in-memory billing service, standing in for a downstream service boundary. +/// +sealed class BillingService +{ + /// + /// Gets the billing account for the tenant. + /// + /// The identifier of the tenant. + /// The account, or a describing why it could not be loaded. + public Result GetAccount(TenantId tenantId) => + tenantId.Value switch + { + "acme" => new BillingServiceUnavailable("the billing service is offline").AsFailure(), + "hooli" => new BillingAccountMissing(tenantId).AsFailure(), + _ => Result.Success(new BillingAccount(tenantId, $"acct-{tenantId.Value}")), + }; +} diff --git a/src/src/Examples.AspNetCore/Program.cs b/src/src/Examples.AspNetCore/Program.cs index 60a4388..0bf93a8 100644 --- a/src/src/Examples.AspNetCore/Program.cs +++ b/src/src/Examples.AspNetCore/Program.cs @@ -8,7 +8,20 @@ // curl -i http://localhost:5215/tenants/globex/usage -> 403 Forbidden (case mapping) // curl -i -X POST http://localhost:5215/tenants/newco -> 200 OK // curl -i -X POST http://localhost:5215/tenants/acme -> 409 Conflict (error mapping) -// curl -i http://localhost:5215/tenants/broken -> 500 Internal Server Error +// curl -i http://localhost:5215/tenants/acme/billing -> 503 Service Unavailable (nested union leaf mapping) +// curl -i http://localhost:5215/tenants/hooli/billing -> 402 Payment Required (nested union leaf mapping) +// curl -i http://localhost:5215/tenants/initech/billing -> 404 Not Found (nested tenant error leaf) +// curl -i http://localhost:5215/tenants/globex/billing -> 403 Forbidden (nested tenant error leaf) +// curl -i -X DELETE http://localhost:5215/tenants/hooli -> 204 No Content (unit result success) +// curl -i -X DELETE http://localhost:5215/tenants/initech -> 404 Not Found (unit result failure) +// curl -i http://localhost:5215/tenants/broken -> 500 Internal Server Error +// +// The billing endpoint returns Result, whose error union nests TenantError +// and BillingError. The mapper resolves the innermost case, so the billing leaf mappings win and the mapping +// registered for TenantError still covers a nested tenant failure. +// +// The delete endpoint returns a unit Result: it has no value on success, so the mapper answers 204 +// No Content, while a missing tenant still flows through the same case mapping as every other failure. // // The last one is deliberate: an endpoint returning `default` is a host bug, so it takes the unmapped-failure // path and is logged rather than being coerced into a success or a failure. @@ -32,18 +45,35 @@ .Map(_ => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict, title: "The tenant already exists.") ) + // A mapping for the leaf of a nested union wins, so these handle the billing service's failures while the + // mapping for TenantError above still covers a nested tenant failure. + .Map(error => + TypedResults.Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: error.Reason) + ) + .Map(error => + TypedResults.Problem( + statusCode: StatusCodes.Status402PaymentRequired, + title: $"No billing account for '{error.TenantId.Value}'." + ) + ) ); var app = builder.Build(); InMemoryTenantStore store = new(); +TenantBillingService billing = new(store, new BillingService()); app.MapGet("/tenants/{id}", (string id) => store.GetTenant(new TenantId(id))).WithResultsHttp(); app.MapGet("/tenants/{id}/usage", (string id) => store.GetEnabledTenant(new TenantId(id))).WithResultsHttp(); +app.MapGet("/tenants/{id}/billing", (string id) => billing.GetAccount(new TenantId(id))).WithResultsHttp(); + app.MapPost("/tenants/{id}", (string id) => store.CreateTenant(new TenantId(id), id)).WithResultsHttp(); +// A unit result has no value on success, so the mapper answers 204 No Content; its failure still maps by case. +app.MapDelete("/tenants/{id}", (string id) => store.DeleteTenant(new TenantId(id))).WithResultsHttp(); + app.MapGet("/tenants/broken", () => default(Result)).WithResultsHttp(); app.Run(); diff --git a/src/src/Examples.AspNetCore/TenantBillingService.cs b/src/src/Examples.AspNetCore/TenantBillingService.cs new file mode 100644 index 0000000..7af948a --- /dev/null +++ b/src/src/Examples.AspNetCore/TenantBillingService.cs @@ -0,0 +1,25 @@ +namespace Purview.Results.Examples.AspNetCore; + +/// +/// Looks up a tenant's billing account, widening the tenant error union into the billing operation's contract. +/// +sealed class TenantBillingService(InMemoryTenantStore store, BillingService billing) +{ + /// + /// Gets the billing account for the tenant, failing if the tenant does not exist or is disabled. + /// + /// The identifier of the tenant. + /// The account, or a describing why it could not be loaded. + public Result GetAccount(TenantId tenantId) + { + var tenant = store.GetEnabledTenant(tenantId); + + // TenantError is a case of TenantOperationError, so its generated helper produces the composite failure + // directly, and the mapping registered for TenantError covers it in the HTTP layer. + if (tenant.TryGetError(out var tenantError)) + return tenantError.AsFailure(); + + // MapError names the target union explicitly, because a bare `error => error` would infer BillingError. + return billing.GetAccount(tenant.Value.Id).MapError(error => error); + } +} diff --git a/src/src/Examples.AspNetCore/TenantStore.cs b/src/src/Examples.AspNetCore/TenantStore.cs index 3289c5d..b90bc07 100644 --- a/src/src/Examples.AspNetCore/TenantStore.cs +++ b/src/src/Examples.AspNetCore/TenantStore.cs @@ -13,6 +13,7 @@ sealed class InMemoryTenantStore { [new TenantId("acme")] = new Tenant(new TenantId("acme"), "Acme", Enabled: true), [new TenantId("globex")] = new Tenant(new TenantId("globex"), "Globex", Enabled: false), + [new TenantId("hooli")] = new Tenant(new TenantId("hooli"), "Hooli", Enabled: true), }; /// @@ -51,4 +52,15 @@ public Result CreateTenant(TenantId tenantId, string name) return Result.Success(tenant); } + + /// + /// Deletes a tenant. + /// + /// The identifier of the tenant to delete. + /// + /// A unit that succeeds with no value, or a + /// failure. + /// + public Result DeleteTenant(TenantId tenantId) => + _tenants.Remove(tenantId) ? Result.Success() : new TenantNotFound(tenantId).AsFailure(); } diff --git a/src/src/Examples.Basic/BillingError.cs b/src/src/Examples.Basic/BillingError.cs new file mode 100644 index 0000000..fcc015c --- /dev/null +++ b/src/src/Examples.Basic/BillingError.cs @@ -0,0 +1,42 @@ +namespace Purview.Results.Examples.Basic; + +/// +/// The expected failures of the billing service. +/// +/// +/// This union belongs to the billing service boundary. The registration service widens it into +/// rather than leaking its case types to callers. +/// +[GenerateResult] +readonly union BillingError(BillingAccountMissing, BillingServiceUnavailable); + +/// +/// A billing account does not exist. +/// +/// The tenant whose billing account is missing. +readonly record struct BillingAccountMissing(TenantId TenantId); + +/// +/// The billing service is unavailable. +/// +/// The reason the service is unavailable. +readonly record struct BillingServiceUnavailable(string Reason); + +/// +/// The expected failures of tenant registration: the tenant operation's own error union and the billing +/// service's error union, each as one case. +/// +/// +/// The case types are the two unions rather than their leaf cases, so a billing failure lifts into this +/// contract with one union conversion and no case type is shared with (which would +/// raise RSG1006). +/// +[GenerateResult] +readonly union RegisterTenantError(TenantError, BillingError); + +/// +/// A billing quota reserved for a tenant. +/// +/// The tenant the quota belongs to. +/// The number of seats reserved. +readonly record struct Quota(TenantId TenantId, int Seats); diff --git a/src/src/Examples.Basic/BillingService.cs b/src/src/Examples.Basic/BillingService.cs new file mode 100644 index 0000000..a0225a4 --- /dev/null +++ b/src/src/Examples.Basic/BillingService.cs @@ -0,0 +1,21 @@ +namespace Purview.Results.Examples.Basic; + +/// +/// An in-memory billing service, standing in for a downstream service boundary. +/// +/// +/// It fails with its own union, which the registration service widens into +/// instead of exposing it to callers. +/// +sealed class BillingService +{ + /// + /// Reserves a quota for the tenant. + /// + /// The identifier of the tenant. + /// The reserved quota, or a describing why it could not be reserved. + public Result ReserveQuota(TenantId tenantId) => + tenantId.Value == "globex" + ? new BillingServiceUnavailable("the billing service is offline").AsFailure() + : Result.Success(new Quota(tenantId, Seats: 5)); +} diff --git a/src/src/Examples.Basic/Program.cs b/src/src/Examples.Basic/Program.cs index 9245a9d..7a419db 100644 --- a/src/src/Examples.Basic/Program.cs +++ b/src/src/Examples.Basic/Program.cs @@ -74,7 +74,31 @@ ShowResult("AsFailure()", helper); -Heading("8. Misuse throws: an uninitialized result is a bug, not a state"); +Heading("8. Chain services by widening one error union into another"); + +TenantRegistrationService registration = new(store, new BillingService()); +TenantId hooliId = new("hooli"); + +// RegisterTenantError has TenantError and BillingError as cases, so a failure from either service flows +// through one result type; the widening Bind lifts a billing failure into the registration union. +ShowRegistration("Register(hooli)", registration.Register(hooliId, "Hooli")); +ShowRegistration("Register(globex)", registration.Register(globexId, "Globex")); +ShowRegistration("Register(hooli) again", registration.Register(hooliId, "Hooli")); +ShowRegistration("guard(hooli)", registration.RegisterWithGuard(hooliId, "Hooli")); + +Heading("9. Throw turns an expected failure into an exception at a boundary that must throw"); + +try +{ + store.GetTenant(missingId).Throw(); +} +catch (ResultException exception) +{ + // The exception exposes the error with its original type, so it can be described like any other failure. + Console.WriteLine($" ResultException: {DescribeError(exception.Error)}"); +} + +Heading("10. Misuse throws: an uninitialized result is a bug, not a state"); try { @@ -85,6 +109,15 @@ Console.WriteLine($" InvalidOperationException: {exception.Message}"); } +Heading("11. A unit Result succeeds with no value, so only the failure has to be carried"); + +// A command that either completes or explains why not is a unit result: its success is the Success marker +// rather than a value. DeleteTenant returns Result, and the generated AsFailure() helper (with +// no value type parameter) builds one from a union case. +ShowUnit("DeleteTenant(hooli)", store.DeleteTenant(hooliId)); +ShowUnit("DeleteTenant(hooli) again", store.DeleteTenant(hooliId)); +ShowUnit("Result.Success()", Result.Success()); + static void Heading(string title) { Console.WriteLine(); @@ -103,6 +136,14 @@ static void ShowResult(string label, Result result) Console.WriteLine($" {label, -28} -> {text}"); } +static void ShowUnit(string label, Result result) +{ + // A unit result's success arm takes no value; only the failure arm receives the error. + var text = result.Match(() => "Success", DescribeError); + + Console.WriteLine($" {label, -28} -> {text}"); +} + static string DescribeError(TenantError error) => error switch { @@ -111,3 +152,26 @@ static string DescribeError(TenantError error) => TenantAlreadyExists exists => $"TenantAlreadyExists({exists.TenantId.Value})", _ => nameof(TenantError), }; + +static void ShowRegistration(string label, Result result) +{ + var text = result.Match(value => $"Success({value.Name})", DescribeRegistrationError); + + Console.WriteLine($" {label, -28} -> {text}"); +} + +static string DescribeRegistrationError(RegisterTenantError error) => + error switch + { + TenantError tenantError => DescribeError(tenantError), + BillingError billingError => DescribeBillingError(billingError), + _ => nameof(RegisterTenantError), + }; + +static string DescribeBillingError(BillingError error) => + error switch + { + BillingAccountMissing missing => $"BillingAccountMissing({missing.TenantId.Value})", + BillingServiceUnavailable unavailable => $"BillingServiceUnavailable({unavailable.Reason})", + _ => nameof(BillingError), + }; diff --git a/src/src/Examples.Basic/TenantRegistrationService.cs b/src/src/Examples.Basic/TenantRegistrationService.cs new file mode 100644 index 0000000..fa2fe5e --- /dev/null +++ b/src/src/Examples.Basic/TenantRegistrationService.cs @@ -0,0 +1,47 @@ +namespace Purview.Results.Examples.Basic; + +/// +/// Registers tenants, calling the billing service first and widening its error into the registration contract. +/// +/// +/// The registration error is : a union whose cases are the tenant error union +/// and the billing error union. A billing failure therefore flows through the same result as a tenant failure, +/// and the registration call site never names a billing case. +/// +sealed class TenantRegistrationService(InMemoryTenantStore store, BillingService billing) +{ + /// + /// Registers a tenant, chaining the billing reservation and the store write with the widening Bind. + /// + /// The identifier of the new tenant. + /// The display name of the new tenant. + /// The registered tenant, or a describing why it failed. + public Result Register(TenantId tenantId, string name) => + // The widening Bind maps a billing failure into RegisterTenantError and leaves a success to the store + // write, which maps its own TenantError into the same union. Both `error => error` lambdas rely on the + // implicit union conversion into RegisterTenantError. + billing.ReserveQuota(tenantId).Bind(quota => CreateTenant(quota, name), error => error); + + /// + /// Registers a tenant using the guard idiom: bomb out on a billing failure before touching the store. + /// + /// The identifier of the new tenant. + /// The display name of the new tenant. + /// The registered tenant, or a describing why it failed. + public Result RegisterWithGuard(TenantId tenantId, string name) + { + var reserved = billing.ReserveQuota(tenantId); + + // BillingError is a case of RegisterTenantError, so its generated helper produces the composite + // failure directly. + if (reserved.TryGetError(out var billingError)) + return billingError.AsFailure(); + + // The guard idiom: the method succeeds with a Tenant, so the failure is produced by the helper the generator + return CreateTenant(reserved.Value, name); + } + + Result CreateTenant(Quota quota, string name) => + // MapError names the target union explicitly, because a bare `error => error` would infer TenantError. + store.CreateTenant(quota.TenantId, name).MapError(error => error); +} diff --git a/src/src/Examples.Basic/TenantStore.cs b/src/src/Examples.Basic/TenantStore.cs index 814d1d6..656da9d 100644 --- a/src/src/Examples.Basic/TenantStore.cs +++ b/src/src/Examples.Basic/TenantStore.cs @@ -41,4 +41,15 @@ public Result CreateTenant(TenantId tenantId, string name) return Result.Success(tenant); } + + /// + /// Deletes a tenant. + /// + /// The identifier of the tenant to delete. + /// + /// A unit that succeeds with no value, or a + /// failure. + /// + public Result DeleteTenant(TenantId tenantId) => + _tenants.Remove(tenantId) ? Result.Success() : new TenantNotFound(tenantId).AsFailure(); } diff --git a/src/src/Examples.Zod/Program.cs b/src/src/Examples.Zod/Program.cs index 87ca8bd..7d8ea7c 100644 --- a/src/src/Examples.Zod/Program.cs +++ b/src/src/Examples.Zod/Program.cs @@ -57,6 +57,15 @@ } } +Heading("7. A unit result validates an input without producing a value"); + +// ToUnitResult drops the validated value: the method accepts an input and reports only why it was rejected. +ShowUnit( + "Validate('acme-3', 'Acme Three')", + TenantRegistrationService.Validate(TenantInput.Create("acme-3", "Acme Three")) +); +ShowUnit("Validate('acme-3', null)", TenantRegistrationService.Validate(TenantInput.Create("acme-3", null))); + static void Heading(string title) { Console.WriteLine(); @@ -73,6 +82,14 @@ static void ShowResult(string label, Result result) Console.WriteLine($" {label, -34} -> {text}"); } +static void ShowUnit(string label, Result result) +{ + // A unit result's success arm takes no value; only the failure arm receives the error. + var text = result.Match(() => "Success", DescribeError); + + Console.WriteLine($" {label, -34} -> {text}"); +} + static string DescribeError(TenantError error) => error switch { diff --git a/src/src/Examples.Zod/TenantRegistrationService.cs b/src/src/Examples.Zod/TenantRegistrationService.cs index 67ca2f3..bc081cc 100644 --- a/src/src/Examples.Zod/TenantRegistrationService.cs +++ b/src/src/Examples.Zod/TenantRegistrationService.cs @@ -67,6 +67,24 @@ public Result Find(TenantId tenantId) => ? Result.Success(tenant) : new TenantNotFound(tenantId).AsFailure(); + /// + /// Validates an input without producing a tenant, so the result carries only the failure. + /// + /// The input to validate. + /// + /// A unit result that succeeds with no value, or a describing why the input was + /// rejected. + /// + /// + /// ToUnitResult is the value-discarding counterpart of ToResult: the validated value is + /// deliberately dropped, which is what a command that accepts an input and reports only why it was + /// rejected needs. + /// + public static Result Validate(TenantInput input) => + TenantInputSchema + .Validate(input) + .ToUnitResult(errors => new TenantInputInvalid(input, errors)); + Result RegisterValidated(TenantInput input) { // Validation guarantees both members are present, so the non-null assertion is safe here. diff --git a/src/src/Results/Extensions/Purview/Results/ResultExtensions.cs b/src/src/Results/Extensions/Purview/Results/ResultExtensions.cs index 5eb9bc7..3f4e94c 100644 --- a/src/src/Results/Extensions/Purview/Results/ResultExtensions.cs +++ b/src/src/Results/Extensions/Purview/Results/ResultExtensions.cs @@ -237,6 +237,30 @@ public Result Ensure(Func predicate, Func + /// Discards the successful value, turning this result into a unit result that carries only the error. + /// + /// + /// A successful unit result when this result represents success; otherwise, a failed unit result + /// containing the existing error. + /// + /// + /// Use this at the point where the value is no longer needed but the failure still has to flow on — a + /// validation step whose validated value the caller already holds, for example. It is the inverse of the + /// unit result's Map, which produces a value from a success that had none. + /// + /// The result is uninitialized. + public Result DiscardValue() + { + RequireInitialized(result); + + return result.IsSuccess ? Result.Success() : Result.Failure(result.Error); + } + + #endregion + #region Asynchronous composition /// @@ -283,6 +307,486 @@ public Task> BindAsync(Func + /// Binds the successful value to another operation that can itself succeed or fail, widening the existing + /// error to the bound operation's error type when this result represents failure. + /// + /// The successful value type of the bound operation. + /// The error type of the bound operation and of the returned failure. + /// The function to invoke with the successful value. + /// + /// The function that widens the existing error into . Only invoked for a + /// failure, so a successful result maps nothing. + /// + /// + /// The result returned by when this result represents success; otherwise, a failed + /// result whose error is applied to the existing error. + /// + /// + /// Use this overload when the next step fails with a different error type — typically a union that includes + /// this result's error. It is equivalent to MapError(mapError).Bind(bind) in one call. + /// + /// + /// Thrown when or is null. + /// + /// The result is uninitialized. + public Result Bind( + Func> bind, + Func mapError + ) + { + ArgumentNullException.ThrowIfNull(bind); + ArgumentNullException.ThrowIfNull(mapError); + RequireInitialized(result); + + return result.IsSuccess ? bind(result.Value) : Result.Failure(mapError(result.Error)); + } + + /// + /// Binds the successful value to an asynchronous operation that can itself succeed or fail, widening the + /// existing error to the bound operation's error type when this result represents failure. + /// + /// The successful value type of the bound operation. + /// The error type of the bound operation and of the returned failure. + /// The asynchronous function to invoke with the successful value. + /// + /// The function that widens the existing error into . Only invoked for a + /// failure, so a successful result maps nothing. + /// + /// + /// The result returned by when this result represents success; otherwise, a failed + /// result whose error is applied to the existing error. + /// + /// + /// The asynchronous counterpart of the widening Bind overload. It is equivalent to + /// MapError(mapError).BindAsync(bind) in one call. + /// + /// + /// Thrown when or is null. + /// + /// The result is uninitialized. + public Task> BindAsync( + Func>> bind, + Func mapError + ) + { + ArgumentNullException.ThrowIfNull(bind); + ArgumentNullException.ThrowIfNull(mapError); + RequireInitialized(result); + + return result.IsSuccess + ? bind(result.Value) + : Task.FromResult(Result.Failure(mapError(result.Error))); + } + + #endregion + + #region Throwing + + /// + /// Returns the successful value, or throws a carrying the error when + /// this result represents failure. + /// + /// The successful value. + /// + /// This is the deliberate result-to-exception escape hatch: reach for it at a boundary that must throw, or + /// in a test, rather than to handle an expected failure, which is normally a value. An uninitialized result + /// is a bug, so it is reported with rather than + /// . + /// + /// + /// The result represents failure; the thrown exception exposes the error through + /// . + /// + /// The result is uninitialized. + public TValue Throw() + { + RequireInitialized(result); + + return result.IsSuccess ? result.Value : throw new ResultException(result.Error); + } + + #endregion + } + + extension(Result result) + { + #region Probing + + /// + /// Attempts to read the error without throwing. + /// + /// + /// The error when the result represents failure; otherwise . + /// + /// when the result represents failure; otherwise . + public bool TryGetError([NotNullWhen(true)] out TError error) + { + if (result.IsFailure) + { + error = result.Error; + + return true; + } + + error = default!; + + return false; + } + + #endregion + + #region Fallbacks + + /// + /// Gets the error, or when the result represents success. + /// + /// The error to return when the result represents success. + /// The error, or . + /// The result is uninitialized. + public TError GetErrorOrDefault(TError fallback) + { + RequireInitialized(result); + + return result.IsFailure ? result.Error : fallback; + } + + /// + /// Returns this result when it represents success, or when it represents + /// failure. + /// + /// The result to return when this result represents failure. + /// This result, or . + /// The result is uninitialized. + public Result OrElse(Result fallback) + { + RequireInitialized(result); + + return result.IsSuccess ? result : fallback; + } + + #endregion + + #region Side effects + + /// + /// Invokes the function matching the result's current state and discards its value. + /// + /// Invoked when the result represents success. + /// Invoked with the error when the result represents failure. + /// + /// Thrown when or is null. + /// + /// The result is uninitialized. + public void Switch(Action success, Action failure) + { + ArgumentNullException.ThrowIfNull(success); + ArgumentNullException.ThrowIfNull(failure); + RequireInitialized(result); + + if (result.IsSuccess) + success(); + else + failure(result.Error); + } + + /// + /// Invokes and returns the result unchanged. + /// + /// Invoked when the result represents success. + /// This result. + /// Thrown when is null. + /// The result is uninitialized. + public Result Tap(Action success) + { + ArgumentNullException.ThrowIfNull(success); + RequireInitialized(result); + + if (result.IsSuccess) + success(); + + return result; + } + + /// + /// Invokes with the error and returns the result unchanged. + /// + /// Invoked with the error when the result represents failure. + /// This result. + /// Thrown when is null. + /// The result is uninitialized. + public Result TapError(Action failure) + { + ArgumentNullException.ThrowIfNull(failure); + RequireInitialized(result); + + if (result.IsFailure) + failure(result.Error); + + return result; + } + + #endregion + + #region Constraints + + /// + /// Turns a success that fails into a failure carrying the error produced by + /// , and leaves a failure unchanged. + /// + /// Tests the successful outcome. + /// + /// Creates the error when the predicate fails. Only invoked when the predicate fails, so a satisfied + /// constraint allocates no error. + /// + /// This result, or the failure created by . + /// + /// Thrown when or is null. + /// + /// The result is uninitialized. + public Result Ensure(Func predicate, Func errorFactory) + { + ArgumentNullException.ThrowIfNull(predicate); + ArgumentNullException.ThrowIfNull(errorFactory); + RequireInitialized(result); + + if (result.IsFailure) + return result; + + // The predicate is only invoked for a success, so a satisfied constraint allocates nothing extra. + return predicate() ? result : Result.Failure(errorFactory()); + } + + #endregion + + #region Asynchronous composition + + /// + /// Produces the successful value with an asynchronous function, preserving the error. + /// + /// The type of the produced successful value. + /// The asynchronous function used to produce the successful value. + /// + /// A successful result containing the produced value when this result represents success; otherwise, a + /// failed result containing the existing error. + /// + /// Thrown when is null. + /// The result is uninitialized. + public async Task> MapAsync(Func> map) + { + ArgumentNullException.ThrowIfNull(map); + RequireInitialized(result); + + return result.IsSuccess + ? Result.Success(await map()) + : Result.Failure(result.Error); + } + + /// + /// Binds the successful outcome to an asynchronous unit operation that can itself succeed or fail, while + /// preserving the error type. + /// + /// The asynchronous function to invoke when this result represents success. + /// + /// The result returned by when this result represents success; otherwise, a + /// failed result containing the existing error. + /// + /// Thrown when is null. + /// The result is uninitialized. + public Task> BindAsync(Func>> bind) + { + ArgumentNullException.ThrowIfNull(bind); + RequireInitialized(result); + + return result.IsSuccess ? bind() : Task.FromResult(Result.Failure(result.Error)); + } + + /// + /// Binds the successful outcome to an asynchronous operation that produces a value and can itself succeed + /// or fail, while preserving the error type. + /// + /// The successful value type of the bound operation. + /// The asynchronous function to invoke when this result represents success. + /// + /// The result returned by when this result represents success; otherwise, a + /// failed result containing the existing error. + /// + /// Thrown when is null. + /// The result is uninitialized. + public Task> BindAsync(Func>> bind) + { + ArgumentNullException.ThrowIfNull(bind); + RequireInitialized(result); + + return result.IsSuccess ? bind() : Task.FromResult(Result.Failure(result.Error)); + } + + #endregion + + #region Error-widening composition + + /// + /// Binds the successful outcome to another unit operation that can itself succeed or fail, widening the + /// existing error to the bound operation's error type when this result represents failure. + /// + /// The error type of the bound operation and of the returned failure. + /// The function to invoke when this result represents success. + /// + /// The function that widens the existing error into . Only invoked for a + /// failure, so a successful result maps nothing. + /// + /// + /// The result returned by when this result represents success; otherwise, a + /// failed result whose error is applied to the existing error. + /// + /// + /// Use this overload when the next step fails with a different error type — typically a union that + /// includes this result's error. It is equivalent to MapError(mapError).Bind(bind) in one call. + /// + /// + /// Thrown when or is null. + /// + /// The result is uninitialized. + public Result Bind(Func> bind, Func mapError) + { + ArgumentNullException.ThrowIfNull(bind); + ArgumentNullException.ThrowIfNull(mapError); + RequireInitialized(result); + + return result.IsSuccess ? bind() : Result.Failure(mapError(result.Error)); + } + + /// + /// Binds the successful outcome to another operation that produces a value and can itself succeed or + /// fail, widening the existing error to the bound operation's error type when this result represents + /// failure. + /// + /// The successful value type of the bound operation. + /// The error type of the bound operation and of the returned failure. + /// The function to invoke when this result represents success. + /// + /// The function that widens the existing error into . Only invoked for a + /// failure, so a successful result maps nothing. + /// + /// + /// The result returned by when this result represents success; otherwise, a + /// failed result whose error is applied to the existing error. + /// + /// + /// The value-producing counterpart of the widening unit Bind. It is equivalent to + /// MapError(mapError).Bind(bind) in one call. + /// + /// + /// Thrown when or is null. + /// + /// The result is uninitialized. + public Result Bind( + Func> bind, + Func mapError + ) + { + ArgumentNullException.ThrowIfNull(bind); + ArgumentNullException.ThrowIfNull(mapError); + RequireInitialized(result); + + return result.IsSuccess ? bind() : Result.Failure(mapError(result.Error)); + } + + /// + /// Binds the successful outcome to an asynchronous unit operation that can itself succeed or fail, + /// widening the existing error to the bound operation's error type when this result represents failure. + /// + /// The error type of the bound operation and of the returned failure. + /// The asynchronous function to invoke when this result represents success. + /// + /// The function that widens the existing error into . Only invoked for a + /// failure, so a successful result maps nothing. + /// + /// + /// The result returned by when this result represents success; otherwise, a + /// failed result whose error is applied to the existing error. + /// + /// + /// Thrown when or is null. + /// + /// The result is uninitialized. + public Task> BindAsync( + Func>> bind, + Func mapError + ) + { + ArgumentNullException.ThrowIfNull(bind); + ArgumentNullException.ThrowIfNull(mapError); + RequireInitialized(result); + + return result.IsSuccess ? bind() : Task.FromResult(Result.Failure(mapError(result.Error))); + } + + /// + /// Binds the successful outcome to an asynchronous operation that produces a value and can itself succeed + /// or fail, widening the existing error to the bound operation's error type when this result represents + /// failure. + /// + /// The successful value type of the bound operation. + /// The error type of the bound operation and of the returned failure. + /// The asynchronous function to invoke when this result represents success. + /// + /// The function that widens the existing error into . Only invoked for a + /// failure, so a successful result maps nothing. + /// + /// + /// The result returned by when this result represents success; otherwise, a + /// failed result whose error is applied to the existing error. + /// + /// + /// Thrown when or is null. + /// + /// The result is uninitialized. + public Task> BindAsync( + Func>> bind, + Func mapError + ) + { + ArgumentNullException.ThrowIfNull(bind); + ArgumentNullException.ThrowIfNull(mapError); + RequireInitialized(result); + + return result.IsSuccess + ? bind() + : Task.FromResult(Result.Failure(mapError(result.Error))); + } + + #endregion + + #region Throwing + + /// + /// Returns the marker, or throws a carrying + /// the error when this result represents failure. + /// + /// The marker. + /// + /// This is the deliberate result-to-exception escape hatch: reach for it at a boundary that must throw, or + /// in a test, rather than to handle an expected failure, which is normally a value. An uninitialized result + /// is a bug, so it is reported with rather than + /// . + /// + /// + /// The result represents failure; the thrown exception exposes the error through + /// . + /// + /// The result is uninitialized. + public Success Throw() + { + RequireInitialized(result); + + return result.IsSuccess ? Success.Instance : throw new ResultException(result.Error); + } + + #endregion } /// @@ -297,4 +801,16 @@ static void RequireInitialized(Result result) if (!result.IsInitialized) throw new InvalidOperationException("The result is uninitialized."); } + + /// + /// Throws when was never initialized. + /// + /// + /// The unit-result counterpart of the overload above, with the same message and the same contract. + /// + static void RequireInitialized(Result result) + { + if (!result.IsInitialized) + throw new InvalidOperationException("The result is uninitialized."); + } } diff --git a/src/src/Results/IResultValue.cs b/src/src/Results/IResultValue.cs index 1aef739..57315b7 100644 --- a/src/src/Results/IResultValue.cs +++ b/src/src/Results/IResultValue.cs @@ -8,6 +8,11 @@ namespace Purview.Results; /// example — can inspect a result through this interface instead of using reflection. Accessing /// or never throws: the accessor that does not describe the /// current state returns . +/// +/// A successful value result reports its value; a successful unit reports the +/// marker, which is how infrastructure can tell a unit success from one that carries a +/// payload. +/// /// public interface IResultValue { diff --git a/src/src/Results/Result.cs b/src/src/Results/Result.cs index 849b5ab..9b2acc8 100644 --- a/src/src/Results/Result.cs +++ b/src/src/Results/Result.cs @@ -14,4 +14,19 @@ public static class Result /// Creates a failed result containing the specified error. /// public static Result Failure(TError error) => Result.Failure(error); + + /// + /// Creates a successful unit result. + /// + /// The error type of the created result. + /// A successful . + public static Result Success() => Result.Success(); + + /// + /// Creates a failed unit result containing the specified error. + /// + /// The error type of the created result. + /// The error the result carries. + /// A failed . + public static Result Failure(TError error) => Result.Failure(error); } diff --git a/src/src/Results/ResultException.cs b/src/src/Results/ResultException.cs new file mode 100644 index 0000000..1ce2f59 --- /dev/null +++ b/src/src/Results/ResultException.cs @@ -0,0 +1,83 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Purview.Results; + +/// +/// The exception thrown by Throw() when a result represents a failure. +/// +/// +/// +/// This exception is a deliberate escape hatch: it turns an expected failure — which this library otherwise +/// treats as a value — into an exception at a boundary that must throw. The error the result carried is +/// available through . +/// +/// +/// An uninitialized result (default) is a bug rather than an expected failure, so Throw() reports +/// it with and never constructs this exception for it. +/// +/// +public class ResultException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public ResultException() { } + + /// + /// Initializes a new instance of the class with the supplied message. + /// + /// The message that describes the error. + public ResultException(string? message) + : base(message) { } + + /// + /// Initializes a new instance of the class with the supplied message and inner + /// exception. + /// + /// The message that describes the error. + /// The exception that caused this exception. + public ResultException(string? message, Exception? innerException) + : base(message, innerException) { } + + /// + /// Gets the error the failed result carried, or when this exception was not created + /// from a result error. + /// + public object? Error { get; protected set; } + + /// + /// Creates the message for an error carried by a failed result. + /// + /// The error the result carried. + /// The message that describes the error. + protected static string CreateMessage(object? error) => + error is null ? "The result represents a failure." : $"The result represents a failure: {error}"; +} + +/// +/// The exception thrown by Throw() for a or a unit +/// , exposing the error with its original type. +/// +/// The type of error the result carried. +[SuppressMessage( + "Design", + "CA1032:Implement standard exception constructors", + Justification = "The error-carrying constructor is this type's contract; the standard constructors are provided by the non-generic ResultException base." +)] +public sealed class ResultException : ResultException +{ + /// + /// Initializes a new instance of the class for the supplied error. + /// + /// The error the failed result carried. + public ResultException(TError error) + : base(CreateMessage(error)) + { + base.Error = error; + } + + /// + /// Gets the error the failed result carried, with its original type. + /// + public new TError Error => (TError)base.Error!; +} diff --git a/src/src/Results/ResultTError.cs b/src/src/Results/ResultTError.cs new file mode 100644 index 0000000..439ecc7 --- /dev/null +++ b/src/src/Results/ResultTError.cs @@ -0,0 +1,242 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Purview.Results; + +/// +/// Represents the result of an operation that either succeeds with no value or fails with an error. +/// +/// The type of error produced when the operation fails. +/// +/// +/// This is the value-less counterpart of : it carries the same three states +/// — Uninitialized (the default value), Success and Failure — and the same +/// throw-on-misuse contract, but its success carries no payload. An operation that has nothing to return on +/// success and everything to explain on failure, such as a command or a validation-only step, can therefore be +/// expressed as a result instead of a bool or an exception. +/// +/// +/// A success holds the marker, so Result<TError> composes with the rest of the +/// suite: IResultValue.SuccessValue returns the marker, and the ASP.NET Core mapper answers a successful +/// unit result with 204 No Content. +/// +/// +public readonly record struct Result : IResultValue +{ + readonly ResultState _state = ResultState.Uninitialized; + + Result(Success _) + { + _state = ResultState.Success; + } + + Result(TError error) + { + Error = error; + _state = ResultState.Failure; + } + + /// + /// Gets a value indicating whether the operation succeeded. + /// + public bool IsSuccess => _state == ResultState.Success; + + /// + /// Gets a value indicating whether the operation failed. + /// + [MemberNotNullWhen(true, nameof(Error))] + public bool IsFailure => _state == ResultState.Failure; + + /// + /// Gets a value indicating whether the result has been initialized. + /// + public bool IsInitialized => _state != ResultState.Uninitialized; + + bool IResultValue.IsInitialized => IsInitialized; + + bool IResultValue.IsSuccess => IsSuccess; + + object? IResultValue.SuccessValue => IsSuccess ? global::Purview.Results.Success.Instance : null; + + object? IResultValue.ErrorValue => IsFailure ? Error : null; + + /// + /// Gets the error. + /// + /// + /// The result represents a success. + /// + [AllowNull] + public TError Error + { + get => + IsFailure + ? field + : throw new InvalidOperationException("The error of a non-failed result cannot be accessed."); + } = default!; + + /// + public override string ToString() => + _state switch + { + ResultState.Success => "Success", + ResultState.Failure => $"Failure({Error})", + ResultState.Uninitialized or _ => "Uninitialized", + }; + + /// + /// Matches the result and invokes the appropriate function for its current state. + /// + /// The type of value returned by both match functions. + /// The function to invoke when the result represents success. + /// + /// The function to invoke with the error when the result represents failure. + /// + /// The value returned by the selected match function. + /// + /// The success function takes no argument because a successful unit result carries no value; the + /// marker is not passed to it. + /// + /// + /// Thrown when or is null. + /// + /// + /// The result is uninitialized. + /// + public TResult Match(Func success, Func failure) + { + ArgumentNullException.ThrowIfNull(success); + ArgumentNullException.ThrowIfNull(failure); + + return _state switch + { + ResultState.Success => success(), + ResultState.Failure => failure(Error), + ResultState.Uninitialized or _ => throw CreateUninitializedException(), + }; + } + + /// + /// Maps the successful outcome to a value while preserving the error type. + /// + /// The type of the mapped successful value. + /// The function used to produce the successful value. + /// + /// A successful result containing the produced value when this result represents success; otherwise, a failed + /// result containing the existing error. + /// + /// + /// The mapping function takes no argument because a successful unit result carries no value. + /// + /// Thrown when is null. + /// + /// The result is uninitialized. + /// + public Result Map(Func map) + { + ArgumentNullException.ThrowIfNull(map); + + return _state switch + { + ResultState.Success => Result.Success(map()), + ResultState.Failure => Result.Failure(Error), + ResultState.Uninitialized or _ => throw CreateUninitializedException(), + }; + } + + /// + /// Binds the successful outcome to another unit operation that can itself succeed or fail, while preserving + /// the error type. + /// + /// The function to invoke when this result represents success. + /// + /// The result returned by when this result represents success; otherwise, a failed + /// result containing the existing error. + /// + /// Thrown when is null. + /// The result is uninitialized. + public Result Bind(Func> bind) + { + ArgumentNullException.ThrowIfNull(bind); + + return _state switch + { + ResultState.Success => bind(), + ResultState.Failure => Result.Failure(Error), + ResultState.Uninitialized or _ => throw CreateUninitializedException(), + }; + } + + /// + /// Binds the successful outcome to an operation that produces a value and can itself succeed or fail, while + /// preserving the error type. + /// + /// The successful value type of the bound operation. + /// The function to invoke when this result represents success. + /// + /// The result returned by when this result represents success; otherwise, a failed + /// result containing the existing error. + /// + /// Thrown when is null. + /// The result is uninitialized. + public Result Bind(Func> bind) + { + ArgumentNullException.ThrowIfNull(bind); + + return _state switch + { + ResultState.Success => bind(), + ResultState.Failure => Result.Failure(Error), + ResultState.Uninitialized or _ => throw CreateUninitializedException(), + }; + } + + /// + /// Maps the error to a new error type while preserving the successful outcome. + /// + /// The mapped error type. + /// The function used to transform the error. + /// + /// A failed result containing the mapped error when this result represents failure; otherwise, a successful + /// unit result. + /// + /// Thrown when is null. + /// The result is uninitialized. + public Result MapError(Func map) + { + ArgumentNullException.ThrowIfNull(map); + + return _state switch + { + ResultState.Success => Result.Success(), + ResultState.Failure => Result.Failure(map(Error)), + ResultState.Uninitialized or _ => throw CreateUninitializedException(), + }; + } + + static InvalidOperationException CreateUninitializedException() => new("The result is uninitialized."); + + public static implicit operator Result(Success _) => Success(); + + public static implicit operator Result(TError error) => Failure(error); + + /// + /// Creates a successful unit result. + /// + [SuppressMessage("Design", "CA1000:Do not declare static members on generic types")] + public static Result Success() => new(Results.Success.Instance); + + /// + /// Creates a failed unit result. + /// + [SuppressMessage("Design", "CA1000:Do not declare static members on generic types")] + public static Result Failure(TError error) => new(error); + + enum ResultState : byte + { + Uninitialized, + + Success, + + Failure, + } +} diff --git a/src/src/Results/Sdk/.agents/agents/purview-results-union-author.agent.md b/src/src/Results/Sdk/.agents/agents/purview-results-union-author.agent.md index 71cfad8..c179a45 100644 --- a/src/src/Results/Sdk/.agents/agents/purview-results-union-author.agent.md +++ b/src/src/Results/Sdk/.agents/agents/purview-results-union-author.agent.md @@ -19,8 +19,8 @@ You are a specialist for authoring Purview.Results error unions. ## Primary objective Replace ad-hoc failure signalling (thrown domain exceptions, `bool`/`out` pairs, sentinel return values, -error-code enums) with a `union` of focused case types and a generated `AsFailure()` helper, so -callers match on types instead of parsing strings. +error-code enums) with a `union` of focused case types and the generated `AsFailure()` / `AsFailure()` +helpers, so callers match on types instead of parsing strings. ## Background knowledge @@ -35,8 +35,8 @@ The most important rules are: defeats the point. - **One union per operation family**, so methods can come and go without changing the error contract. - **Never generate or hand-write implicit conversions.** Five compiler rules block them (`CS0715`, `CS0556`, - `CS9282`, `CS0246`, `CS0029`); the generated `AsFailure()` helper — or the `(Union)case` cast — is - the whole toolbox. + `CS9282`, `CS0246`, `CS0029`); the generated `AsFailure()` / `AsFailure()` helpers — or the + `(Union)case` cast — are the whole toolbox. - **Keep the result transport-agnostic.** No HTTP types, status codes or `IResult` values in the union. - **`default` is a bug, not a failure.** Never let a method fall off the end of its body. @@ -45,8 +45,9 @@ The most important rules are: 1. Inventory the failure modes of the operation and name each one as a case type with the payload the caller needs. 2. Declare the union with `[GenerateResult]` in the same namespace as the cases. -3. Change the signature to `Result` and replace throwing exits with - `new Case(...).AsFailure()`. +3. Change the signature to `Result` — or the value-less `Result` when the member has + nothing to return on success — and replace throwing exits with `new Case(...).AsFailure()` or + `new Case(...).AsFailure()`. 4. Leave genuinely exceptional exits (`InvalidOperationException`, infrastructure faults) throwing. 5. Keep the call sites honest: unwrap with `Match`/`Map`/`Bind` rather than `if (IsSuccess)` when a value has to come out. diff --git a/src/src/Results/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md b/src/src/Results/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md index 2e21c12..1ef6999 100644 --- a/src/src/Results/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md +++ b/src/src/Results/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md @@ -1,6 +1,6 @@ --- agent: ask -description: "Migrate ad-hoc failure signalling (domain exceptions, bool/out pairs, error enums) in the selected files to a C# 15 union of case types with Purview.Results and generated AsFailure() helpers." +description: "Migrate ad-hoc failure signalling (domain exceptions, bool/out pairs, error enums) in the selected files to a C# 15 union of case types with Purview.Results and generated AsFailure() and AsFailure() helpers." --- You are modernising error handling in this repository. Apply the `purview-results-union-errors` skill for union @@ -29,8 +29,9 @@ case types and a `Result` return type built with the generated h public readonly union TenantError(TenantNotFound, TenantDisabled, TenantAlreadyExists); ``` -2. Change the affected signatures to `Result` and produce failures with - `new Case(...).AsFailure()`. +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 `new Case(...).AsFailure()` or + `new Case(...).AsFailure()`. 3. Leave genuinely exceptional exits throwing when `keepExceptions` is true — a lost connection or a violated invariant is not a domain outcome. 4. Do **not** attempt implicit conversions from a case to a result, and do not add operators, extension diff --git a/src/src/Results/Sdk/.agents/skills/purview-results-core/SKILL.md b/src/src/Results/Sdk/.agents/skills/purview-results-core/SKILL.md index abe2b4a..1dfc468 100644 --- a/src/src/Results/Sdk/.agents/skills/purview-results-core/SKILL.md +++ b/src/src/Results/Sdk/.agents/skills/purview-results-core/SKILL.md @@ -1,11 +1,12 @@ --- name: purview-results-core -description: "Use when writing or reviewing code that returns Purview.Results Result values — choosing between exceptions and results, creating, reading, or transforming a result, and reasoning about the uninitialized default state." +description: "Use when writing or reviewing code that returns Purview.Results Result or value-less Result values — choosing between exceptions and results, creating, reading, or transforming a result, and reasoning about the uninitialized default state." --- # Purview.Results core -Use this skill whenever a method returns `Result` from the `Purview.Results` package. +Use this skill whenever a method returns `Result` or the value-less `Result` from the +`Purview.Results` package. ## The three states @@ -49,6 +50,7 @@ convert to the result in one hop, which is why the source generator exists (see | `Match(success, failure)` | Fold both states into one value | | `Map(map)` | Transform the successful value, preserving the error | | `Bind(bind)` | Chain an operation that can fail, preserving the error type | +| `Bind(bind, mapError)` | Chain an operation that fails with a different error type, widening the existing error | | `MapError(map)` | Transform the error, preserving the value | | `TryGetValue(out value)` | Probe without throwing: `true` only for a success | | `TryGetError(out error)` | Probe without throwing: `true` only for a failure | @@ -59,6 +61,8 @@ convert to the result in one hop, which is why the source generator exists (see | `Tap(success)` / `TapError(failure)` | Side effects that return the result, so calls can be chained | | `Ensure(predicate, errorFactory)` | Turn a value that fails a predicate into a failure | | `MapAsync(map)` / `BindAsync(bind)` | Asynchronous `Map`/`Bind` | +| `BindAsync(bind, mapError)` | Asynchronous widening `Bind` | +| `Throw()` | Return the value, or throw `ResultException` carrying the error (escape hatch) | Prefer `Match`/`Map`/`Bind` over `if (result.IsSuccess)` when a value has to come out of the result: they keep the failure path explicit and cannot silently leak a `default`. @@ -66,12 +70,43 @@ the failure path explicit and cannot silently leak a `default`. `TryGetValue`/`TryGetError` are the only members that never throw; the rest throw for `default` so a forgotten initialization surfaces immediately. +## Value-less results + +When an operation has nothing to return on success — a command, a validation-only step — use `Result` +instead of inventing a meaningless value (a `bool`, a `Unit`). It has the same three states and the same +throw-on-misuse contract, but a success holds the `Success` marker and its success callbacks take no argument: + +```csharp +Result DeleteTenant(TenantId tenantId) => + _tenants.Remove(tenantId) + ? Result.Success() + : new TenantNotFound(tenantId).AsFailure(); + +deleted.Match(() => "deleted", error => $"could not delete: {error}"); +deleted.Map(() => 1); // attach a value: Result +deleted.Bind(() => GetTenant(tenantId)); // continue with another operation +deleted.Ensure(() => _tenants.Count > 0, () => new TenantError()); +``` + +- `Result.Success()` / `.Failure(error)`, `Result.Success()` / `Result.Failure(error)`, + and the implicit conversions from `Success` and `TError` create a unit result. +- `Match`, `Switch`, `Tap` and `Ensure` take a **no-argument** success delegate; `Map` produces a value from a + success that had none; `Bind` continues with either another unit operation or a value-producing one. +- `Error` throws in the wrong state; `Throw()` returns the `Success` marker or throws `ResultException`; + `ToString()` is `Success` / `Failure(error)` / `Uninitialized`. +- `Result.DiscardValue()` drops a value result's value and keeps the error, so a value result can + join a unit-result chain. + +Use `Result` when the failure is the only interesting outcome; use `Result` when the +caller needs the produced value. + ## Infrastructural view `IResultValue` is the non-generic, read-only view (`IsInitialized`, `IsSuccess`, `SuccessValue`, `ErrorValue`) for code that cannot be generic over the value and error types — an endpoint filter or a logging middleware, for -example. Its accessors never throw: the one that does not describe the current state returns `null`. Use it in -infrastructure, and the generic type in domain code. +example. Its accessors never throw: the one that does not describe the current state returns `null`, and a +successful unit `Result` reports the `Success` marker as its `SuccessValue`. Use it in infrastructure, and +the generic type in domain code. ## When to use a result @@ -86,8 +121,8 @@ Rules of thumb: 1. If the caller is expected to *do* something different per outcome, the outcome is a result value. 2. If the caller can only log and rethrow, it is an exception. -3. Never use `Result` where the failure is the only interesting outcome — the boolean usually - carries no information; model the outcomes as cases instead. +3. Never use `Result` where the failure is the only interesting outcome — use the value-less + `Result` instead, or model the outcomes as cases. 4. Convert at the boundaries: a controller or endpoint handler turns the result into a response, an application service returns it, and repositories return it rather than throwing on "not found". diff --git a/src/src/Results/Sdk/.agents/skills/purview-results-union-errors/SKILL.md b/src/src/Results/Sdk/.agents/skills/purview-results-union-errors/SKILL.md index f144f5f..525dc37 100644 --- a/src/src/Results/Sdk/.agents/skills/purview-results-union-errors/SKILL.md +++ b/src/src/Results/Sdk/.agents/skills/purview-results-union-errors/SKILL.md @@ -1,12 +1,12 @@ --- name: purview-results-union-errors -description: "Use when modelling expected failures as a C# 15 union and converting its cases into Purview.Results values — declaring [GenerateResult] unions, using the generated AsFailure() helpers, reacting to the RSG1000-RSG1007 diagnostics, or explaining why a union case cannot convert implicitly." +description: "Use when modelling expected failures as a C# 15 union and converting its cases into Purview.Results values — declaring [GenerateResult] unions, using the generated AsFailure() and AsFailure() helpers, reacting to the RSG1000-RSG1007 diagnostics, or explaining why a union case cannot convert implicitly." --- # Modelling error unions for Purview.Results Use this skill when a codebase models expected failures as a **union** and returns -`Result` from the `Purview.Results` package. +`Result` or the value-less `Result` from the `Purview.Results` package. ## The shape @@ -28,8 +28,15 @@ public static Result Get(TenantId id) => : new TenantNotFound(id).AsFailure(); // generated ``` -`AsFailure()` is generated **once per case type**, into `public static class {Union}ResultExtensions` in -the union's own namespace, and it calls `Result.Failure(case)`. +`AsFailure()` and the non-generic `AsFailure()` are generated **once per case type**, into +`public static class {Union}ResultExtensions` in the union's own namespace. The generic helper calls +`Result.Failure(case)`; the non-generic one calls `Result.Failure(case)` and produces a +value-less unit result: + +```csharp +Result Get(TenantId id) => new TenantNotFound(id).AsFailure(); +Result Delete(TenantId id) => new TenantNotFound(id).AsFailure(); +``` ## Why the helper is required @@ -54,10 +61,31 @@ Result Get(TenantId id) => (TenantError)new TenantNotFound( ``` `Purview.Results` also ships an IDE code fix: when you write -`return new TenantNotFound(id);` in a `Result`-returning member, the lightbulb offers the -`AsFailure()` rewrite. The fix is only offered when the rewrite will bind (the converted type is a -result, the error type is a `[GenerateResult]` union, the value is one of its cases, and the union is in scope -by simple name). +`return new TenantNotFound(id);` in a `Result`- or `Result`-returning member, the +lightbulb offers the `AsFailure()` or `AsFailure()` rewrite. The fix is only offered when the rewrite +will bind (the converted type is a result, the error type is a `[GenerateResult]` union, the value is one of its +cases, and the union is in scope by simple name). + +## Chaining across services + +When one service calls another, the caller's operation-family union takes the callee's **error union** as a case, +then the widening `Bind` (or a `TryGetError` guard) lifts the callee's failure into the caller's contract: + +```csharp +[GenerateResult] +public readonly union RegisterTenantError(TenantError, BillingError); + +Result Register(TenantId id, string name) => + billing.ReserveQuota(id) + .Bind(quota => CreateTenant(quota, name), error => error); // BillingError -> RegisterTenantError +``` + +List the union as a case, not its leaf cases: a leaf shared with the callee's union raises `RSG1006` and its helper +is generated for only one of the unions, so a call site can bind to the wrong union. To keep the callee's types out +of the caller's contract, map its cases into the caller's own case types with `MapError` instead — the compiler +then forces the mapping to stay exhaustive as the callee's union grows. The ASP.NET Core mapper resolves a union +error to its innermost case, so a leaf mapping wins and an enclosing-union mapping still applies when the leaf has +none. ## Rules of thumb @@ -108,7 +136,8 @@ compared by value. ## Requirements and switches - **.NET 11 SDK or later** with `LangVersion=preview` — union declarations are a preview language feature. -- A reference to `Purview.Results`, which provides both the result type and this generator. +- A reference to `Purview.Results`, which provides both result shapes (`Result` and + `Result`) and this generator. - `-p:DisableResultsSourceGenerator=true` (or a `Directory.Build.props` setting) disables the helpers while the opt-in attribute is still emitted, which is useful when the generated code has to be inspected in isolation. @@ -117,7 +146,9 @@ compared by value. 1. Cases are small records with meaningful payloads. 2. The union is a `union` declaration annotated with `[GenerateResult]`. -3. Every method that fails for an expected reason returns `Result`. -4. Failures are produced with `new Case(...).AsFailure()` (or the cast form). +3. Every method that fails for an expected reason returns `Result`, or `Result` when the + success carries no value. +4. Failures are produced with `new Case(...).AsFailure()` (or `AsFailure()` for a unit result, or the cast + form). 5. The build is clean of `RSG1000`–`RSG1007`. 6. The union is mapped to responses once, in the host (see `purview-results-http-mapping`). diff --git a/src/src/Results/Sdk/README.md b/src/src/Results/Sdk/README.md index 1d55d1f..4826bdf 100644 --- a/src/src/Results/Sdk/README.md +++ b/src/src/Results/Sdk/README.md @@ -5,7 +5,7 @@ A small, dependency-light `Result` for .NET that lets **expected** failures flow through a value instead of an exception. Exceptional circumstances still throw; states you expect — not found, invalid, -conflict — are values. +conflict — are values. A value-less `Result` covers operations that have nothing to return on success. ## Installation @@ -54,7 +54,9 @@ Result failed = "not a number"; | `Match(success, failure)` | Fold both states into one value | | `Map(map)` | Transform the successful value, preserving the error | | `Bind(bind)` | Chain another operation that can fail, preserving the error type | +| `Bind(bind, mapError)` | Chain another operation that fails with a different error type, widening the existing error | | `MapError(map)` | Transform the error, preserving the value | +| `Throw()` | Return the successful value, or throw `ResultException` carrying the error | | `ToString()` | `Success(value)`, `Failure(error)` or `Uninitialized` | `Result` is a `readonly record struct`. `default` is *uninitialized*: `IsInitialized`, @@ -66,14 +68,57 @@ Result failed = "not a number"; endpoint filter, for example. Its accessors never throw: the one that does not describe the current state returns `null`. +`Throw()` is the deliberate escape hatch: it returns the successful value and throws a `ResultException` +carrying the error on failure, so a boundary that must throw does not have to branch. The exception exposes the +error with its original type through `Error`; a non-generic `ResultException` base carries it as `object?` for a +catch-all. An uninitialized result is still a bug, so `Throw()` reports `default` with +`InvalidOperationException`. + +## Unit results + +An operation that has nothing to return on success — a command, a validation-only step — uses the value-less +`Result`. It has the same three states and the same throw-on-misuse contract, but its success holds the +`Success` marker rather than a value, so its success callbacks take no argument: + +```csharp +Result DeleteTenant(TenantId tenantId) => + _tenants.Remove(tenantId) + ? Result.Success() + : new TenantNotFound(tenantId).AsFailure(); + +var deleted = DeleteTenant(tenantId); + +deleted.Match(() => "deleted", error => $"could not delete: {error}"); // success takes no value +deleted.Map(() => 1); // attach a value to a success +deleted.Bind(() => GetTenant(tenantId)); // continue with another operation +deleted.Ensure(() => _tenants.Count > 0, () => new TenantError()); // turn a success into a failure +``` + +| Member | Purpose | +| --- | --- | +| `Result.Success()` / `.Failure(error)` | Create a unit result | +| `Result.Success()` / `Result.Failure(error)` | Create a unit result without naming the error twice | +| `IsInitialized` / `IsSuccess` / `IsFailure` | The three states, shared with `Result` | +| `Error` | The error; throws `InvalidOperationException` in the other state | +| `Match(success, failure)` / `Map(map)` / `Bind(bind)` / `MapError(map)` | Fold or transform; the success callbacks take no value | +| `Throw()` | Return the `Success` marker, or throw `ResultException` carrying the error | +| `ToString()` | `Success`, `Failure(error)` or `Uninitialized` | +| `Result.DiscardValue()` | Turn a value result into a unit result, preserving the error | +| `Result.MapAsync` / `.BindAsync` / widening `Bind` / `Ensure` / `Tap` / `Switch` / `OrElse` | The unit-result counterparts of the value-result extensions, in `ResultExtensions` | + +The implicit conversions from `Success` and from `TError` are public contract, so a method returning +`Result` can `return` either the marker or a plain error. + ## Union error types A C# 15 union is a natural `TError`: the error cases stay strongly typed. Because C# never composes the union conversion with the result's own conversion, a case value cannot be returned directly — the source generator -bundled in this package generates a per-case `AsFailure()` helper for `[GenerateResult]` unions: +bundled in this package generates a per-case `AsFailure()` helper for `[GenerateResult]` unions, plus a +non-generic `AsFailure()` that produces a unit result: ```csharp Result GetTenant(TenantId tenantId) => new TenantNotFound(tenantId).AsFailure(); +Result DeleteTenant(TenantId tenantId) => new TenantNotFound(tenantId).AsFailure(); ``` The generator cannot declare that conversion for you: C# forbids user-defined operators in a static class, @@ -88,11 +133,16 @@ case → union conversion so only the library's union → result conversion rema Result GetTenant(TenantId tenantId) => (TenantError)new TenantNotFound(tenantId); ``` +When one service calls another, widen the callee's error union into the caller's operation-family union with the +widening `Bind`, or lift it at a guard with the generated helper. List the callee's union as a case rather than its +leaf cases, so no case type is shared between the two unions. + ## Examples [`src/src/Examples.Basic`](https://github.com/purview-dev/results/tree/main/src/src/Examples.Basic) is a runnable console example over the Tenant* domain this README documents: the three result states, the combinators, -probing, the throw-on-misuse contract and the generated `AsFailure()` helper. +probing, the throw-on-misuse contract, the generated `AsFailure()` helper, chaining two services by +widening one error union into another, and `Throw()`. ```bash dotnet run --project src/src/Examples.Basic diff --git a/src/src/Results/Success.cs b/src/src/Results/Success.cs new file mode 100644 index 0000000..7df4d93 --- /dev/null +++ b/src/src/Results/Success.cs @@ -0,0 +1,26 @@ +namespace Purview.Results; + +/// +/// Represents the successful outcome of an operation that produces no value. +/// +/// +/// +/// is a result whose success carries no payload; it succeeds with this marker +/// instead of a value, so an operation that only has a meaningful failure — a command that either completes or +/// reports why it could not — can still be expressed as a result. +/// +/// +/// The type has no state: every value equals and default, +/// and its is the stable text "Success". +/// +/// +public readonly record struct Success +{ + /// + /// Gets the single value. + /// + public static Success Instance => default; + + /// + public override string ToString() => "Success"; +} diff --git a/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs b/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs index 375b80a..65d770a 100644 --- a/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs +++ b/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs @@ -15,8 +15,9 @@ namespace Purview.Results.SourceGenerator.CodeFixes; /// -/// Offers the generated AsFailure<TValue>() helper when a union case value is returned where a -/// Result<TValue, TError> is expected (CS0029). +/// Offers the generated AsFailure<TValue>() and AsFailure() helpers when a union case value +/// is returned where a Result<TValue, TError> or a unit Result<TError> is expected +/// (CS0029). /// /// /// @@ -28,10 +29,11 @@ namespace Purview.Results.SourceGenerator.CodeFixes; /// /// /// A fix is only offered when the rewritten call will actually bind: the converted type must be -/// Purview.Results.Result<TValue, TError>, TError must be a union opted in with -/// [GenerateResult] (so the helper exists), the expression must be one of that union's case values, and -/// the union must be reachable by its simple name at the call site (the helper is generated into the union's -/// own namespace). +/// Purview.Results.Result<TValue, TError> or Purview.Results.Result<TError>, +/// TError must be a union opted in with [GenerateResult] (so the helper exists), the expression +/// must be one of that union's case values, and the union must be reachable by its simple name at the call site +/// (the helper is generated into the union's own namespace). A value result gets +/// AsFailure<TValue>(); a unit result gets the non-generic AsFailure(). /// /// [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(UnionCaseResultCodeFixProvider)), Shared] @@ -40,6 +42,7 @@ public sealed class UnionCaseResultCodeFixProvider : CodeFixProvider const string EquivalenceKey = "PurviewResultsUseAsFailure"; const string ResultNamespace = "Purview.Results"; const string ResultMetadataName = "Result`2"; + const string ResultUnitMetadataName = "Result`1"; const string GenerateResultAttributeMetadataName = "GenerateResultAttribute"; const string FailureHelperName = "AsFailure"; @@ -65,8 +68,8 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) if (expression is null) continue; - var (valueType, unionType) = ResolveResultTarget(model, expression, cancellationToken); - if (valueType is null || unionType is null) + var (valueType, unionType, isUnit) = ResolveResultTarget(model, expression, cancellationToken); + if (unionType is null || (!isUnit && valueType is null)) continue; // The expression must be a case of the union, otherwise AsFailure() would not exist for @@ -79,10 +82,15 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) if (!IsUnionVisibleBySimpleName(model, unionType, expression.SpanStart)) continue; + var title = isUnit + ? $"Convert the union case to a failed unit result with {FailureHelperName}()" + : $"Convert the union case to a failed result with {FailureHelperName}<{valueType!.Name}>()"; + context.RegisterCodeFix( CodeAction.Create( - title: $"Convert the union case to a failed result with {FailureHelperName}<{valueType.Name}>()", - createChangedDocument: token => UseAsFailureAsync(context.Document, expression, valueType, token), + title: title, + createChangedDocument: token => + UseAsFailureAsync(context.Document, expression, isUnit ? null : valueType, token), equivalenceKey: EquivalenceKey ), diagnostic @@ -97,26 +105,30 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) return node as ExpressionSyntax ?? node.FirstAncestorOrSelf(); } - static (ITypeSymbol? ValueType, INamedTypeSymbol? UnionType) ResolveResultTarget( + static (ITypeSymbol? ValueType, INamedTypeSymbol? UnionType, bool IsUnit) ResolveResultTarget( SemanticModel model, ExpressionSyntax expression, CancellationToken cancellationToken ) { if (model.GetTypeInfo(expression, cancellationToken).ConvertedType is not INamedTypeSymbol converted) - return (null, null); + return (null, null, false); + + var isValueResult = converted.Arity == 2 && HasMetadataName(converted, ResultNamespace, ResultMetadataName); + var isUnitResult = converted.Arity == 1 && HasMetadataName(converted, ResultNamespace, ResultUnitMetadataName); - if (converted.Arity != 2 || !HasMetadataName(converted, ResultNamespace, ResultMetadataName)) - return (null, null); + if (!isValueResult && !isUnitResult) + return (null, null, false); + // The union is the last type argument in both shapes: Result and Result. if ( - converted.TypeArguments[1] is not INamedTypeSymbol union + converted.TypeArguments[converted.Arity - 1] is not INamedTypeSymbol union || !union.IsUnion || !HasGenerateResultAttribute(union) ) - return (null, null); + return (null, null, false); - return (converted.TypeArguments[0], union); + return isUnitResult ? (null, union, true) : (converted.TypeArguments[0], union, false); } static bool HasGenerateResultAttribute(INamedTypeSymbol union) @@ -176,7 +188,7 @@ static bool HasMetadataName(INamedTypeSymbol type, string @namespace, string met static async Task UseAsFailureAsync( Document document, ExpressionSyntax expression, - ITypeSymbol valueType, + ITypeSymbol? valueType, CancellationToken cancellationToken ) { @@ -184,19 +196,30 @@ CancellationToken cancellationToken if (root is null) return document; - var typeArgument = SyntaxFactory - .ParseTypeName(valueType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)) - .WithAdditionalAnnotations(Simplifier.Annotation); + // A unit result uses the non-generic AsFailure(); a value result names its value type. + SimpleNameSyntax helperName; + if (valueType is null) + { + helperName = SyntaxFactory.IdentifierName(FailureHelperName); + } + else + { + var typeArgument = SyntaxFactory + .ParseTypeName(valueType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)) + .WithAdditionalAnnotations(Simplifier.Annotation); + + helperName = SyntaxFactory.GenericName( + SyntaxFactory.Identifier(FailureHelperName), + SyntaxFactory.TypeArgumentList(SyntaxFactory.SingletonSeparatedList(typeArgument)) + ); + } var invocation = SyntaxFactory .InvocationExpression( SyntaxFactory.MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, expression.WithoutTrivia(), - SyntaxFactory.GenericName( - SyntaxFactory.Identifier(FailureHelperName), - SyntaxFactory.TypeArgumentList(SyntaxFactory.SingletonSeparatedList(typeArgument)) - ) + helperName ) ) .WithTriviaFrom(expression) diff --git a/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs b/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs index ae19e8b..d364ce7 100644 --- a/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs +++ b/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs @@ -46,7 +46,7 @@ static void EmitUnion(SourceProductionContext context, GeneratorContext generati writer.FileScopedNamespace(union.Namespace); writer.XmlSummary( - $"Provides strongly typed failure helpers that create {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultTypeName))} values from the union cases of {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}." + $"Provides strongly typed failure helpers that create {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultTypeName))} and {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultUnitTypeName))} values from the union cases of {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}." ); writer.Class( @@ -64,6 +64,12 @@ static void EmitUnion(SourceProductionContext context, GeneratorContext generati } static void EmitHelper(CodeWriter writer, ResultUnionModel union, ResultUnionCaseModel unionCase) + { + EmitValueHelper(writer, union, unionCase); + EmitUnitHelper(writer, union, unionCase); + } + + static void EmitValueHelper(CodeWriter writer, ResultUnionModel union, ResultUnionCaseModel unionCase) { var resultType = CreateResultTypeReference(union); var resultTypeName = resultType.RenderFullName; @@ -104,6 +110,42 @@ options with ); } + static void EmitUnitHelper(CodeWriter writer, ResultUnionModel union, ResultUnionCaseModel unionCase) + { + var resultType = CreateUnitResultTypeReference(union); + var resultTypeName = resultType.RenderFullName; + + writer.XmlSummary( + $"Creates a failed unit result containing the {XmlInlineCode(DocText.Escape(unionCase.FullyQualifiedName))} union case of {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}." + ); + writer.XmlParam( + PropertyLibrary.ErrorParameterName, + $"The {XmlInlineCode(DocText.Escape(unionCase.FullyQualifiedName))} union case to convert." + ); + writer.XmlReturn( + $"A failed {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultUnitTypeName))} whose error is {XmlParamRef(PropertyLibrary.ErrorParameterName)}." + ); + + writer.Method( + PropertyLibrary.FailureHelperName, + resultType, + union.Accessibility, + options => + options with + { + IsStatic = true, + Parameters = + [ + new ParameterDeclarationOptions(PropertyLibrary.ErrorParameterName, unionCase.CaseType) + { + IsThis = true, + }, + ], + }, + body => body.Return($"{resultTypeName}.Failure({PropertyLibrary.ErrorParameterName})") + ); + } + /// /// Creates the Result<TValue, TUnion> reference for a union, using the framework's /// structured type-reference abstraction so generic arguments, nesting and namespace qualification are @@ -123,4 +165,12 @@ static TypeReference CreateResultTypeReference(ResultUnionModel union) => union.UnionType ) ); + + /// + /// Creates the Result<TUnion> unit result reference for a union. + /// + /// The union whose cases the helper converts. + /// The structured reference to the unit result type. + static TypeReference CreateUnitResultTypeReference(ResultUnionModel union) => + new(ResultsTypeLibrary.ResultUnit.MakeGeneric(union.UnionType)); } diff --git a/src/src/SourceGenerator/Helpers/PropertyLibrary.cs b/src/src/SourceGenerator/Helpers/PropertyLibrary.cs index 309c822..670aca7 100644 --- a/src/src/SourceGenerator/Helpers/PropertyLibrary.cs +++ b/src/src/SourceGenerator/Helpers/PropertyLibrary.cs @@ -52,4 +52,11 @@ static class PropertyLibrary /// before it is written into documentation text. /// public const string ResultTypeName = ResultsNamespace + ".Result"; + + /// + /// The fully-qualified name of the runtime Result<TError> unit result type, used when the + /// generated source is described in XML documentation. The value is raw, so callers escape it with + /// before it is written into documentation text. + /// + public const string ResultUnitTypeName = ResultsNamespace + ".Result"; } diff --git a/src/src/SourceGenerator/Helpers/ResultsTypeLibrary.cs b/src/src/SourceGenerator/Helpers/ResultsTypeLibrary.cs index d5a99a3..c1ffbce 100644 --- a/src/src/SourceGenerator/Helpers/ResultsTypeLibrary.cs +++ b/src/src/SourceGenerator/Helpers/ResultsTypeLibrary.cs @@ -33,6 +33,16 @@ static class ResultsTypeLibrary arity: 2 ); + /// + /// The runtime Result<TError> unit result type. Callers apply the generic argument for a + /// specific union. + /// + public static readonly TypeIdentity ResultUnit = new( + typeName: "Result", + @namespace: PropertyLibrary.ResultsNamespace, + arity: 1 + ); + /// /// The language-specific System.Runtime.CompilerServices.IUnion interface implemented by /// declared unions. diff --git a/src/src/SourceGenerator/README.md b/src/src/SourceGenerator/README.md index 4f7174b..a5965ee 100644 --- a/src/src/SourceGenerator/README.md +++ b/src/src/SourceGenerator/README.md @@ -4,9 +4,9 @@ [![Release](https://github.com/purview-dev/results/actions/workflows/release.yml/badge.svg)](https://github.com/purview-dev/results/actions/workflows/release.yml) An incremental Roslyn source generator that makes C# 15 **union** error cases ergonomic to use with -`Purview.Results.Result`. It ships inside the -[`Purview.Results`](https://www.nuget.org/packages/Purview.Results) package; it is not published as a separate -package. +`Purview.Results.Result` and its value-less counterpart `Purview.Results.Result`. It ships +inside the [`Purview.Results`](https://www.nuget.org/packages/Purview.Results) package; it is not published as a +separate package. ## Installation @@ -31,7 +31,8 @@ public readonly record struct TenantAlreadyExists(TenantId TenantId); ``` For a union opted in with `[GenerateResult]` the generator emits one strongly typed extension class in the -union's namespace: +union's namespace, with two helpers per case — a value-producing `AsFailure()` and a non-generic +`AsFailure()` that produces a unit result: ```csharp namespace Test.Tenancy; @@ -41,11 +42,10 @@ 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 TenantNotFound error) => + Result.Failure(error); - public static Result AsFailure(this TenantAlreadyExists error) => - Result.Failure(error); + // ... one pair per case: TenantDisabled, TenantAlreadyExists } ``` @@ -53,6 +53,7 @@ Usage: ```csharp Result result = new TenantNotFound(tenantId).AsFailure(); +Result unitResult = new TenantNotFound(tenantId).AsFailure(); ``` ## Why the generator exists @@ -69,9 +70,9 @@ in `src/tests/SourceGenerator.UnitTests/UnionCompilerBehaviourTests.cs`. The sam extension method declared on the *union* type cannot help either (`CS1929`): C# does not apply union conversions to an extension-method receiver, which is why one helper is generated **per case type**. -The generator does not change, wrap or replace `Result`; it only produces the missing call -site ergonomics. There is no reflection, no `dynamic`, no runtime type discovery and no mutable static state: -every helper is a pure static method that calls the existing `Result.Failure` factory. +The generator does not change, wrap or replace `Result` or `Result`; it only produces +the missing call site ergonomics. There is no reflection, no `dynamic`, no runtime type discovery and no mutable +static state: every helper is a pure static method that calls the existing `Result<...>.Failure` factory. ## Why there are no implicit conversions @@ -207,25 +208,27 @@ consumers too. ## Consumer requirements - A C# 15 compiler with union declaration support (`.NET 11` SDK or later) and `LangVersion=preview`. -- A reference to `Purview.Results`, which provides both `Result` and this generator. +- A reference to `Purview.Results`, which provides both `Result` and the unit `Result`, + and this generator. ## Call-site ergonomics: the code fix -Because the generator cannot make a case value convert (`AsFailure()` is the best the language allows), -the package also ships an IDE code fix: `UnionCaseResultCodeFixProvider` in the companion +Because the generator cannot make a case value convert (`AsFailure()` and `AsFailure()` are the best the +language allows), the package also ships an IDE code fix: `UnionCaseResultCodeFixProvider` in the companion `SourceGenerator.CodeFixes` component (`src/src/SourceGenerator.CodeFixes`). It answers the compiler's `CS0029` ("cannot implicitly convert") and rewrites a returned case value into the generated helper, so `return new TenantNotFound(id);` becomes -`return new TenantNotFound(id).AsFailure();` from the lightbulb. +`return new TenantNotFound(id).AsFailure();` from the lightbulb, or `...AsFailure();` when the method +returns a unit `Result`. A fix is only offered when the rewrite will bind: | Guard | Why | | --- | --- | -| The converted type is `Purview.Results.Result` | Only results have generated helpers | +| The converted type is `Purview.Results.Result` or the unit `Purview.Results.Result` | Only results have generated helpers; a value result gets `AsFailure()` and a unit result the non-generic `AsFailure()` | | `TError` is a union **and** opted in with `[GenerateResult]` | The helper is only generated for opted-in unions | -| The expression's type is one of `TError`'s case types | `AsFailure()` exists once per case | +| The expression's type is one of `TError`'s case types | The helper exists once per case | | The union is reachable by its simple name at the call site | The helper class is generated into the union's own namespace | The component is not packable on its own: it needs `Microsoft.CodeAnalysis.CSharp.Workspaces`, which only the @@ -255,11 +258,12 @@ rewritten source to prove it compiles. ## Examples [`src/src/Examples.Basic`](https://github.com/purview-dev/results/tree/main/src/src/Examples.Basic) -declares a `[GenerateResult]` union over the Tenant* domain the README uses and calls the helper this generator +declares a `[GenerateResult]` union over the Tenant* domain the README uses and calls the helpers this generator emits for each of its cases: ```csharp Result result = new TenantNotFound(tenantId).AsFailure(); +Result unitResult = new TenantNotFound(tenantId).AsFailure(); ``` The [repository README](https://github.com/purview-dev/results#examples) covers the ZodSharp, ASP.NET Core and diff --git a/src/src/ZodSharp/Sdk/.agents/skills/purview-results-zodsharp-validation/SKILL.md b/src/src/ZodSharp/Sdk/.agents/skills/purview-results-zodsharp-validation/SKILL.md index 24d2b51..a20490a 100644 --- a/src/src/ZodSharp/Sdk/.agents/skills/purview-results-zodsharp-validation/SKILL.md +++ b/src/src/ZodSharp/Sdk/.agents/skills/purview-results-zodsharp-validation/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-results-zodsharp-validation -description: "Use when ZodSharp validation has to feed Purview.Results — converting ValidationResult with ToResult, carrying ValidationError values on a union case, implementing IValidationErrorCarrier, or deciding what a rejected input becomes in the result pipeline." +description: "Use when ZodSharp validation has to feed Purview.Results — converting ValidationResult with ToResult or the value-discarding ToUnitResult, carrying ValidationError values on a union case, implementing IValidationErrorCarrier, or deciding what a rejected input becomes in the result pipeline." --- # Folding ZodSharp validation into results @@ -51,6 +51,22 @@ return await ReconcileCoreAsync(validated.Value, repositories, cancellationToken This keeps the failure case carrying the errors while the success type stays whatever the method actually produces. +## When the method has no success value + +When the method reports only why an input was rejected, `ToUnitResult` discards the validated value and returns a +value-less `Result`. It still names the validated value type, because a union case does not carry its +union: + +```csharp +Result Validate(TenantInput input) => + TenantInputSchema + .Validate(input) + .ToUnitResult(errors => new TenantInputInvalid(input, errors)); +``` + +Use `ToResult` when the success carries the validated value, `ToUnitResult` when it carries nothing, and the +generated `AsFailure()` when the success carries something else. + ## Carrying the errors ```csharp @@ -81,7 +97,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(…)`). +2. The error type is named explicitly (`ToResult(…)` or `ToUnitResult(…)`). 3. The case that carries the errors implements `IValidationErrorCarrier`. -4. A method whose success type differs uses `AsFailure()` with the validation errors. +4. A method whose success type differs uses `AsFailure()`, or `AsFailure()` for a unit result, with the + validation errors. 5. The host maps the carrier to a validation problem once (see `purview-results-zodsharp-problems`). diff --git a/src/src/ZodSharp/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md index 3e8e02f..f2f2ff4 100644 --- a/src/src/ZodSharp/Sdk/README.md +++ b/src/src/ZodSharp/Sdk/README.md @@ -46,11 +46,13 @@ return await ReconcileCoreAsync(validated.Value, repositories, cancellationToken | 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 | +| `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 | | `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. +union type that contains it. `ToUnitResult` still names the validated value type for the same reason, even though +it discards it. 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 diff --git a/src/src/ZodSharp/ZodValidationResultExtensions.cs b/src/src/ZodSharp/ZodValidationResultExtensions.cs index 09d35dd..a7d55dd 100644 --- a/src/src/ZodSharp/ZodValidationResultExtensions.cs +++ b/src/src/ZodSharp/ZodValidationResultExtensions.cs @@ -80,4 +80,39 @@ Func, TError> onFailure ? Result.Success(validation.Value) : Result.Failure(onFailure(validation.Errors)); } + + /// + /// Converts a 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 + /// ToUnitResult<TenantInput, TenantError>(...), because a union case does not carry the union + /// type that contains it. + /// + /// The validation result to convert. + /// + /// 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. + /// + /// + /// Reach for this when the surrounding method validates a value but succeeds with nothing — a command that + /// accepts an input and reports only why it was rejected. 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. + /// + /// Thrown when is null. + public static Result ToUnitResult( + this ValidationResult validation, + Func, TError> onFailure + ) + { + ArgumentNullException.ThrowIfNull(onFailure); + + return validation.IsSuccess ? Result.Success() : Result.Failure(onFailure(validation.Errors)); + } } diff --git a/src/tests/AspNetCore.UnitTests/DefaultResultsHttpMapperTests.cs b/src/tests/AspNetCore.UnitTests/DefaultResultsHttpMapperTests.cs index 70f0ed9..f741c97 100644 --- a/src/tests/AspNetCore.UnitTests/DefaultResultsHttpMapperTests.cs +++ b/src/tests/AspNetCore.UnitTests/DefaultResultsHttpMapperTests.cs @@ -39,6 +39,66 @@ public async Task Map_GivenSuccessfulValueThatIsAResponse_ReturnsItUnchanged() await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status204NoContent); } + [Test] + public async Task Map_GivenSuccessfulUnitResult_ReturnsNoContent() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(Result.Success(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status204NoContent); + await Assert.That(response.Body).IsEqualTo(string.Empty); + } + + [Test] + public async Task Map_GivenSuccessfulUnitResultAndSuccessMapper_ReturnsTheMapperResponse() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(options => + options.SuccessMapper = (_, _) => TypedResults.Ok("overridden") + ); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(Result.Success(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status200OK); + await Assert.That(response.Body).Contains("overridden"); + } + + [Test] + public async Task Map_GivenUnitFailure_ReturnsTheMappedResponse() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(options => + options.Map(notFound => + TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, title: $"Item {notFound.ItemId}.") + ) + ); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(new ItemNotFound(7).AsFailure(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status404NotFound); + await Assert.That(response.Body).Contains("Item 7."); + } + [Test] public async Task Map_GivenMappedCase_ReturnsTheMappedResponse() { @@ -168,4 +228,139 @@ public async Task Map_GivenMappingAndUnusedFallback_ReturnsTheMappedResponse() await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status404NotFound); await Assert.That(fallbackInvocations).IsEqualTo(0); } + + [Test] + public async Task Map_GivenNestedUnionFailure_ReturnsTheLeafCaseMapping() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(options => + options.Map(unavailable => + TypedResults.Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: unavailable.Reason) + ) + ); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(CreateNestedBillingFailure(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status503ServiceUnavailable); + await Assert.That(response.Body).Contains("down"); + } + + [Test] + public async Task Map_GivenNestedUnionFailure_LeafMappingBeatsTheNestedUnionAndErrorMappings() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(options => + { + options.Map(_ => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict)); + options.Map(_ => TypedResults.Problem(statusCode: StatusCodes.Status502BadGateway)); + options.Map(_ => + TypedResults.Problem(statusCode: StatusCodes.Status503ServiceUnavailable) + ); + }); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(CreateNestedBillingFailure(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status503ServiceUnavailable); + } + + [Test] + public async Task Map_GivenNestedUnionFailureWithoutLeafMapping_ReturnsTheNestedUnionMapping() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(options => + { + options.Map(_ => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict)); + options.Map(_ => TypedResults.Problem(statusCode: StatusCodes.Status502BadGateway)); + }); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(CreateNestedBillingFailure(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status502BadGateway); + } + + [Test] + public async Task Map_GivenNestedUnionFailureWithoutInnerMapping_ReturnsTheErrorTypeMapping() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(options => + options.Map(_ => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict)) + ); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(CreateNestedBillingFailure(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status409Conflict); + } + + [Test] + public async Task Map_GivenNestedUnionFailure_ReturnsTheFallbackResponseForTheLeafCase() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(options => + options.AddFallback( + (error, _) => + error is ItemBillingUnavailable unavailable + ? TypedResults.Problem( + statusCode: StatusCodes.Status422UnprocessableEntity, + title: unavailable.Reason + ) + : null + ) + ); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(CreateNestedBillingFailure(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status422UnprocessableEntity); + await Assert.That(response.Body).Contains("down"); + } + + [Test] + public async Task Map_GivenUnmappedNestedUnionFailure_NamesTheLeafCase() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(CreateNestedBillingFailure(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status500InternalServerError); + await Assert.That(response.Body).Contains(typeof(ItemBillingUnavailable).FullName!); + } + + static Result CreateNestedBillingFailure() => + ((HttpTestBillingError)new ItemBillingUnavailable("down")).AsFailure(); } diff --git a/src/tests/AspNetCore.UnitTests/ResultsHttpTestUnions.cs b/src/tests/AspNetCore.UnitTests/ResultsHttpTestUnions.cs index 284e464..b5e291d 100644 --- a/src/tests/AspNetCore.UnitTests/ResultsHttpTestUnions.cs +++ b/src/tests/AspNetCore.UnitTests/ResultsHttpTestUnions.cs @@ -9,3 +9,15 @@ namespace Purview.Results.AspNetCore; public readonly record struct ItemConflict(int ItemId, string Reason); public readonly record struct ItemRejected(int ItemId); + +public readonly record struct ItemBillingMissing(int ItemId); + +public readonly record struct ItemBillingUnavailable(string Reason); + +// A downstream service's error union, nested inside the outer operation-family union so the mapper has to +// unwrap two levels to reach the leaf. +[GenerateResult] +public readonly union HttpTestBillingError(ItemBillingMissing, ItemBillingUnavailable); + +[GenerateResult] +public readonly union HttpTestOuterError(HttpTestError, HttpTestBillingError); diff --git a/src/tests/Results.UnitTests/ResultExtensionsTests.cs b/src/tests/Results.UnitTests/ResultExtensionsTests.cs index 8063282..fb4601b 100644 --- a/src/tests/Results.UnitTests/ResultExtensionsTests.cs +++ b/src/tests/Results.UnitTests/ResultExtensionsTests.cs @@ -1,3 +1,5 @@ +using System.Globalization; + namespace Purview.Results; public sealed class ResultExtensionsTests @@ -469,8 +471,639 @@ public async Task BindAsync_WhenBindIsNull_ShouldThrow() #endregion + #region Error-widening composition + + [Test] + public async Task Bind_WhenSuccessAndBindingSucceeds_ShouldReturnBoundSuccess() + { + var result = Successful("123"); + + var actual = result.Bind( + value => Result.Success(int.Parse(value, CultureInfo.InvariantCulture)), + error => new OtherTestError(error.Message.Length) + ); + + await Assert.That(actual.IsSuccess).IsTrue(); + await Assert.That(actual.Value).IsEqualTo(123); + } + + [Test] + public async Task Bind_WhenSuccessAndBindingSucceeds_ShouldNotInvokeMapError() + { + var result = Successful(); + var invoked = false; + + result.Bind( + _ => Result.Success(0), + _ => + { + invoked = true; + + return new OtherTestError(0); + } + ); + + await Assert.That(invoked).IsFalse(); + } + + [Test] + public async Task Bind_WhenSuccessAndBindingFails_ShouldReturnBoundFailure() + { + var result = Successful(); + OtherTestError error = new(42); + + var actual = result.Bind(_ => Result.Failure(error), _ => new OtherTestError(0)); + + await Assert.That(actual.IsFailure).IsTrue(); + await Assert.That(actual.Error).IsEqualTo(error); + } + + [Test] + public async Task Bind_WhenFailure_ShouldMapErrorAndNotInvokeBinding() + { + var result = Failed(); + var invoked = false; + + var actual = result.Bind( + value => + { + invoked = true; + + return Result.Success(value.Length); + }, + error => new OtherTestError(error.Message.Length) + ); + + await Assert.That(invoked).IsFalse(); + await Assert.That(actual.IsFailure).IsTrue(); + await Assert.That(actual.Error).IsEqualTo(new OtherTestError(Error.Length)); + } + + [Test] + public async Task Bind_WhenFailure_ShouldInvokeMapErrorOnce() + { + var result = Failed(); + var invoked = 0; + + result.Bind( + _ => Result.Success(0), + _ => + { + invoked++; + + return new OtherTestError(0); + } + ); + + await Assert.That(invoked).IsEqualTo(1); + } + + [Test] + public async Task Bind_WhenUninitialized_ShouldThrow() + { + Result result = default; + + Result Act() => + result.Bind(value => Result.Success(value.Length), _ => new OtherTestError(0)); + + await Assert + .That(Act) + .ThrowsExactly() + .WithMessage("The result is uninitialized.", StringComparison.Ordinal); + } + + [Test] + public async Task Bind_WhenBindIsNull_ShouldThrow() + { + var result = Successful(); + + Result Act() => + result.Bind((Func>)null!, _ => new OtherTestError(0)); + + await Assert.That(Act).ThrowsExactly(); + } + + [Test] + public async Task Bind_WhenMapErrorIsNull_ShouldThrow() + { + var result = Successful(); + + Result Act() => result.Bind(_ => Result.Success(0), null!); + + await Assert.That(Act).ThrowsExactly(); + } + + [Test] + public async Task BindAsyncWithMapError_WhenSuccessAndBindingSucceeds_ShouldReturnBoundSuccess() + { + var result = Successful("123"); + + var actual = await result.BindAsync( + value => + Task.FromResult(Result.Success(int.Parse(value, CultureInfo.InvariantCulture))), + error => new OtherTestError(error.Message.Length) + ); + + await Assert.That(actual.IsSuccess).IsTrue(); + await Assert.That(actual.Value).IsEqualTo(123); + } + + [Test] + public async Task BindAsync_WhenSuccessAndBindingSucceeds_ShouldNotInvokeMapError() + { + var result = Successful(); + var invoked = false; + + await result.BindAsync( + _ => Task.FromResult(Result.Success(0)), + _ => + { + invoked = true; + + return new OtherTestError(0); + } + ); + + await Assert.That(invoked).IsFalse(); + } + + [Test] + public async Task BindAsync_WhenFailure_ShouldMapErrorAndNotInvokeBinding() + { + var result = Failed(); + var invoked = false; + + var actual = await result.BindAsync( + value => + { + invoked = true; + + return Task.FromResult(Result.Success(value.Length)); + }, + error => new OtherTestError(error.Message.Length) + ); + + await Assert.That(invoked).IsFalse(); + await Assert.That(actual.IsFailure).IsTrue(); + await Assert.That(actual.Error).IsEqualTo(new OtherTestError(Error.Length)); + } + + [Test] + public async Task BindAsync_WhenUninitialized_ShouldThrow() + { + Result result = default; + + async Task Act() => + await result.BindAsync( + value => Task.FromResult(Result.Success(value.Length)), + _ => new OtherTestError(0) + ); + + await Assert + .That(Act) + .ThrowsExactly() + .WithMessage("The result is uninitialized.", StringComparison.Ordinal); + } + + [Test] + public async Task BindAsyncWithMapError_WhenBindIsNull_ShouldThrow() + { + var result = Successful(); + + async Task Act() => + await result.BindAsync((Func>>)null!, _ => new OtherTestError(0)); + + await Assert.That(Act).ThrowsExactly(); + } + + [Test] + public async Task BindAsync_WhenMapErrorIsNull_ShouldThrow() + { + var result = Successful(); + + async Task Act() => await result.BindAsync(_ => Task.FromResult(Result.Success(0)), null!); + + await Assert.That(Act).ThrowsExactly(); + } + + #endregion + + #region Throwing + + [Test] + public async Task Throw_WhenSuccess_ShouldReturnValue() + { + var result = Successful(); + + await Assert.That(result.Throw()).IsEqualTo(Value); + } + + [Test] + public async Task Throw_WhenFailure_ShouldThrowResultException() + { + var result = Failed(); + + string Act() => result.Throw(); + + await Assert.That(Act).ThrowsExactly>(); + } + + [Test] + public async Task Throw_WhenFailure_ShouldExposeTheError() + { + var result = Failed(); + + var exception = ThrowAndCapture(result); + + await Assert.That(exception.Error).IsEqualTo(new TestError(Error)); + } + + [Test] + public async Task Throw_WhenFailure_ShouldIncludeTheErrorInTheMessage() + { + var result = Failed(); + + var exception = ThrowAndCapture(result); + + await Assert.That(exception.Message).IsEqualTo($"The result represents a failure: {Error}"); + } + + [Test] + public async Task Throw_WhenFailure_ShouldBeCatchableAsTheNonGenericBase() + { + var result = Failed(); + var caught = false; + + try + { + result.Throw(); + } + catch (ResultException exception) + { + caught = exception.Error is TestError; + } + + await Assert.That(caught).IsTrue(); + } + + [Test] + public async Task Throw_WhenUninitialized_ShouldThrowInvalidOperationException() + { + Result result = default; + + string Act() => result.Throw(); + + await Assert + .That(Act) + .ThrowsExactly() + .WithMessage("The result is uninitialized.", StringComparison.Ordinal); + } + + #endregion + + #region Unit result extensions + + static Result UnitSuccessful() => Result.Success(); + + static Result UnitFailed() => Result.Failure(new(Error)); + + [Test] + public async Task TryGetError_WhenUnitFailure_ShouldReturnTrueAndError() + { + var result = UnitFailed(); + + var found = result.TryGetError(out var error); + + await Assert.That(found).IsTrue(); + await Assert.That(error).IsEqualTo(new TestError(Error)); + } + + [Test] + public async Task TryGetError_WhenUnitSuccess_ShouldReturnFalse() + { + var result = UnitSuccessful(); + + var found = result.TryGetError(out var error); + + await Assert.That(found).IsFalse(); + await Assert.That(error).IsEqualTo(default); + } + + [Test] + public async Task TryGetError_WhenUnitUninitialized_ShouldReturnFalse() + { + Result result = default; + + var found = result.TryGetError(out var error); + + await Assert.That(found).IsFalse(); + await Assert.That(error).IsEqualTo(default); + } + + [Test] + public async Task GetErrorOrDefault_WhenUnitFailure_ShouldReturnError() + { + var result = UnitFailed(); + + await Assert.That(result.GetErrorOrDefault(new TestError("fallback"))).IsEqualTo(new TestError(Error)); + } + + [Test] + public async Task GetErrorOrDefault_WhenUnitSuccess_ShouldReturnFallback() + { + var result = UnitSuccessful(); + + await Assert.That(result.GetErrorOrDefault(new TestError("fallback"))).IsEqualTo(new TestError("fallback")); + } + + [Test] + public async Task OrElse_WhenUnitFailure_ShouldReturnFallback() + { + var result = UnitFailed(); + + var actual = result.OrElse(UnitSuccessful()); + + await Assert.That(actual.IsSuccess).IsTrue(); + } + + [Test] + public async Task OrElse_WhenUnitSuccess_ShouldReturnOriginal() + { + var result = UnitSuccessful(); + + var actual = result.OrElse(UnitFailed()); + + await Assert.That(actual.IsSuccess).IsTrue(); + } + + [Test] + public async Task Switch_WhenUnitSuccess_ShouldInvokeSuccessOnly() + { + var result = UnitSuccessful(); + var successInvoked = false; + var failureInvoked = false; + + result.Switch(() => successInvoked = true, _ => failureInvoked = true); + + await Assert.That(successInvoked).IsTrue(); + await Assert.That(failureInvoked).IsFalse(); + } + + [Test] + public async Task Switch_WhenUnitFailure_ShouldInvokeFailureOnly() + { + var result = UnitFailed(); + var successInvoked = false; + var failureInvoked = false; + + result.Switch(() => successInvoked = true, _ => failureInvoked = true); + + await Assert.That(successInvoked).IsFalse(); + await Assert.That(failureInvoked).IsTrue(); + } + + [Test] + public async Task Tap_WhenUnitSuccess_ShouldInvokeAndReturnResult() + { + var result = UnitSuccessful(); + var invoked = false; + + var actual = result.Tap(() => invoked = true); + + await Assert.That(invoked).IsTrue(); + await Assert.That(actual.IsSuccess).IsTrue(); + } + + [Test] + public async Task TapError_WhenUnitFailure_ShouldInvokeAndReturnResult() + { + var result = UnitFailed(); + TestError? observed = null; + + var actual = result.TapError(error => observed = error); + + await Assert.That(observed).IsEqualTo(new TestError(Error)); + await Assert.That(actual.IsFailure).IsTrue(); + } + + [Test] + public async Task Ensure_WhenUnitPredicateFails_ShouldReturnFailureWithCreatedError() + { + var result = UnitSuccessful(); + + var actual = result.Ensure(() => false, () => new TestError("constraint failed")); + + await Assert.That(actual.IsFailure).IsTrue(); + await Assert.That(actual.Error).IsEqualTo(new TestError("constraint failed")); + } + + [Test] + public async Task Ensure_WhenUnitPredicateSucceeds_ShouldReturnOriginalSuccess() + { + var result = UnitSuccessful(); + + var actual = result.Ensure(() => true, () => new TestError("constraint failed")); + + await Assert.That(actual.IsSuccess).IsTrue(); + } + + [Test] + public async Task MapAsync_WhenUnitSuccess_ShouldProduceValue() + { + var result = UnitSuccessful(); + + var actual = await result.MapAsync(() => Task.FromResult(Value.Length)); + + await Assert.That(actual.IsSuccess).IsTrue(); + await Assert.That(actual.Value).IsEqualTo(Value.Length); + } + + [Test] + public async Task MapAsync_WhenUnitFailure_ShouldPreserveError() + { + var result = UnitFailed(); + + var actual = await result.MapAsync(() => Task.FromResult(Value.Length)); + + await Assert.That(actual.IsFailure).IsTrue(); + await Assert.That(actual.Error).IsEqualTo(new TestError(Error)); + } + + [Test] + public async Task BindAsync_WhenUnitSuccess_ShouldReturnBoundUnitSuccess() + { + var result = UnitSuccessful(); + + var actual = await result.BindAsync(() => Task.FromResult(UnitSuccessful())); + + await Assert.That(actual.IsSuccess).IsTrue(); + } + + [Test] + public async Task BindAsyncToValue_WhenUnitSuccess_ShouldReturnBoundValueResult() + { + var result = UnitSuccessful(); + + var actual = await result.BindAsync(() => Task.FromResult(Result.Success(123))); + + await Assert.That(actual.IsSuccess).IsTrue(); + await Assert.That(actual.Value).IsEqualTo(123); + } + + [Test] + public async Task BindAsync_WhenUnitFailure_ShouldPreserveErrorAndNotInvokeBinding() + { + var result = UnitFailed(); + var invoked = false; + + var actual = await result.BindAsync(() => + { + invoked = true; + + return Task.FromResult(UnitSuccessful()); + }); + + await Assert.That(invoked).IsFalse(); + await Assert.That(actual.Error).IsEqualTo(new TestError(Error)); + } + + [Test] + public async Task Bind_WhenUnitSuccessAndBindingSucceeds_ShouldWidenAndReturnBoundUnitSuccess() + { + var result = UnitSuccessful(); + + var actual = result.Bind(Result.Success, error => new OtherTestError(error.Message.Length)); + + await Assert.That(actual.IsSuccess).IsTrue(); + } + + [Test] + public async Task BindToValue_WhenUnitFailure_ShouldMapError() + { + var result = UnitFailed(); + + var actual = result.Bind( + () => Result.Success(123), + error => new OtherTestError(error.Message.Length) + ); + + await Assert.That(actual.IsFailure).IsTrue(); + await Assert.That(actual.Error).IsEqualTo(new OtherTestError(Error.Length)); + } + + [Test] + public async Task BindAsyncWithMapError_WhenUnitFailure_ShouldMapError() + { + var result = UnitFailed(); + + var actual = await result.BindAsync( + () => Task.FromResult(Result.Success()), + error => new OtherTestError(error.Message.Length) + ); + + await Assert.That(actual.IsFailure).IsTrue(); + await Assert.That(actual.Error).IsEqualTo(new OtherTestError(Error.Length)); + } + + [Test] + public async Task Throw_WhenUnitSuccess_ShouldReturnTheSuccessMarker() + { + var result = UnitSuccessful(); + + await Assert.That(result.Throw()).IsEqualTo(Success.Instance); + } + + [Test] + public async Task Throw_WhenUnitFailure_ShouldThrowResultException() + { + var result = UnitFailed(); + + var exception = ThrowUnitAndCapture(result); + + await Assert.That(exception.Error).IsEqualTo(new TestError(Error)); + } + + [Test] + public async Task Throw_WhenUnitUninitialized_ShouldThrowInvalidOperationException() + { + Result result = default; + + Success Act() => result.Throw(); + + await Assert + .That(Act) + .ThrowsExactly() + .WithMessage("The result is uninitialized.", StringComparison.Ordinal); + } + + [Test] + public async Task DiscardValue_WhenValueSuccess_ShouldReturnUnitSuccess() + { + var result = Successful(); + + var actual = result.DiscardValue(); + + await Assert.That(actual.IsSuccess).IsTrue(); + } + + [Test] + public async Task DiscardValue_WhenValueFailure_ShouldPreserveError() + { + var result = Failed(); + + var actual = result.DiscardValue(); + + await Assert.That(actual.IsFailure).IsTrue(); + await Assert.That(actual.Error).IsEqualTo(new TestError(Error)); + } + + [Test] + public async Task DiscardValue_WhenValueUninitialized_ShouldThrow() + { + Result result = default; + + Result Act() => result.DiscardValue(); + + await Assert + .That(Act) + .ThrowsExactly() + .WithMessage("The result is uninitialized.", StringComparison.Ordinal); + } + + #endregion + + static ResultException ThrowAndCapture(Result result) + { + try + { + result.Throw(); + } + catch (ResultException exception) + { + return exception; + } + + throw new InvalidOperationException("The result did not throw."); + } + + static ResultException ThrowUnitAndCapture(Result result) + { + try + { + result.Throw(); + } + catch (ResultException exception) + { + return exception; + } + + throw new InvalidOperationException("The result did not throw."); + } + readonly record struct TestError(string Message) { public override string ToString() => Message; } + + readonly record struct OtherTestError(int Code); } diff --git a/src/tests/Results.UnitTests/ResultTErrorTests.cs b/src/tests/Results.UnitTests/ResultTErrorTests.cs new file mode 100644 index 0000000..7b4fff9 --- /dev/null +++ b/src/tests/Results.UnitTests/ResultTErrorTests.cs @@ -0,0 +1,356 @@ +namespace Purview.Results; + +public sealed class ResultTErrorTests +{ + const string Error = "error"; + + #region State + + [Test] + public async Task DefaultResult_ShouldBeUninitialized() + { + Result result = default; + + await Assert.That(result.IsInitialized).IsFalse(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.IsFailure).IsFalse(); + } + + [Test] + public async Task Success_ShouldHaveExpectedState() + { + var result = Result.Success(); + + await Assert.That(result.IsInitialized).IsTrue(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.IsFailure).IsFalse(); + } + + [Test] + public async Task Failure_ShouldHaveExpectedState() + { + TestError error = new(Error); + + var result = Result.Failure(error); + + await Assert.That(result.IsInitialized).IsTrue(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error).IsEqualTo(error); + } + + [Test] + public async Task Error_WhenSuccess_ShouldThrow() + { + var result = Result.Success(); + + TestError Act() => result.Error; + + await Assert + .That(Act) + .ThrowsExactly() + .WithMessage("The error of a non-failed result cannot be accessed.", StringComparison.Ordinal); + } + + [Test] + public async Task Error_WhenUninitialized_ShouldThrow() + { + Result result = default; + + TestError Act() => result.Error; + + await Assert.That(Act).ThrowsExactly(); + } + + #endregion + + #region Factories and conversions + + [Test] + public async Task ResultSuccessFactory_ShouldCreateSuccess() + { + var result = Result.Success(); + + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task ResultFailureFactory_ShouldCreateFailure() + { + TestError error = new(Error); + + var result = Result.Failure(error); + + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error).IsEqualTo(error); + } + + [Test] + public async Task ImplicitSuccessConversion_ShouldCreateSuccess() + { + Result result = Success.Instance; + + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task ImplicitErrorConversion_ShouldCreateFailure() + { + TestError error = new(Error); + + Result result = error; + + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error).IsEqualTo(error); + } + + #endregion + + #region IResultValue + + [Test] + public async Task IResultValue_WhenSuccess_ShouldExposeTheSuccessMarker() + { + IResultValue result = Result.Success(); + + await Assert.That(result.IsInitialized).IsTrue(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.SuccessValue).IsEqualTo(Success.Instance); + await Assert.That(result.ErrorValue).IsNull(); + } + + [Test] + public async Task IResultValue_WhenFailure_ShouldExposeTheError() + { + TestError error = new(Error); + IResultValue result = Result.Failure(error); + + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.SuccessValue).IsNull(); + await Assert.That(result.ErrorValue).IsEqualTo(error); + } + + [Test] + public async Task IResultValue_WhenUninitialized_ShouldExposeNeitherState() + { + IResultValue result = default(Result); + + await Assert.That(result.IsInitialized).IsFalse(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.SuccessValue).IsNull(); + await Assert.That(result.ErrorValue).IsNull(); + } + + #endregion + + #region ToString + + [Test] + public async Task ToString_WhenSuccess_ShouldDescribeSuccess() + { + var result = Result.Success(); + + await Assert.That(result.ToString()).IsEqualTo("Success"); + } + + [Test] + public async Task ToString_WhenFailure_ShouldDescribeFailure() + { + var result = Result.Failure(new(Error)); + + await Assert.That(result.ToString()).IsEqualTo("Failure(error)"); + } + + [Test] + public async Task ToString_WhenUninitialized_ShouldDescribeUninitialized() + { + Result result = default; + + await Assert.That(result.ToString()).IsEqualTo("Uninitialized"); + } + + #endregion + + #region Match + + [Test] + public async Task Match_WhenSuccess_ShouldInvokeSuccess() + { + var result = Result.Success(); + + var matched = result.Match(success: () => "success", failure: error => $"failure:{error}"); + + await Assert.That(matched).IsEqualTo("success"); + } + + [Test] + public async Task Match_WhenFailure_ShouldInvokeFailure() + { + var result = Result.Failure(new(Error)); + + var matched = result.Match(success: () => "success", failure: error => $"failure:{error}"); + + await Assert.That(matched).IsEqualTo("failure:error"); + } + + [Test] + public async Task Match_WhenUninitialized_ShouldThrow() + { + Result result = default; + + string Act() => result.Match(success: () => "success", failure: error => error.Message); + + await Assert + .That(Act) + .ThrowsExactly() + .WithMessage("The result is uninitialized.", StringComparison.Ordinal); + } + + #endregion + + #region Map + + [Test] + public async Task Map_WhenSuccess_ShouldProduceValue() + { + var result = Result.Success(); + + var mapped = result.Map(() => 123); + + await Assert.That(mapped.IsSuccess).IsTrue(); + await Assert.That(mapped.Value).IsEqualTo(123); + } + + [Test] + public async Task Map_WhenFailure_ShouldPreserveErrorAndNotInvokeMapping() + { + TestError error = new(Error); + var result = Result.Failure(error); + var invoked = false; + + var mapped = result.Map(() => + { + invoked = true; + + return 123; + }); + + await Assert.That(invoked).IsFalse(); + await Assert.That(mapped.IsFailure).IsTrue(); + await Assert.That(mapped.Error).IsEqualTo(error); + } + + [Test] + public async Task Map_WhenUninitialized_ShouldThrow() + { + Result result = default; + + Result Act() => result.Map(() => 123); + + await Assert + .That(Act) + .ThrowsExactly() + .WithMessage("The result is uninitialized.", StringComparison.Ordinal); + } + + #endregion + + #region MapError + + [Test] + public async Task MapError_WhenFailure_ShouldTransformError() + { + var result = Result.Failure(new(Error)); + + var mapped = result.MapError(error => new OtherTestError(error.Message.Length)); + + await Assert.That(mapped.IsFailure).IsTrue(); + await Assert.That(mapped.Error).IsEqualTo(new OtherTestError(5)); + } + + [Test] + public async Task MapError_WhenSuccess_ShouldPreserveSuccess() + { + var result = Result.Success(); + + var mapped = result.MapError(error => new OtherTestError(error.Message.Length)); + + await Assert.That(mapped.IsSuccess).IsTrue(); + } + + #endregion + + #region Bind + + [Test] + public async Task Bind_WhenSuccessAndBindingSucceeds_ShouldReturnBoundUnitSuccess() + { + var result = Result.Success(); + + var bound = result.Bind(Result.Success); + + await Assert.That(bound.IsSuccess).IsTrue(); + } + + [Test] + public async Task Bind_WhenSuccessAndBindingFails_ShouldReturnBoundFailure() + { + var result = Result.Success(); + TestError error = new(Error); + + var bound = result.Bind(() => Result.Failure(error)); + + await Assert.That(bound.IsFailure).IsTrue(); + await Assert.That(bound.Error).IsEqualTo(error); + } + + [Test] + public async Task Bind_WhenFailure_ShouldPreserveErrorAndNotInvokeBinding() + { + TestError error = new(Error); + var result = Result.Failure(error); + var invoked = false; + + var bound = result.Bind(() => + { + invoked = true; + + return Result.Success(); + }); + + await Assert.That(invoked).IsFalse(); + await Assert.That(bound.Error).IsEqualTo(error); + } + + [Test] + public async Task BindToValue_WhenSuccess_ShouldReturnBoundValueResult() + { + var result = Result.Success(); + + var bound = result.Bind(() => Result.Success(123)); + + await Assert.That(bound.IsSuccess).IsTrue(); + await Assert.That(bound.Value).IsEqualTo(123); + } + + [Test] + public async Task BindToValue_WhenFailure_ShouldPreserveError() + { + TestError error = new(Error); + var result = Result.Failure(error); + + var bound = result.Bind(() => Result.Success(123)); + + await Assert.That(bound.IsFailure).IsTrue(); + await Assert.That(bound.Error).IsEqualTo(error); + } + + #endregion + + readonly record struct TestError(string Message) + { + public override string ToString() => Message; + } + + readonly record struct OtherTestError(int Code); +} diff --git a/src/tests/Results.UnitTests/SuccessTests.cs b/src/tests/Results.UnitTests/SuccessTests.cs new file mode 100644 index 0000000..118b6cb --- /dev/null +++ b/src/tests/Results.UnitTests/SuccessTests.cs @@ -0,0 +1,16 @@ +namespace Purview.Results; + +public sealed class SuccessTests +{ + [Test] + public async Task Instance_ShouldEqualDefault() + { + await Assert.That(Success.Instance).IsEqualTo(default); + } + + [Test] + public async Task ToString_ShouldDescribeSuccess() + { + await Assert.That(Success.Instance.ToString()).IsEqualTo("Success"); + } +} diff --git a/src/tests/SourceGenerator.UnitTests/GeneratedResultsIntegrationTests.cs b/src/tests/SourceGenerator.UnitTests/GeneratedResultsIntegrationTests.cs index be06341..c7fe791 100644 --- a/src/tests/SourceGenerator.UnitTests/GeneratedResultsIntegrationTests.cs +++ b/src/tests/SourceGenerator.UnitTests/GeneratedResultsIntegrationTests.cs @@ -118,4 +118,82 @@ public async Task Map_GivenGeneratedSuccess_InvokesTheMapping() await Assert.That(mapped.IsSuccess).IsTrue(); await Assert.That(mapped.Value).IsEqualTo("tenant-1"); } + + [Test] + public async Task Bind_GivenBillingFailure_ShouldWidenTheErrorIntoTheCompositeUnion() + { + // Arrange + var billing = new BillingServiceUnavailable("down").AsFailure(); + + // Act + // The widening Bind maps the billing error into RegisterTenantError, so the binder may return the + // composite error type directly. The lambda returns the error unchanged; the union conversion lifts + // BillingError into RegisterTenantError. + var bound = billing.Bind( + _ => Result.Success(new TenantId("tenant-1")), + error => error + ); + + // Assert + await Assert.That(bound.IsFailure).IsTrue(); + await Assert.That(bound.Error is BillingError).IsTrue(); + } + + [Test] + public async Task Bind_GivenBillingSuccess_ShouldReturnTheBoundResult() + { + // Arrange + var billing = Result.Success(new Tenant(new TenantId("t"))); + + // Act + var bound = billing.Bind( + // A tenant error is a case of the composite union, so its generated helper produces the composite + // failure directly once the case has been lifted to TenantError. + _ => ((TenantError)new TenantAlreadyExists(new TenantId("t"))).AsFailure(), + error => error + ); + + // Assert + await Assert.That(bound.IsFailure).IsTrue(); + await Assert.That(bound.Error is TenantError).IsTrue(); + } + + [Test] + public async Task AsFailure_GivenNestedBillingUnionCase_ShouldProduceTheCompositeFailure() + { + // Arrange + var billing = new BillingServiceUnavailable("down").AsFailure(); + + // Act + // The guard idiom: a whole union case of the composite union has its own AsFailure helper, so the + // billing failure lifts into the registration contract without an explicit MapError. + await Assert.That(billing.TryGetError(out var billingError)).IsTrue(); + var failure = billingError.AsFailure(); + + // Assert + await Assert.That(failure.IsFailure).IsTrue(); + await Assert.That(failure.Error is BillingError).IsTrue(); + } + + [Test] + public async Task Throw_GivenGeneratedFailure_ShouldThrowResultExceptionCarryingTheUnion() + { + // Arrange + var result = new TenantNotFound(new TenantId("tenant-1")).AsFailure(); + ResultException? exception = null; + + // Act + try + { + result.Throw(); + } + catch (ResultException caught) + { + exception = caught; + } + + // Assert + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Error is TenantNotFound).IsTrue(); + } } diff --git a/src/tests/SourceGenerator.UnitTests/GeneratedResultsRuntimeTests.cs b/src/tests/SourceGenerator.UnitTests/GeneratedResultsRuntimeTests.cs index 60516a5..2514d54 100644 --- a/src/tests/SourceGenerator.UnitTests/GeneratedResultsRuntimeTests.cs +++ b/src/tests/SourceGenerator.UnitTests/GeneratedResultsRuntimeTests.cs @@ -117,4 +117,36 @@ public async Task Result_GivenSuccessValueWithGeneratedErrorUnion_IsInitializedS await Assert.That(result.IsSuccess).IsTrue(); await Assert.That(result.Value).IsEqualTo(tenant); } + + [Test] + public async Task AsFailureUnit_GivenNotFoundCase_ProducesAUnitFailure() + { + // Arrange + TenantNotFound tenantNotFound = new(new TenantId("tenant-1")); + + // Act + // The explicit type is the compile-time proof that the non-generic helper produces exactly a + // Result for this union. +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result result = tenantNotFound.AsFailure(); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // 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 TenantNotFound).IsTrue(); + } + + [Test] + public async Task AsFailureUnit_GivenDisabledCase_ProducesAFailureWhoseToStringNamesTheState() + { + // Arrange + // Act + var result = new TenantDisabled(new TenantId("tenant-2")).AsFailure(); + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.ToString()).StartsWith("Failure("); + } } diff --git a/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs b/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs index 59ab3ae..a030382 100644 --- a/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs +++ b/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs @@ -30,3 +30,31 @@ namespace Purview.Results.SourceGenerator; /// [GenerateResult] public readonly union TenantError(TenantNotFound, TenantDisabled, TenantAlreadyExists); + +/// A billing account does not exist. +/// The identifier of the billing account. +public readonly record struct BillingAccountMissing(string AccountId); + +/// The billing service is unavailable. +/// The reason the billing service is unavailable. +public readonly record struct BillingServiceUnavailable(string Reason); + +/// The errors a billing operation can produce. +/// +/// This union stands in for a service boundary: a tenant operation consumes it and widens its error into +/// rather than leaking the billing case types. +/// +[GenerateResult] +public readonly union BillingError(BillingAccountMissing, BillingServiceUnavailable); + +/// +/// The errors a tenant registration can produce: the whole and +/// unions, each as a single case. +/// +/// +/// The case types are unions, not their leaf cases, so a billing failure can be lifted into this contract with +/// one union conversion and no case type is shared with (which would raise +/// RSG1006). +/// +[GenerateResult] +public readonly union RegisterTenantError(TenantError, BillingError); diff --git a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs index 36bce37..9ca539b 100644 --- a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs @@ -54,6 +54,30 @@ public async Task GenerateAsync_GivenOptedInUnion_EmitsOneHelperPerCase(Cancella await Assert.That(generated).Contains("global::Purview.Results.Result"); } + [Test] + public async Task GenerateAsync_GivenOptedInUnion_EmitsAUnitFailureHelperPerCase( + CancellationToken cancellationToken + ) + { + // Arrange + // Act + var result = await GenerateAsync(TenantUnionSource, cancellationToken); + + // Assert + var generated = result.AssertSingleGeneratedSource(); + + // The unit helper's body names Result with no value type argument, which distinguishes it from + // the generic AsFailure() helper's Result body. + await Assert + .That(generated) + .Contains("global::Purview.Results.Result.Failure(error)"); + + // The generic value helper is still emitted alongside it. + await Assert + .That(generated) + .Contains("global::Purview.Results.Result.Failure(error)"); + } + [Test] public async Task GenerateAsync_GivenOptedInUnion_EmitsNullableEnabledFullyQualifiedSource( CancellationToken cancellationToken diff --git a/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs b/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs index ae45da7..4f33a86 100644 --- a/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs +++ b/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs @@ -48,6 +48,35 @@ public static Result GetTenant(TenantId tenantId) await Assert.That(result.RewriteCompiles).IsTrue(); } + [Test] + public async Task UnionCaseResultCodeFixProvider_GivenUnionCaseReturnedFromUnitResultMethod_ShouldOfferTheUnitFix( + CancellationToken cancellationToken + ) + { + var source = + UnionSource + + """ + + namespace Test + { + public static class Usage + { + public static Result DeleteTenant(TenantId tenantId) + { + return new TenantNotFound(tenantId); + } + } + } + """; + + var result = await UnionCodeFixTestHarness.ApplyAsync(source, cancellationToken); + + await Assert.That(result.FixOffered).IsTrue(); + await Assert.That(result.FixedCode).Contains(".AsFailure()"); + await Assert.That(result.FixedCode).DoesNotContain(".AsFailure<"); + await Assert.That(result.RewriteCompiles).IsTrue(); + } + [Test] public async Task UnionCaseResultCodeFixProvider_GivenUnionCaseReturnedFromExpressionBody_ShouldOfferTheFix( CancellationToken cancellationToken diff --git a/src/tests/ZodSharp.UnitTests/ZodValidationResultExtensionsTests.cs b/src/tests/ZodSharp.UnitTests/ZodValidationResultExtensionsTests.cs index dd163af..396600f 100644 --- a/src/tests/ZodSharp.UnitTests/ZodValidationResultExtensionsTests.cs +++ b/src/tests/ZodSharp.UnitTests/ZodValidationResultExtensionsTests.cs @@ -111,4 +111,55 @@ public async Task ToResult_GivenNullFailureFactory_ThrowsArgumentNullException() // Act & Assert await Assert.That(() => validation.ToResult(null!)).Throws(); } + + [Test] + public async Task ToUnitResult_GivenSuccessfulValidation_ReturnsAUnitSuccessWithoutInvokingTheFactory() + { + // Arrange + var validation = ValidationResult.Success(42); + var factoryInvocations = 0; + + // Act + var result = validation.ToUnitResult(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 ToUnitResult_GivenFailedValidation_ReturnsAUnitFailureCarryingTheCreatedError() + { + // Arrange + var validation = ValidationResult.Failure( + ValidationError.Create("too_small", "Value is too short.", ["Value"]) + ); + + // Act + var result = validation.ToUnitResult(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 ToUnitResult_GivenNullFailureFactory_ThrowsArgumentNullException() + { + // Arrange + var validation = ValidationResult.Success(1); + + // Act & Assert + await Assert.That(() => validation.ToUnitResult(null!)).Throws(); + } } From 31b169d6d5c2a7eb18bf39f8c9245a3bb1971ef1 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 5 Oct 2026 15:41:53 +0100 Subject: [PATCH 06/11] feat: added better union support for multi-union setups --- AGENTS.md | 16 +- README.md | 13 +- docs/wiki/Diagnostics.md | 2 +- docs/wiki/Getting-Started.md | 15 +- docs/wiki/Guarantees-and-Limitations.md | 5 +- docs/wiki/Home.md | 4 +- docs/wiki/Source-Generator.md | 34 +++- docs/wiki/Union-Errors.md | 50 ++++-- src/src/Examples.Basic/Program.cs | 15 +- src/src/Examples.Basic/TenantError.cs | 5 +- src/src/Examples.Basic/TenantStore.cs | 6 +- .../purview-results-union-author.agent.md | 16 +- ...e-error-returns-to-result-unions.prompt.md | 8 +- .../skills/purview-results-core/SKILL.md | 9 +- .../purview-results-union-errors/SKILL.md | 34 +++- src/src/Results/Sdk/README.md | 16 +- .../UnionCaseResultCodeFixProvider.cs | 53 +++--- .../Diagnostics/DiagnosticLibrary.cs | 4 +- .../Discovery/ResultUnionDiscovery.cs | 4 +- .../Emit/ResultUnionEmitter.cs | 165 +++++++++++++++++- .../Helpers/PropertyLibrary.cs | 17 ++ .../Models/ResultUnionModel.cs | 12 +- src/src/SourceGenerator/README.md | 64 +++++-- .../Validation/ResultUnionValidator.cs | 19 +- .../GeneratedResultsRuntimeTests.cs | 63 +++++++ .../ResultsSourceGeneratorDiagnosticsTests.cs | 7 + .../ResultsSourceGeneratorTests.cs | 79 +++++++++ .../UnionCaseResultCodeFixProviderTests.cs | 47 ++++- 28 files changed, 664 insertions(+), 118 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4c7a5c1..c3bfba0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ generator that makes C# 15 union error cases ergonomic), and the ZodSharp and AS | `src/Results.slnx` | Canonical solution for restore, build, test and pack | | `src/src/Results` | `Result`, the value-less `Result`, the `Success` marker, the `Result` factories, `IResultValue` | | `src/src/SourceGenerator` | Roslyn incremental generator, diagnostic analyzer and `[GenerateResult]` attribute; not a package of its own, packed into `Purview.Results` | -| `src/src/SourceGenerator.CodeFixes` | IDE code fix for `CS0029`: rewrites a returned union case into the generated `AsFailure()` (value result) or `AsFailure()` (unit result) | +| `src/src/SourceGenerator.CodeFixes` | IDE code fix for `CS0029`: rewrites a returned union case into the generated union-receiver factory `Union.Failure(case)` (value result) or `Union.Failure(case)` (unit result) | | `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 | @@ -130,10 +130,17 @@ including the diagnostics table, build properties and activation rules. is unavailable for a non-generic case type (`CS0246`), and only one user-defined conversion may participate in a sequence (`CS0029`). All five are recorded in `UnionCompilerBehaviourTests.cs`. Improve call-site ergonomics through the code fix in `src/src/SourceGenerator.CodeFixes` instead. -- Emit **two helpers per case**: the value-producing `AsFailure()` (`Result`) and the +- Emit **two per-case helpers**: the value-producing `AsFailure()` (`Result`) and the non-generic `AsFailure()` (`Result` unit result). Keep them overloads of the same name in the one generated extension class, keep the bodies pure `Result<...>.Failure(error)` calls, and keep case ordering deterministic. +- Also emit the **union-receiver factory** as a C# 14 extension block on the union type + (`extension(TUnion) { public static ... }`): `Failure(case)`/`Failure(case)` and `Success()`/ + `Success(value)`. The factory names the union by its receiver, so it is the shared-case safe form: a + case type shared with another union (`RSG1006`) has its per-case `AsFailure` helper generated once, but every + union's factory covers all of its own cases. `ResultUnionModel.Cases` is the union's full case set (used by the + factory) and `ResultUnionModel.HelperCases` is the deduplicated subset (used by `AsFailure`); never conflate the + two. The code fix rewrites `CS0029` to the factory, not to `AsFailure`, so it cannot bind to the wrong union. - Adding a code fix means adding it to `src/src/SourceGenerator.CodeFixes`, not to the generator project: the code fix needs `Microsoft.CodeAnalysis.*.Workspaces`, which must not enter the generator's analyzer closure. The two projects stay independent (no project reference, no `InternalsVisibleTo`) so no reference cycle can @@ -255,8 +262,9 @@ including the diagnostics table, build properties and activation rules. ## Examples `src/src/Examples.*` holds one runnable example per integration aspect, all on the Tenant* domain the READMEs -document: `Examples.Basic` (the result type, the value-less `Result` and the generated `AsFailure()` -and `AsFailure()` helpers), `Examples.Zod` (`Purview.Results.ZodSharp`, including `ToUnitResult`), +document: `Examples.Basic` (the result type, the value-less `Result`, the generated `AsFailure()` +and `AsFailure()` helpers, and the union-receiver `Union.Failure(...)`/`Union.Success(...)` factories), +`Examples.Zod` (`Purview.Results.ZodSharp`, including `ToUnitResult`), `Examples.AspNetCore` (`Purview.Results.AspNetCore`, including a unit result answering `204`), `Examples.AspNetCore.Zod` (`Purview.Results.ZodSharp.AspNetCore`) and `Examples.ValueObjects.Zod` (`Purview.Results.ZodSharp` + `Purview.ValueObjects`, a type-level `[ZodRule]` whose code and origin flow into the diff --git a/README.md b/README.md index 850768f..e395599 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Exceptional circumstances still throw; expected outcomes are values. | Package | Purpose | Targets | | --- | --- | --- | -| [`Purview.Results`](src/src/Results/Sdk/README.md) | `Result` and its value-less counterpart `Result`, the `Result` factories, and the bundled source generator that emits `AsFailure()` and `AsFailure()` helpers for `[GenerateResult]` unions. No runtime dependencies. | `net11.0` | +| [`Purview.Results`](src/src/Results/Sdk/README.md) | `Result` and its value-less counterpart `Result`, the `Result` factories, and the bundled source generator that emits per-case `AsFailure()`/`AsFailure()` helpers and union-receiver `Union.Failure(...)`/`Union.Success(...)` factories for `[GenerateResult]` unions. No runtime dependencies. | `net11.0` | | [`Purview.Results.ZodSharp`](src/src/ZodSharp/Sdk/README.md) | Bridges ZodSharp `ValidationResult` values into results. | `net11.0` | | [`Purview.Results.AspNetCore`](src/src/AspNetCore/Sdk/README.md) | Maps results onto ASP.NET Core responses (`IResult`, `ProblemDetails`). | `net11.0` | | [`Purview.Results.ZodSharp.AspNetCore`](src/src/ZodSharp.AspNetCore/Sdk/README.md) | Maps result failures that carry validation errors onto `HttpValidationProblemDetails`. | `net11.0` | @@ -45,6 +45,13 @@ public readonly record struct TenantDisabled(TenantId TenantId); public readonly record struct TenantAlreadyExists(TenantId TenantId); ``` +The generator also declares a C# 14 extension block on the union itself, so the union names the error and the +call site stays unambiguous: `TenantError.Failure(tenantNotFound)` produces `Result`, +`TenantError.Failure(tenantNotFound)` produces `Result`, and +`TenantError.Success()` / `TenantError.Success(tenant)` produce the success shapes. This union-receiver factory is +the shared-case safe form: when a case type belongs to more than one union, the per-case `AsFailure` helper is +generated once and reported as `RSG1006`, so the factory is the call that always binds to the union you name. + The generator cannot make a bare case value convert implicitly — C# forbids operators in a static class, conversion operators in extension members, and more than one user-defined conversion per sequence — so the per-case helper is the ergonomics the language allows. `Purview.Results` also ships a code fix for the IDE and @@ -71,7 +78,7 @@ Tenancy domain the Quick start uses, so one error-union vocabulary drives every | Example | Packages | Demonstrates | | --- | --- | --- | -| [`Examples.Basic`](src/src/Examples.Basic) | `Purview.Results` | States, `Match`/`Map`/`Bind`/`MapError`/`Ensure`, probing, the throw-on-misuse contract, the generated `AsFailure()` and `AsFailure()` helpers, cross-service error-union widening, value-less `Result` commands, and `Throw()` | +| [`Examples.Basic`](src/src/Examples.Basic) | `Purview.Results` | States, `Match`/`Map`/`Bind`/`MapError`/`Ensure`, probing, the throw-on-misuse contract, the generated `AsFailure()` and `AsFailure()` helpers and the union-receiver `Union.Failure(...)`/`Union.Success(...)` factories, cross-service error-union widening, value-less `Result` commands, and `Throw()` | | [`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 | @@ -94,7 +101,7 @@ dotnet run --project src/src/Examples.ValueObjects.Zod Result GetTenant(TenantId tenantId) => _tenants.TryGetValue(tenantId, out var tenant) ? Result.Success(tenant) - : new TenantNotFound(tenantId).AsFailure(); + : TenantError.Failure(new TenantNotFound(tenantId)); var loaded = GetTenant(tenantId); diff --git a/docs/wiki/Diagnostics.md b/docs/wiki/Diagnostics.md index 374914a..5807bd1 100644 --- a/docs/wiki/Diagnostics.md +++ b/docs/wiki/Diagnostics.md @@ -20,7 +20,7 @@ The union rules live in one shared library (`Diagnostics/DiagnosticLibrary.cs`, | `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. | +| `RSG1006` | Warning | Generator | No (only the shared case's `AsFailure` helper is skipped) | A case type is shared with another union, so its per-case helper is generated once; the union-receiver `Failure(...)` factory is generated for every union and is the shared-case safe form. | | `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 diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index 50289f1..6a15391 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -36,7 +36,7 @@ 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 a value-producing `AsFailure()` and a non-generic `AsFailure()` in the union's -namespace: +namespace, plus a union-receiver factory on the union type itself: ```csharp public static class TenantErrorResultExtensions @@ -47,9 +47,22 @@ public static class TenantErrorResultExtensions public static Result AsFailure(this TenantNotFound error) => Result.Failure(error); // ... the same pair per case type + + extension(TenantError) + { + public static Result Failure(TenantNotFound error) => + Result.Failure(error); + + public static Result Failure(TenantNotFound error) => + Result.Failure(error); + // ... the same Failure pair per case type, plus Success() and Success(value) + } } ``` +The union-receiver factory names the union, so it stays unambiguous when a case type is shared with another union +(`RSG1006`). + See [Union Errors](Union-Errors.md) for the modelling rules and [Source Generator](Source-Generator.md) for the generated shape. diff --git a/docs/wiki/Guarantees-and-Limitations.md b/docs/wiki/Guarantees-and-Limitations.md index 59a8065..3e816e7 100644 --- a/docs/wiki/Guarantees-and-Limitations.md +++ b/docs/wiki/Guarantees-and-Limitations.md @@ -39,8 +39,9 @@ change must not break. - `[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()` and `AsFailure()` helpers and the IDE code fix are the ergonomics the - language allows; the helper-free alternative is the `(TenantError)caseValue` cast. + rules. The per-case `AsFailure()` and `AsFailure()` helpers, the union-receiver + `Union.Failure(...)`/`Union.Success(...)` factories 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). diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 014314c..5087688 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -50,8 +50,8 @@ This wiki is the project documentation hub. Packages are published under the `Pu - **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()` and `AsFailure()` helpers the language - cannot express itself. + value, and the generator supplies the per-case `AsFailure()` and `AsFailure()` helpers plus the + union-receiver `Union.Failure(...)`/`Union.Success(...)` factories 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 diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md index e472513..641583d 100644 --- a/docs/wiki/Source-Generator.md +++ b/docs/wiki/Source-Generator.md @@ -2,9 +2,9 @@ The [`Purview.Results`](https://www.nuget.org/packages/Purview.Results) package ships an incremental Roslyn source generator that makes C# 15 **union** error cases ergonomic with `Result` and the value-less -`Result`. It generates the `[GenerateResult]` attribute, the `AsFailure()` and `AsFailure()` -helpers for every union case, and the diagnostics that keep unsupported shapes out of a build. It is not published -as a separate package. +`Result`. It generates the `[GenerateResult]` attribute, the per-case `AsFailure()` and `AsFailure()` +helpers, the union-receiver `Union.Failure(...)` and `Union.Success(...)` factories for every union case, and the +diagnostics that keep unsupported shapes out of a build. It is not published as a separate package. ## Installation @@ -69,12 +69,34 @@ public static class TenantErrorResultExtensions Result.Failure(error); // ... the same pair for TenantDisabled and TenantAlreadyExists + + extension(TenantError) + { + public static Result Failure(TenantNotFound error) => + Result.Failure(error); + + public static Result Failure(TenantNotFound error) => + Result.Failure(error); + + // ... the same Failure pair per case ... + + public static Result Success() => + Result.Success(); + + public static Result Success(TValue value) => + Result.Success(value); + } } ``` -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` or `Result`. +The per-case `AsFailure` helpers let the *case* name the call (`new TenantNotFound(id).AsFailure()`); the +union-receiver factory lets the *union* name it (`TenantError.Failure(new TenantNotFound(id))`). The +factory is the **shared-case safe** form: when a case type belongs to more than one union, its per-case `AsFailure` +helper is generated once and reported as `RSG1006`, while the factory is generated for every union. + +The generated helpers are pure static methods that call the existing `Result<...>.Failure`/`.Success` factories: +there is no reflection, no `dynamic`, no runtime type discovery and no mutable static state. The generator does not +change, wrap or replace `Result` or `Result`. ## Build properties diff --git a/docs/wiki/Union-Errors.md b/docs/wiki/Union-Errors.md index 8be5308..dca6912 100644 --- a/docs/wiki/Union-Errors.md +++ b/docs/wiki/Union-Errors.md @@ -21,11 +21,11 @@ public readonly record struct TenantAlreadyExists(TenantId TenantId); 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 +## The generated helpers -For each case type the generator emits two overloads in a `{Union}ResultExtensions` static class in the union's -own namespace — a value-producing `AsFailure()` and a non-generic `AsFailure()` that produces a unit -result: +For each case type the generator emits two per-case overloads in a `{Union}ResultExtensions` static class in the +union's own namespace — a value-producing `AsFailure()` and a non-generic `AsFailure()` that produces a +unit result: ```csharp public static class TenantErrorResultExtensions @@ -52,8 +52,26 @@ Result DeleteTenant(TenantId tenantId) => : new TenantNotFound(tenantId).AsFailure(); ``` -One overload **per case type** is generated 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. +The same class also declares a C# 14 extension block on the union type, so the union can name the call instead of +the case: + +```csharp +Result GetTenant(TenantId tenantId) => + TenantError.Failure(new TenantNotFound(tenantId)); + +Result DeleteTenant(TenantId tenantId) => + TenantError.Failure(new TenantNotFound(tenantId)); + +Result ok = TenantError.Success(); +Result created = TenantError.Success(tenant); +``` + +The per-case `AsFailure` helper is generated **once per case type**, not once per union, because C# does not apply +union conversions to an *instance* extension-method receiver (`CS1929`). A case type shared by two unions therefore +gets a single `AsFailure` helper, reported as `RSG1006`, which can bind to the wrong union. The union-receiver +factory is a **static** extension member (`extension(Union) { public static ... }`): the receiver is the union type +itself and the case is an argument, so it is generated for every union and every case and is the form to use when a +case type is shared. ## Why there is no implicit conversion @@ -92,15 +110,16 @@ Returning a bare case value where a result is expected is a compiler error (`CS0 `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, for a value result -return new TenantNotFound(id).AsFailure(); // after the fix, for a unit result +return new TenantNotFound(id); // CS0029 +return TenantError.Failure(new TenantNotFound(id)); // after the fix, for a value result +return TenantError.Failure(new TenantNotFound(id)); // after the fix, for a unit result ``` A fix is offered only when the rewrite will bind: the converted type is `Purview.Results.Result` or the unit `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. A value result gets `AsFailure()`; a unit result gets the non-generic `AsFailure()`. +site. A value result gets `Union.Failure(case)`; a unit result gets `Union.Failure(case)`. The rewrite names +the union, so it is correct even when the case type is shared with another union. ## Chaining across services @@ -122,14 +141,15 @@ Result Register(TenantId id, string name) => billing.ReserveQuota(id) .Bind(quota => CreateTenant(quota, name), error => error); -// Guard idiom: BillingError is a case of RegisterTenantError, so its generated helper produces the composite -// failure directly. +// Guard idiom: BillingError is a case of RegisterTenantError, so the union-receiver factory produces the +// composite failure directly. if (reserved.TryGetError(out var billingError)) - return billingError.AsFailure(); + return RegisterTenantError.Failure(billingError); ``` List the callee's **union** as the case, not its leaf cases. A leaf listed in two unions is a shared case, which -raises `RSG1006` and generates the helper for only one of them, so a call site can bind to the wrong union. If the +raises `RSG1006` and generates the per-case `AsFailure` helper for only one of them, so a case-receiver call site +can bind to the wrong union; convert it with the union-receiver factory (`CallerUnion.Failure(leaf)`) instead. If the caller must not expose the callee's types at all, map the callee's cases into the caller's own case types with `MapError(error => error switch { ... })` instead — the compiler then forces the mapping to stay exhaustive as the callee's union grows. @@ -144,7 +164,7 @@ mapping for the leaf (`Map`) wins, and a mapping for | --- | --- | | 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 case type shared by two unions | Yes — `RSG1006` warning; the per-case `AsFailure` helper is generated once, and the union-receiver `Failure(...)` factory is generated for both | | 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` | diff --git a/src/src/Examples.Basic/Program.cs b/src/src/Examples.Basic/Program.cs index 7a419db..1bad424 100644 --- a/src/src/Examples.Basic/Program.cs +++ b/src/src/Examples.Basic/Program.cs @@ -65,14 +65,23 @@ ShowResult("implicit error", fromError); ShowResult("error factory", fromFactory); -Heading("7. The generated AsFailure() helper is what makes union cases ergonomic"); +Heading("7. The generated helpers name either the case or the union"); // `return new TenantNotFound(id);` does not compile (CS0029): the case converts to the union and the union -// converts to the result, but C# never composes two user-defined conversions. The generator emits one helper -// per case of every [GenerateResult] union, which is the ergonomics the language allows. +// converts to the result, but C# never composes two user-defined conversions. The generator emits a per-case +// AsFailure helper and a union-receiver factory for every [GenerateResult] union. The factory names the union, +// so it stays unambiguous even when a case type is shared with another union (RSG1006). var helper = new TenantNotFound(missingId).AsFailure(); +var factoryFailure = TenantError.Failure(new TenantNotFound(missingId)); +var factoryUnitFailure = TenantError.Failure(new TenantNotFound(missingId)); +var factorySuccess = TenantError.Success(); +var factoryValueSuccess = TenantError.Success(new Tenant(acmeId, "Acme", Enabled: true)); ShowResult("AsFailure()", helper); +ShowResult("TenantError.Failure()", factoryFailure); +ShowUnit("TenantError.Failure()", factoryUnitFailure); +ShowUnit("TenantError.Success()", factorySuccess); +ShowResult("TenantError.Success(tenant)", factoryValueSuccess); Heading("8. Chain services by widening one error union into another"); diff --git a/src/src/Examples.Basic/TenantError.cs b/src/src/Examples.Basic/TenantError.cs index 6a23074..980a1fa 100644 --- a/src/src/Examples.Basic/TenantError.cs +++ b/src/src/Examples.Basic/TenantError.cs @@ -5,8 +5,9 @@ namespace Purview.Results.Examples.Basic; /// /// /// The union itself is the error type of Result<Tenant, TenantError>. A bare case value cannot -/// convert to the result because C# never composes two user-defined conversions, so the generator emits one -/// AsFailure<TValue>() helper per case of every [GenerateResult] union. +/// convert to the result because C# never composes two user-defined conversions, so the generator emits a +/// per-case AsFailure<TValue>() helper and a union-receiver TenantError.Failure(...) factory +/// for every [GenerateResult] union. /// [GenerateResult] readonly union TenantError(TenantNotFound, TenantDisabled, TenantAlreadyExists); diff --git a/src/src/Examples.Basic/TenantStore.cs b/src/src/Examples.Basic/TenantStore.cs index 656da9d..b066ab7 100644 --- a/src/src/Examples.Basic/TenantStore.cs +++ b/src/src/Examples.Basic/TenantStore.cs @@ -23,7 +23,7 @@ sealed class InMemoryTenantStore public Result GetTenant(TenantId tenantId) => _tenants.TryGetValue(tenantId, out var tenant) ? Result.Success(tenant) - : new TenantNotFound(tenantId).AsFailure(); + : TenantError.Failure(new TenantNotFound(tenantId)); /// /// Creates a tenant. @@ -34,7 +34,7 @@ public Result GetTenant(TenantId tenantId) => public Result CreateTenant(TenantId tenantId, string name) { if (_tenants.ContainsKey(tenantId)) - return new TenantAlreadyExists(tenantId).AsFailure(); + return TenantError.Failure(new TenantAlreadyExists(tenantId)); Tenant tenant = new(tenantId, name, Enabled: true); _tenants[tenantId] = tenant; @@ -51,5 +51,5 @@ public Result CreateTenant(TenantId tenantId, string name) /// failure. /// public Result DeleteTenant(TenantId tenantId) => - _tenants.Remove(tenantId) ? Result.Success() : new TenantNotFound(tenantId).AsFailure(); + _tenants.Remove(tenantId) ? Result.Success() : TenantError.Failure(new TenantNotFound(tenantId)); } diff --git a/src/src/Results/Sdk/.agents/agents/purview-results-union-author.agent.md b/src/src/Results/Sdk/.agents/agents/purview-results-union-author.agent.md index c179a45..5ef6d45 100644 --- a/src/src/Results/Sdk/.agents/agents/purview-results-union-author.agent.md +++ b/src/src/Results/Sdk/.agents/agents/purview-results-union-author.agent.md @@ -19,8 +19,9 @@ You are a specialist for authoring Purview.Results error unions. ## Primary objective Replace ad-hoc failure signalling (thrown domain exceptions, `bool`/`out` pairs, sentinel return values, -error-code enums) with a `union` of focused case types and the generated `AsFailure()` / `AsFailure()` -helpers, so callers match on types instead of parsing strings. +error-code enums) with a `union` of focused case types and the generated union-receiver +`Union.Failure(...)`/`Union.Success(...)` factories (plus the per-case `AsFailure()`/`AsFailure()` +helpers), so callers match on types instead of parsing strings. ## Background knowledge @@ -35,8 +36,9 @@ The most important rules are: defeats the point. - **One union per operation family**, so methods can come and go without changing the error contract. - **Never generate or hand-write implicit conversions.** Five compiler rules block them (`CS0715`, `CS0556`, - `CS9282`, `CS0246`, `CS0029`); the generated `AsFailure()` / `AsFailure()` helpers — or the - `(Union)case` cast — are the whole toolbox. + `CS9282`, `CS0246`, `CS0029`); the generated union-receiver `Union.Failure(...)` factory, the per-case + `AsFailure()` / `AsFailure()` helpers, and the `(Union)case` cast are the whole toolbox. Prefer the + union-receiver factory when a case type is shared with another union (`RSG1006`), because it names the union. - **Keep the result transport-agnostic.** No HTTP types, status codes or `IResult` values in the union. - **`default` is a bug, not a failure.** Never let a method fall off the end of its body. @@ -46,8 +48,8 @@ The most important rules are: needs. 2. Declare the union with `[GenerateResult]` in the same namespace as the cases. 3. Change the signature to `Result` — or the value-less `Result` when the member has - nothing to return on success — and replace throwing exits with `new Case(...).AsFailure()` or - `new Case(...).AsFailure()`. + nothing to return on success — and replace throwing exits with `Union.Failure(case)` or + `Union.Failure(case)` (or the per-case `new Case(...).AsFailure()` / `AsFailure()` helpers). 4. Leave genuinely exceptional exits (`InvalidOperationException`, infrastructure faults) throwing. 5. Keep the call sites honest: unwrap with `Match`/`Map`/`Bind` rather than `if (IsSuccess)` when a value has to come out. @@ -58,7 +60,7 @@ The most important rules are: 1. Load `purview-results-union-errors` before editing. 2. Cases are `readonly record struct`s with meaningful payloads. -3. Every failure is created through a generated helper or the cast form; never by hand-rolled conversion. +3. Every failure is created through a generated factory/helper or the cast form; never by hand-rolled conversion. 4. The affected projects must build with no `RSG1000`–`RSG1007` diagnostics. 5. Update tests alongside the signatures, and add a compiler experiment rather than an assumption when a language behaviour is in question. diff --git a/src/src/Results/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md b/src/src/Results/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md index 1ef6999..50eefc7 100644 --- a/src/src/Results/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md +++ b/src/src/Results/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md @@ -1,6 +1,6 @@ --- agent: ask -description: "Migrate ad-hoc failure signalling (domain exceptions, bool/out pairs, error enums) in the selected files to a C# 15 union of case types with Purview.Results and generated AsFailure() and AsFailure() helpers." +description: "Migrate ad-hoc failure signalling (domain exceptions, bool/out pairs, error enums) in the selected files to a C# 15 union of case types with Purview.Results and the generated union-receiver Union.Failure(...)/Union.Success(...) factories and per-case AsFailure()/AsFailure() helpers." --- You are modernising error handling in this repository. Apply the `purview-results-union-errors` skill for union @@ -30,13 +30,13 @@ case types and a `Result` return type built with the generated h ``` 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 `new Case(...).AsFailure()` or - `new Case(...).AsFailure()`. + 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 when `keepExceptions` is true — a lost connection or a violated invariant is not a domain outcome. 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 helper, or `(TenantError)new TenantNotFound(id)` when a cast reads better. + the generated factory/helper, or `(TenantError)new TenantNotFound(id)` when a cast reads better. 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. diff --git a/src/src/Results/Sdk/.agents/skills/purview-results-core/SKILL.md b/src/src/Results/Sdk/.agents/skills/purview-results-core/SKILL.md index 1dfc468..93719fd 100644 --- a/src/src/Results/Sdk/.agents/skills/purview-results-core/SKILL.md +++ b/src/src/Results/Sdk/.agents/skills/purview-results-core/SKILL.md @@ -36,7 +36,14 @@ Result.Failure(new TenantNotFound(id)); // Implicit conversions (one hop only) Result ok = tenant; -Result failed = new TenantNotFound(id); +TenantError error = new TenantNotFound(id); // case -> union is one hop +Result failed = error; // union -> result is the next hop + +// Union-receiver factory (generated for [GenerateResult] unions; shared-case safe) +TenantError.Failure(new TenantNotFound(id)); +TenantError.Failure(new TenantNotFound(id)); +TenantError.Success(); +TenantError.Success(tenant); ``` The implicit conversions accept `TValue` or `TError` directly. They do **not** compose: a union *case* cannot diff --git a/src/src/Results/Sdk/.agents/skills/purview-results-union-errors/SKILL.md b/src/src/Results/Sdk/.agents/skills/purview-results-union-errors/SKILL.md index 525dc37..a3d1a94 100644 --- a/src/src/Results/Sdk/.agents/skills/purview-results-union-errors/SKILL.md +++ b/src/src/Results/Sdk/.agents/skills/purview-results-union-errors/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-results-union-errors -description: "Use when modelling expected failures as a C# 15 union and converting its cases into Purview.Results values — declaring [GenerateResult] unions, using the generated AsFailure() and AsFailure() helpers, reacting to the RSG1000-RSG1007 diagnostics, or explaining why a union case cannot convert implicitly." +description: "Use when modelling expected failures as a C# 15 union and converting its cases into Purview.Results values — declaring [GenerateResult] unions, using the generated per-case AsFailure()/AsFailure() helpers and the union-receiver Union.Failure(...)/Union.Success(...) factories, reacting to the RSG1000-RSG1007 diagnostics, or explaining why a union case cannot convert implicitly." --- # Modelling error unions for Purview.Results @@ -38,6 +38,20 @@ Result Get(TenantId id) => new TenantNotFound(id).AsFailure Result Delete(TenantId id) => new TenantNotFound(id).AsFailure(); ``` +The same class declares a C# 14 extension block on the union type, so the union can name the call instead of the +case: + +```csharp +Result Get(TenantId id) => TenantError.Failure(new TenantNotFound(id)); +Result Delete(TenantId id) => TenantError.Failure(new TenantNotFound(id)); +Result ok = TenantError.Success(); +Result created = TenantError.Success(tenant); +``` + +Prefer the union-receiver factory when a case type is shared with another union (`RSG1006`): the per-case +`AsFailure` helper is generated once and can bind to the wrong union, while `Union.Failure(...)` always names the +union you want. + ## Why the helper is required The generator cannot declare an implicit conversion from a case to the result, and neither can you. Five @@ -62,9 +76,10 @@ Result Get(TenantId id) => (TenantError)new TenantNotFound( `Purview.Results` also ships an IDE code fix: when you write `return new TenantNotFound(id);` in a `Result`- or `Result`-returning member, the -lightbulb offers the `AsFailure()` or `AsFailure()` rewrite. The fix is only offered when the rewrite -will bind (the converted type is a result, the error type is a `[GenerateResult]` union, the value is one of its -cases, and the union is in scope by simple name). +lightbulb offers the `Union.Failure(case)` or `Union.Failure(case)` rewrite. The fix names the union, so it +is correct even when the case type is shared with another union. It is only offered when the rewrite will bind (the +converted type is a result, the error type is a `[GenerateResult]` union, the value is one of its cases, and the +union is in scope by simple name). ## Chaining across services @@ -80,8 +95,9 @@ Result Register(TenantId id, string name) => .Bind(quota => CreateTenant(quota, name), error => error); // BillingError -> RegisterTenantError ``` -List the union as a case, not its leaf cases: a leaf shared with the callee's union raises `RSG1006` and its helper -is generated for only one of the unions, so a call site can bind to the wrong union. To keep the callee's types out +List the union as a case, not its leaf cases: a leaf shared with the callee's union raises `RSG1006` and its per-case +helper is generated for only one of the unions, so a case-receiver call site can bind to the wrong union; convert it +with the union-receiver factory (`CallerUnion.Failure(leaf)`) instead. To keep the callee's types out of the caller's contract, map its cases into the caller's own case types with `MapError` instead — the compiler then forces the mapping to stay exhaustive as the callee's union grows. The ASP.NET Core mapper resolves a union error to its innermost case, so a leaf mapping wins and an enclosing-union mapping still applies when the leaf has @@ -112,7 +128,7 @@ The package ships an analyzer and a generator that share one diagnostics library | `RSG1003` | Error | A type generated code must reference is not accessible (for example a `file` type) | | `RSG1004` | Error | The same union is configured more than once; the helpers are generated once | | `RSG1005` | Error | Two unions produce the same generated class name in one namespace | -| `RSG1006` | Warning | A case type is shared with another union, so its helper is generated once | +| `RSG1006` | Warning | A case type is shared with another union, so its per-case `AsFailure` helper is generated once; the union-receiver `Failure(...)` factory is generated for every union | | `RSG1007` | Error | The union uses an `IUnionMembers` member provider, which is not supported | `RSG1004` and `RSG1006` are non-blocking: the union (or the shared case) is skipped and reported. @@ -148,7 +164,7 @@ compared by value. 2. The union is a `union` declaration annotated with `[GenerateResult]`. 3. Every method that fails for an expected reason returns `Result`, or `Result` when the success carries no value. -4. Failures are produced with `new Case(...).AsFailure()` (or `AsFailure()` for a unit result, or the cast - form). +4. Failures are produced with `new Case(...).AsFailure()` (or `AsFailure()` for a unit result, the + union-receiver `Union.Failure(case)` / `Union.Failure(case)` factory, or the cast form). 5. The build is clean of `RSG1000`–`RSG1007`. 6. The union is mapped to responses once, in the host (see `purview-results-http-mapping`). diff --git a/src/src/Results/Sdk/README.md b/src/src/Results/Sdk/README.md index 4826bdf..ef92876 100644 --- a/src/src/Results/Sdk/README.md +++ b/src/src/Results/Sdk/README.md @@ -121,6 +121,19 @@ Result GetTenant(TenantId tenantId) => new TenantNotFound(t Result DeleteTenant(TenantId tenantId) => new TenantNotFound(tenantId).AsFailure(); ``` +The generator also declares a C# 14 extension block on the union type itself, so the union names the error: + +```csharp +Result GetTenant(TenantId tenantId) => TenantError.Failure(new TenantNotFound(tenantId)); +Result DeleteTenant(TenantId tenantId) => TenantError.Failure(new TenantNotFound(tenantId)); +Result ok = TenantError.Success(); +Result created = TenantError.Success(tenant); +``` + +Use the union-receiver factory when a case type is shared with another union: the per-case `AsFailure` helper is +generated once (for the union that sorts first) and reported as `RSG1006`, so it can bind to the wrong union, while +`Union.Failure(...)` always names the union you want. + The generator cannot declare that conversion for you: C# forbids user-defined operators in a static class, forbids conversion operators in extension members, and permits only one user-defined conversion per conversion sequence. The same package ships a code fix that offers the rewrite in the IDE when a case value is returned @@ -135,7 +148,8 @@ Result GetTenant(TenantId tenantId) => (TenantError)new Ten When one service calls another, widen the callee's error union into the caller's operation-family union with the widening `Bind`, or lift it at a guard with the generated helper. List the callee's union as a case rather than its -leaf cases, so no case type is shared between the two unions. +leaf cases, so no case type is shared between the two unions; if a case type must be shared, convert it with the +union-receiver `Union.Failure(...)` factory, which is generated for every union. ## Examples diff --git a/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs b/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs index 65d770a..2753555 100644 --- a/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs +++ b/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs @@ -15,9 +15,9 @@ namespace Purview.Results.SourceGenerator.CodeFixes; /// -/// Offers the generated AsFailure<TValue>() and AsFailure() helpers when a union case value -/// is returned where a Result<TValue, TError> or a unit Result<TError> is expected -/// (CS0029). +/// Offers the generated union-receiver factories Union.Failure<TValue>(case) and +/// Union.Failure(case) when a union case value is returned where a +/// Result<TValue, TError> or a unit Result<TError> is expected (CS0029). /// /// /// @@ -28,23 +28,29 @@ namespace Purview.Results.SourceGenerator.CodeFixes; /// recorded in UnionCompilerBehaviourTests in this repository. /// /// +/// The fix names the union by its receiver (Union.Failure(case)) rather than the case, because the +/// union-receiver factory is generated for every case of every union. The per-case AsFailure helpers +/// are generated once when a case type is shared with another union, so a case-receiver rewrite could bind to +/// the wrong union; the factory cannot. +/// +/// /// A fix is only offered when the rewritten call will actually bind: the converted type must be /// Purview.Results.Result<TValue, TError> or Purview.Results.Result<TError>, -/// TError must be a union opted in with [GenerateResult] (so the helper exists), the expression +/// TError must be a union opted in with [GenerateResult] (so the factory exists), the expression /// must be one of that union's case values, and the union must be reachable by its simple name at the call site -/// (the helper is generated into the union's own namespace). A value result gets -/// AsFailure<TValue>(); a unit result gets the non-generic AsFailure(). +/// (the factory is generated into the union's own namespace). A value result gets +/// Union.Failure<TValue>(case); a unit result gets Union.Failure(case). /// /// [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(UnionCaseResultCodeFixProvider)), Shared] public sealed class UnionCaseResultCodeFixProvider : CodeFixProvider { - const string EquivalenceKey = "PurviewResultsUseAsFailure"; + const string EquivalenceKey = "PurviewResultsUseUnionFailureFactory"; const string ResultNamespace = "Purview.Results"; const string ResultMetadataName = "Result`2"; const string ResultUnitMetadataName = "Result`1"; const string GenerateResultAttributeMetadataName = "GenerateResultAttribute"; - const string FailureHelperName = "AsFailure"; + const string FailureFactoryName = "Failure"; /// public override ImmutableArray FixableDiagnosticIds => ["CS0029"]; @@ -83,14 +89,14 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) continue; var title = isUnit - ? $"Convert the union case to a failed unit result with {FailureHelperName}()" - : $"Convert the union case to a failed result with {FailureHelperName}<{valueType!.Name}>()"; + ? $"Convert the union case to a failed unit result with {unionType.Name}.{FailureFactoryName}()" + : $"Convert the union case to a failed result with {unionType.Name}.{FailureFactoryName}<{valueType!.Name}>()"; context.RegisterCodeFix( CodeAction.Create( title: title, createChangedDocument: token => - UseAsFailureAsync(context.Document, expression, isUnit ? null : valueType, token), + UseUnionFactoryAsync(context.Document, expression, unionType, isUnit ? null : valueType, token), equivalenceKey: EquivalenceKey ), diagnostic @@ -185,9 +191,10 @@ static bool IsUnionVisibleBySimpleName(SemanticModel model, INamedTypeSymbol uni static bool HasMetadataName(INamedTypeSymbol type, string @namespace, string metadataName) => type.MetadataName == metadataName && type.ContainingNamespace.ToDisplayString() == @namespace; - static async Task UseAsFailureAsync( + static async Task UseUnionFactoryAsync( Document document, ExpressionSyntax expression, + INamedTypeSymbol unionType, ITypeSymbol? valueType, CancellationToken cancellationToken ) @@ -196,11 +203,16 @@ CancellationToken cancellationToken if (root is null) return document; - // A unit result uses the non-generic AsFailure(); a value result names its value type. - SimpleNameSyntax helperName; + // The union-receiver factory is named on the union type: Union.Failure(case) for a unit result and + // Union.Failure(case) for a value result. + var unionName = SyntaxFactory + .ParseTypeName(unionType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)) + .WithAdditionalAnnotations(Simplifier.Annotation); + + SimpleNameSyntax factoryName; if (valueType is null) { - helperName = SyntaxFactory.IdentifierName(FailureHelperName); + factoryName = SyntaxFactory.IdentifierName(FailureFactoryName); } else { @@ -208,18 +220,17 @@ CancellationToken cancellationToken .ParseTypeName(valueType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)) .WithAdditionalAnnotations(Simplifier.Annotation); - helperName = SyntaxFactory.GenericName( - SyntaxFactory.Identifier(FailureHelperName), + factoryName = SyntaxFactory.GenericName( + SyntaxFactory.Identifier(FailureFactoryName), SyntaxFactory.TypeArgumentList(SyntaxFactory.SingletonSeparatedList(typeArgument)) ); } var invocation = SyntaxFactory .InvocationExpression( - SyntaxFactory.MemberAccessExpression( - SyntaxKind.SimpleMemberAccessExpression, - expression.WithoutTrivia(), - helperName + SyntaxFactory.MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, unionName, factoryName), + SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(expression.WithoutTrivia())) ) ) .WithTriviaFrom(expression) diff --git a/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs b/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs index e247521..53edd25 100644 --- a/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs +++ b/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs @@ -129,11 +129,11 @@ static class DiagnosticLibrary public static readonly DiagnosticDescriptor SharedCaseType = new( id: "RSG1006", title: "Union case type is shared with another union", - messageFormat: "The case type '{0}' is also a case of '{1}', so its result helper is generated for '{1}' only to keep the call site unambiguous", + messageFormat: "The case type '{0}' is also a case of '{1}', so its AsFailure helper is generated for '{1}' only; use the union-receiver factory '{2}.Failure(...)' to convert the case for this union without ambiguity", category: Category, defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true, - description: "A case type may belong to more than one union. Generating the same helper for both would make extension method resolution ambiguous, so the helper is generated once." + description: "A case type may belong to more than one union. Generating the same per-case AsFailure helper for both would make extension method resolution ambiguous, so the helper is generated once. The union-receiver Failure(...) factory is generated for every union, including the shared case, and is the shared-case safe form." ); /// diff --git a/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs b/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs index 2c17ab2..6890293 100644 --- a/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs +++ b/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs @@ -51,6 +51,7 @@ CancellationToken cancellationToken : union.ContainingNamespace.ToDisplayString(); var extensionClassName = CreateExtensionClassName(union); + var cases = CreateCaseModels(analysis.CaseTypes); return GeneratorResult.Create( new ResultUnionModel( @@ -66,7 +67,8 @@ CancellationToken cancellationToken ? extensionClassName + ".g.cs" : namespaceName + "." + extensionClassName + ".g.cs", Location: ToSourceLocation(union), - Cases: CreateCaseModels(analysis.CaseTypes) + Cases: cases, + HelperCases: cases ), diagnostics ); diff --git a/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs b/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs index d364ce7..c65b56d 100644 --- a/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs +++ b/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs @@ -46,7 +46,7 @@ static void EmitUnion(SourceProductionContext context, GeneratorContext generati writer.FileScopedNamespace(union.Namespace); writer.XmlSummary( - $"Provides strongly typed failure helpers that create {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultTypeName))} and {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultUnitTypeName))} values from the union cases of {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}." + $"Provides strongly typed failure and success helpers that create {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultTypeName))} and {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultUnitTypeName))} values from the union cases of {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}." ); writer.Class( @@ -55,8 +55,10 @@ static void EmitUnion(SourceProductionContext context, GeneratorContext generati options => options with { IsStatic = true, IsPartial = false }, body => { - foreach (var unionCase in union.Cases) + foreach (var unionCase in union.HelperCases) EmitHelper(body, union, unionCase); + + EmitUnionFactory(body, union); } ); @@ -146,6 +148,165 @@ options with ); } + /// + /// Emits the union-receiver factory members as a C# 14 extension block on the union type. + /// + /// + /// Naming the union by the receiver (Union.Failure(case)) rather than the case + /// (case.AsFailure<TValue>()) keeps the call site unambiguous when a case type is shared with + /// another union, because each union owns its own factory set. The factory is therefore the shared-case + /// safe form of the per-case helpers. + /// + static void EmitUnionFactory(CodeWriter writer, ResultUnionModel union) + { + writer.ExtensionBlock( + new TypeReference(union.UnionType), + body => + { + foreach (var unionCase in union.Cases) + { + EmitUnitFactory(body, union, unionCase); + EmitValueFactory(body, union, unionCase); + } + + EmitUnitSuccessFactory(body, union); + EmitValueSuccessFactory(body, union); + } + ); + } + + static void EmitUnitFactory(CodeWriter writer, ResultUnionModel union, ResultUnionCaseModel unionCase) + { + var resultType = CreateUnitResultTypeReference(union); + var resultTypeName = resultType.RenderFullName; + + writer.XmlSummary( + $"Creates a failed unit result containing the {XmlInlineCode(DocText.Escape(unionCase.FullyQualifiedName))} union case of {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}." + ); + writer.XmlParam( + PropertyLibrary.ErrorParameterName, + $"The {XmlInlineCode(DocText.Escape(unionCase.FullyQualifiedName))} union case to convert." + ); + writer.XmlReturn( + $"A failed {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultUnitTypeName))} whose error is {XmlParamRef(PropertyLibrary.ErrorParameterName)}." + ); + + writer.Method( + PropertyLibrary.FailureFactoryName, + resultType, + union.Accessibility, + options => + options with + { + IsStatic = true, + Parameters = + [ + new ParameterDeclarationOptions(PropertyLibrary.ErrorParameterName, unionCase.CaseType), + ], + }, + body => body.Return($"{resultTypeName}.Failure({PropertyLibrary.ErrorParameterName})") + ); + } + + static void EmitValueFactory(CodeWriter writer, ResultUnionModel union, ResultUnionCaseModel unionCase) + { + var resultType = CreateResultTypeReference(union); + var resultTypeName = resultType.RenderFullName; + + writer.XmlSummary( + $"Creates a failed result containing the {XmlInlineCode(DocText.Escape(unionCase.FullyQualifiedName))} union case of {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}." + ); + writer.XmlTypeParam( + PropertyLibrary.ValueTypeParameterName, + "The type of the successful value of the created result." + ); + writer.XmlParam( + PropertyLibrary.ErrorParameterName, + $"The {XmlInlineCode(DocText.Escape(unionCase.FullyQualifiedName))} union case to convert." + ); + writer.XmlReturn( + $"A failed {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultTypeName))} whose error is {XmlParamRef(PropertyLibrary.ErrorParameterName)}." + ); + + writer.Method( + PropertyLibrary.FailureFactoryName, + resultType, + union.Accessibility, + options => + options with + { + IsStatic = true, + GenericTypes = [new GenericTypeParameterOptions(PropertyLibrary.ValueTypeParameterName)], + Parameters = + [ + new ParameterDeclarationOptions(PropertyLibrary.ErrorParameterName, unionCase.CaseType), + ], + }, + body => body.Return($"{resultTypeName}.Failure({PropertyLibrary.ErrorParameterName})") + ); + } + + static void EmitUnitSuccessFactory(CodeWriter writer, ResultUnionModel union) + { + var resultType = CreateUnitResultTypeReference(union); + var resultTypeName = resultType.RenderFullName; + + writer.XmlSummary( + $"Creates a successful unit result for {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}." + ); + writer.XmlReturn($"A successful {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultUnitTypeName))}."); + + writer.Method( + PropertyLibrary.SuccessFactoryName, + resultType, + union.Accessibility, + options => options with { IsStatic = true }, + body => body.Return($"{resultTypeName}.Success()") + ); + } + + static void EmitValueSuccessFactory(CodeWriter writer, ResultUnionModel union) + { + var resultType = CreateResultTypeReference(union); + var resultTypeName = resultType.RenderFullName; + + writer.XmlSummary( + $"Creates a successful result carrying a value for {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}." + ); + writer.XmlTypeParam( + PropertyLibrary.ValueTypeParameterName, + "The type of the successful value of the created result." + ); + writer.XmlParam(PropertyLibrary.ValueParameterName, "The successful value."); + writer.XmlReturn( + $"A successful {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultTypeName))} carrying {XmlParamRef(PropertyLibrary.ValueParameterName)}." + ); + + writer.Method( + PropertyLibrary.SuccessFactoryName, + resultType, + union.Accessibility, + options => + options with + { + IsStatic = true, + GenericTypes = [new GenericTypeParameterOptions(PropertyLibrary.ValueTypeParameterName)], + Parameters = + [ + // The method's own type parameter is referenced as a literal named argument, the same + // representation the generic result type reference uses: the framework's + // TypeReference.ForTypeParameter carries no identity, and the writer's parameter + // validation rejects a parameter whose reference has no identity name. + new ParameterDeclarationOptions( + PropertyLibrary.ValueParameterName, + new TypeReference(new TypeIdentity(PropertyLibrary.ValueTypeParameterName, null)) + ), + ], + }, + body => body.Return($"{resultTypeName}.Success({PropertyLibrary.ValueParameterName})") + ); + } + /// /// Creates the Result<TValue, TUnion> reference for a union, using the framework's /// structured type-reference abstraction so generic arguments, nesting and namespace qualification are diff --git a/src/src/SourceGenerator/Helpers/PropertyLibrary.cs b/src/src/SourceGenerator/Helpers/PropertyLibrary.cs index 670aca7..5f12fdb 100644 --- a/src/src/SourceGenerator/Helpers/PropertyLibrary.cs +++ b/src/src/SourceGenerator/Helpers/PropertyLibrary.cs @@ -36,6 +36,18 @@ static class PropertyLibrary /// public const string FailureHelperName = "AsFailure"; + /// + /// The name of the generated union-receiver failure factory method, declared as a static extension + /// member on the union type so the union is named by the receiver. + /// + public const string FailureFactoryName = "Failure"; + + /// + /// The name of the generated union-receiver success factory method, declared as a static extension + /// member on the union type so the union is named by the receiver. + /// + public const string SuccessFactoryName = "Success"; + /// /// The name of the generic value type parameter used by the generated helpers. /// @@ -46,6 +58,11 @@ static class PropertyLibrary /// public const string ErrorParameterName = "error"; + /// + /// The name of the value parameter used by the generated success factory. + /// + public const string ValueParameterName = "value"; + /// /// The fully-qualified name of the runtime Result<TValue, TError> type, used when the /// generated source is described in XML documentation. The value is raw, so callers escape it with diff --git a/src/src/SourceGenerator/Models/ResultUnionModel.cs b/src/src/SourceGenerator/Models/ResultUnionModel.cs index 34423ec..d513841 100644 --- a/src/src/SourceGenerator/Models/ResultUnionModel.cs +++ b/src/src/SourceGenerator/Models/ResultUnionModel.cs @@ -72,7 +72,14 @@ readonly record struct ResultUnionCaseModel(TypeReference CaseType, string Fully /// The deterministic hint name of the generated source file. /// The declaration location of the union. /// -/// The union's case types in deterministic (ordinal, fully-qualified name) order. +/// The union's full set of case types in deterministic (ordinal, fully-qualified name) order. The +/// union-receiver factory is generated for every one of these, including a case type shared with another +/// union. +/// +/// +/// The subset of whose per-case AsFailure helpers are generated for this +/// union. A case type shared with another union is owned by the union that sorts first, so it is absent here +/// for the others; the union-receiver factory still covers it through . /// readonly record struct ResultUnionModel( string Namespace, @@ -83,7 +90,8 @@ readonly record struct ResultUnionModel( string ExtensionClassName, string HintName, ResultSourceLocation Location, - EquatableArray Cases + EquatableArray Cases, + EquatableArray HelperCases ) { /// diff --git a/src/src/SourceGenerator/README.md b/src/src/SourceGenerator/README.md index a5965ee..45c33e5 100644 --- a/src/src/SourceGenerator/README.md +++ b/src/src/SourceGenerator/README.md @@ -56,6 +56,47 @@ Result result = new TenantNotFound(tenantId).AsFailure unitResult = new TenantNotFound(tenantId).AsFailure(); ``` +The same class also declares a C# 14 extension block on the union type itself, so a case can be converted by +naming the union (`TenantError.Failure(case)`) rather than the case: + +```csharp +public static class TenantErrorResultExtensions +{ + // ... the per-case AsFailure helpers above ... + + extension(TenantError) + { + public static Result Failure(TenantNotFound error) => + Result.Failure(error); + + public static Result Failure(TenantNotFound error) => + Result.Failure(error); + + // ... one Failure pair per case: TenantDisabled, TenantAlreadyExists ... + + public static Result Success() => + Result.Success(); + + public static Result Success(TValue value) => + Result.Success(value); + } +} +``` + +Usage: + +```csharp +Result result = TenantError.Failure(new TenantNotFound(tenantId)); +Result unitResult = TenantError.Failure(new TenantNotFound(tenantId)); +Result success = TenantError.Success(); +Result valueSuccess = TenantError.Success(tenant); +``` + +The union-receiver factory is the **shared-case safe** form. When a case type is a case of more than one union, the +per-case `AsFailure` helper is generated once — for the union that sorts first — and reported as `RSG1006`, so a +case-receiver call can bind to the wrong union. The factory names the union by its receiver, so it is generated for +every union and covers every one of the union's cases, including a shared one. + ## Why the generator exists C# composes a union *case* into its union and `Result` composes the *union* into a result, @@ -151,7 +192,7 @@ The diagnostics live in one shared library (`Diagnostics/DiagnosticLibrary.cs` + | `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. | +| `RSG1006` | Warning | Generator | No (only the shared case's `AsFailure` helper is skipped) | A case type is shared with another union, so its per-case helper is generated once; the union-receiver `Failure(...)` factory is generated for every union and is the shared-case safe form. | | `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 @@ -213,23 +254,24 @@ consumers too. ## Call-site ergonomics: the code fix -Because the generator cannot make a case value convert (`AsFailure()` and `AsFailure()` are the best the -language allows), the package also ships an IDE code fix: `UnionCaseResultCodeFixProvider` in the companion -`SourceGenerator.CodeFixes` component (`src/src/SourceGenerator.CodeFixes`). +Because the generator cannot make a case value convert (`Union.Failure(...)` and the per-case `AsFailure()` +are the best the language allows), the package also ships an IDE code fix: `UnionCaseResultCodeFixProvider` in the +companion `SourceGenerator.CodeFixes` component (`src/src/SourceGenerator.CodeFixes`). It answers the compiler's `CS0029` ("cannot implicitly convert") and rewrites a returned case value into the -generated helper, so `return new TenantNotFound(id);` becomes -`return new TenantNotFound(id).AsFailure();` from the lightbulb, or `...AsFailure();` when the method -returns a unit `Result`. +union-receiver factory, so `return new TenantNotFound(id);` becomes +`return TenantError.Failure(new TenantNotFound(id));` from the lightbulb, or +`TenantError.Failure(new TenantNotFound(id))` when the method returns a unit `Result`. Naming the union +keeps the rewrite correct even when the case type is shared with another union (`RSG1006`). A fix is only offered when the rewrite will bind: | Guard | Why | | --- | --- | -| The converted type is `Purview.Results.Result` or the unit `Purview.Results.Result` | Only results have generated helpers; a value result gets `AsFailure()` and a unit result the non-generic `AsFailure()` | -| `TError` is a union **and** opted in with `[GenerateResult]` | The helper is only generated for opted-in unions | -| The expression's type is one of `TError`'s case types | The helper exists once per case | -| The union is reachable by its simple name at the call site | The helper class is generated into the union's own namespace | +| The converted type is `Purview.Results.Result` or the unit `Purview.Results.Result` | Only results have generated factories; a value result gets `Failure(case)` and a unit result `Failure(case)` | +| `TError` is a union **and** opted in with `[GenerateResult]` | The factory is only generated for opted-in unions | +| The expression's type is one of `TError`'s case types | The factory exists once per case | +| The union is reachable by its simple name at the call site | The factory extension block is generated into the union's own namespace | The component is not packable on its own: it needs `Microsoft.CodeAnalysis.CSharp.Workspaces`, which only the IDE host provides, so `Purview.Results` packs it into `analyzers/dotnet/cs/` as a second analyzer assembly diff --git a/src/src/SourceGenerator/Validation/ResultUnionValidator.cs b/src/src/SourceGenerator/Validation/ResultUnionValidator.cs index 3af4415..225aa79 100644 --- a/src/src/SourceGenerator/Validation/ResultUnionValidator.cs +++ b/src/src/SourceGenerator/Validation/ResultUnionValidator.cs @@ -79,7 +79,7 @@ GeneratorContext context generatedTypes[generatedTypeKey] = union; - var cases = ImmutableArray.CreateBuilder(union.Cases.Count); + var helperCases = ImmutableArray.CreateBuilder(union.Cases.Count); foreach (var unionCase in union.Cases) { @@ -91,23 +91,20 @@ GeneratorContext context DiagnosticScope.Compilation, union, unionCase.FullyQualifiedName, - caseOwner.FullyQualifiedName + caseOwner.FullyQualifiedName, + union.FullyQualifiedName ); continue; } caseOwners[unionCase.CaseType] = union; - cases.Add(unionCase); + helperCases.Add(unionCase); } - if (cases.Count == 0) - { - // Every case was either shared with another union (reported above) or the union has no cases - // (reported by the analyzer); either way there is nothing left to generate for this union. - continue; - } - - unions.Add(cases.Count == union.Cases.Count ? union : union with { Cases = cases.ToImmutable() }); + // The union is always generated: its union-receiver factory covers every case in union.Cases, even + // one shared with another union. Only the shared case's per-case AsFailure helper is dropped, which + // is why the union keeps its own HelperCases subset. + unions.Add(union with { HelperCases = helperCases.ToImmutable() }); } return new ResultUnionGenerationModel(context, unions.ToImmutable(), diagnostics.ToImmutable()); diff --git a/src/tests/SourceGenerator.UnitTests/GeneratedResultsRuntimeTests.cs b/src/tests/SourceGenerator.UnitTests/GeneratedResultsRuntimeTests.cs index 2514d54..e4b3de4 100644 --- a/src/tests/SourceGenerator.UnitTests/GeneratedResultsRuntimeTests.cs +++ b/src/tests/SourceGenerator.UnitTests/GeneratedResultsRuntimeTests.cs @@ -149,4 +149,67 @@ public async Task AsFailureUnit_GivenDisabledCase_ProducesAFailureWhoseToStringN await Assert.That(result.IsFailure).IsTrue(); await Assert.That(result.ToString()).StartsWith("Failure("); } + + [Test] + public async Task UnionFactoryFailure_GivenNotFoundCase_ProducesAUnitFailure() + { + // Arrange + TenantNotFound tenantNotFound = new(new TenantId("tenant-1")); + + // Act + // The explicit type is the compile-time proof that the union-receiver factory produces exactly a + // Result for the union named by the receiver. +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result result = TenantError.Failure(tenantNotFound); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error is TenantNotFound).IsTrue(); + } + + [Test] + public async Task UnionFactoryValueFailure_GivenNotFoundCase_ProducesAValueFailure() + { + // Arrange + TenantNotFound tenantNotFound = new(new TenantId("tenant-1")); + + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result result = TenantError.Failure(tenantNotFound); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error is TenantNotFound).IsTrue(); + } + + [Test] + public async Task UnionFactorySuccess_ProducesAUnitSuccess() + { + // Arrange + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result result = TenantError.Success(); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task UnionFactoryValueSuccess_GivenValue_ProducesAValueSuccess() + { + // Arrange + Tenant tenant = new(new TenantId("tenant-1")); + + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result result = TenantError.Success(tenant); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(tenant); + } } diff --git a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorDiagnosticsTests.cs b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorDiagnosticsTests.cs index 29813b8..5e5d998 100644 --- a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorDiagnosticsTests.cs +++ b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorDiagnosticsTests.cs @@ -197,6 +197,13 @@ namespace Test await Assert.That(auditSource).Contains("this global::Test.NotFound error"); await Assert.That(tenantSource).Contains("this global::Test.Disabled error"); await Assert.That(tenantSource).DoesNotContain("this global::Test.NotFound error"); + + // The shared case's per-case AsFailure helper is generated once, but each union still owns its + // union-receiver factory, which is the shared-case safe form. + await Assert.That(auditSource).Contains("> Failure("); + await Assert.That(auditSource).Contains("global::Test.NotFound error"); + await Assert.That(tenantSource).Contains("> Failure("); + await Assert.That(tenantSource).Contains("global::Test.NotFound error"); } [Test] diff --git a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs index 9ca539b..a8331a7 100644 --- a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs @@ -78,6 +78,85 @@ await Assert .Contains("global::Purview.Results.Result.Failure(error)"); } + [Test] + public async Task GenerateAsync_GivenOptedInUnion_EmitsUnionFactoryExtensionBlock( + CancellationToken cancellationToken + ) + { + // Arrange + // Act + var result = await GenerateAsync(TenantUnionSource, cancellationToken); + + // Assert + // The union-receiver factory is a C# 14 static extension block on the union type, so a case can be + // converted by naming the union (Union.Failure(case)) rather than the case. The writer wraps the long + // parameter list onto its own line, so the assertions target the signature head and the parameter. + var generated = result.AssertSingleGeneratedSource(); + await Assert.That(generated).Contains("extension(global::Test.TenantError)"); + await Assert.That(generated).Contains("> Failure("); + await Assert.That(generated).Contains("> Failure("); + await Assert.That(generated).Contains("global::Test.TenantNotFound error"); + await Assert.That(generated).Contains("> Success()"); + await Assert.That(generated).Contains("> Success("); + await Assert.That(generated).Contains("Result.Success()"); + await Assert + .That(generated) + .Contains("global::Purview.Results.Result.Success(value)"); + } + + [Test] + public async Task GenerateAsync_GivenSharedCaseType_EmitsTheUnionFactoryForBothUnions( + CancellationToken cancellationToken + ) + { + // Arrange + // NotFound is shared by both unions (RSG1006), so its per-case AsFailure helper is generated once — + // but each union still owns its factory, which is the shared-case safe form. + const string source = """ + using Purview.Results; + + namespace Test + { + public readonly record struct NotFound(int Id); + + public readonly record struct Rejected(string Reason); + + [GenerateResult] + public readonly union GetError(NotFound); + + [GenerateResult] + public readonly union SetError(NotFound, Rejected); + + public static class Usage + { + public static Result Unit(NotFound error) => SetError.Failure(error); + + public static Result Value(NotFound error) => SetError.Failure(error); + } + } + """; + + // Act + var result = await GenerateAsync(source, cancellationToken); + + // Assert + result.AssertNoCompilationErrors(); + + var setTree = result.PrimarySyntaxTrees.Single(static tree => + tree.FilePath.EndsWith("SetErrorResultExtensions.g.cs", StringComparison.Ordinal) + ); + var setSource = (await setTree.GetTextAsync(cancellationToken)).ToString(); + + // The shared case's factory is present on the union that lost the AsFailure helper. + await Assert.That(setSource).Contains("> Failure("); + await Assert.That(setSource).Contains("> Failure("); + await Assert.That(setSource).Contains("global::Test.NotFound error"); + + // The per-case AsFailure helper is generated once, for GetError (the ordinal winner). + await Assert.That(setSource).DoesNotContain("this global::Test.NotFound error"); + await Assert.That(setSource).Contains("this global::Test.Rejected error"); + } + [Test] public async Task GenerateAsync_GivenOptedInUnion_EmitsNullableEnabledFullyQualifiedSource( CancellationToken cancellationToken diff --git a/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs b/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs index 4f33a86..f95a45d 100644 --- a/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs +++ b/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs @@ -44,7 +44,7 @@ public static Result GetTenant(TenantId tenantId) var result = await UnionCodeFixTestHarness.ApplyAsync(source, cancellationToken); await Assert.That(result.FixOffered).IsTrue(); - await Assert.That(result.FixedCode).Contains(".AsFailure()"); + await Assert.That(result.FixedCode).Contains("TenantError.Failure("); await Assert.That(result.RewriteCompiles).IsTrue(); } @@ -72,8 +72,8 @@ public static Result DeleteTenant(TenantId tenantId) var result = await UnionCodeFixTestHarness.ApplyAsync(source, cancellationToken); await Assert.That(result.FixOffered).IsTrue(); - await Assert.That(result.FixedCode).Contains(".AsFailure()"); - await Assert.That(result.FixedCode).DoesNotContain(".AsFailure<"); + await Assert.That(result.FixedCode).Contains("TenantError.Failure("); + await Assert.That(result.FixedCode).DoesNotContain("Failure<"); await Assert.That(result.RewriteCompiles).IsTrue(); } @@ -99,7 +99,7 @@ public static Result GetTenant(TenantId tenantId) => var result = await UnionCodeFixTestHarness.ApplyAsync(source, cancellationToken); await Assert.That(result.FixOffered).IsTrue(); - await Assert.That(result.FixedCode).Contains(".AsFailure()"); + await Assert.That(result.FixedCode).Contains("TenantError.Failure("); await Assert.That(result.RewriteCompiles).IsTrue(); } @@ -226,4 +226,43 @@ public static class Usage await Assert.That(result.FixOffered).IsFalse(); } + + [Test] + public async Task UnionCaseResultCodeFixProvider_GivenCaseSharedWithAnotherUnion_ShouldRewriteToTheNamedUnionFactory( + CancellationToken cancellationToken + ) + { + // NotFound is shared with GetError, so the per-case AsFailure helper is generated for GetError (the + // ordinal winner). A case-receiver rewrite would bind to the wrong union; naming SetError cannot. + const string source = """ + using Purview.Results; + + namespace Test + { + public readonly record struct NotFound(int Id); + + public readonly record struct Rejected(string Reason); + + [GenerateResult] + public readonly union GetError(NotFound); + + [GenerateResult] + public readonly union SetError(NotFound, Rejected); + + public static class Usage + { + public static Result Set(NotFound error) + { + return error; + } + } + } + """; + + var result = await UnionCodeFixTestHarness.ApplyAsync(source, cancellationToken); + + await Assert.That(result.FixOffered).IsTrue(); + await Assert.That(result.FixedCode).Contains("SetError.Failure(error)"); + await Assert.That(result.RewriteCompiles).IsTrue(); + } } From 2d494171f1b8268848f7813d739db7d6ab596c24 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 5 Oct 2026 17:58:55 +0100 Subject: [PATCH 07/11] refactor: improved multi-leaf union support --- AGENTS.md | 24 +- README.md | 6 +- docs/wiki/Diagnostics.md | 9 +- docs/wiki/Getting-Started.md | 5 +- docs/wiki/Home.md | 2 +- docs/wiki/Release-Flow.md | 2 +- docs/wiki/Source-Generator.md | 16 +- docs/wiki/Union-Errors.md | 34 ++- src/src/Examples.Basic/BillingError.cs | 7 +- src/src/Examples.Basic/Program.cs | 7 + .../purview-results-union-author.agent.md | 9 +- ...e-error-returns-to-result-unions.prompt.md | 2 +- .../purview-results-union-errors/SKILL.md | 23 +- src/src/Results/Sdk/README.md | 15 +- .../UnionCaseResultCodeFixProvider.cs | 35 ++- .../AnalyzerReleases.Unshipped.md | 1 + .../Diagnostics/DiagnosticLibrary.cs | 17 ++ .../Diagnostics/ResultUnionDiagnostics.cs | 205 ++++++++++++++++++ .../Discovery/ResultUnionDiscovery.cs | 36 ++- .../Emit/ResultUnionEmitter.cs | 99 +++++++++ .../Models/ResultUnionModel.cs | 45 +++- src/src/SourceGenerator/README.md | 37 +++- .../Validation/ResultUnionValidator.cs | 79 +++++-- .../GeneratedResultsRuntimeTests.cs | 31 +++ .../GeneratedTestUnions.cs | 6 +- .../ResultsDiagnosticAnalyzerTests.cs | 46 +++- .../ResultsSourceGeneratorTests.cs | 205 ++++++++++++++++++ .../UnionCaseResultCodeFixProviderTests.cs | 43 ++++ .../UnionCompilerBehaviourTests.cs | 31 +++ 29 files changed, 983 insertions(+), 94 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c3bfba0..95b43cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,9 +93,9 @@ including the diagnostics table, build properties and activation rules. - **One analyzer, one generator, one diagnostics library.** `Diagnostics/DiagnosticLibrary.cs`, `Diagnostics/ResultDiagnostic.cs` and `Diagnostics/ResultUnionDiagnostics.cs` hold the single implementation - of the union rules. The analyzer reports the per-target rules (`RSG1000`–`RSG1004`, `RSG1007`); the generator - reports the compilation-wide rules (`RSG1005`, `RSG1006`). Never report the same rule from both hosts, and - keep `DiagnosticLibrary.IsBlocking` as the single blocking policy. + of the union rules. The analyzer reports the per-target rules (`RSG1000`–`RSG1004`, `RSG1007`, `RSG1008`); the + generator reports the compilation-wide rules (`RSG1005`, `RSG1006`). Never report the same rule from both + hosts, and keep `DiagnosticLibrary.IsBlocking` as the single blocking policy. - New or changed rules require an `AnalyzerReleases.Unshipped.md` entry (the compiler's RS2008 rule catalogue). - **One suppressor.** `Suppressors/UnionEqualityDiagnosticSuppressor.cs` is the only programmatic suppression in this repository: it suppresses `CA1815` and only `CA1815`, and only on a declaration that is both a union @@ -137,10 +137,20 @@ including the diagnostics table, build properties and activation rules. - Also emit the **union-receiver factory** as a C# 14 extension block on the union type (`extension(TUnion) { public static ... }`): `Failure(case)`/`Failure(case)` and `Success()`/ `Success(value)`. The factory names the union by its receiver, so it is the shared-case safe form: a - case type shared with another union (`RSG1006`) has its per-case `AsFailure` helper generated once, but every - union's factory covers all of its own cases. `ResultUnionModel.Cases` is the union's full case set (used by the - factory) and `ResultUnionModel.HelperCases` is the deduplicated subset (used by `AsFailure`); never conflate the - two. The code fix rewrites `CS0029` to the factory, not to `AsFailure`, so it cannot bind to the wrong union. + leaf case type shared with another union (`RSG1006`) has its per-case `AsFailure` helper generated once, but + every union's factory covers all of its own cases. `ResultUnionModel.Cases` is the union's full case set (used + by the factory) and `ResultUnionModel.HelperCases` is the deduplicated subset (used by `AsFailure`); never + conflate the two. The code fix rewrites `CS0029` to the factory, not to `AsFailure`, so it cannot bind to the + wrong union. +- **Union inclusion.** A case type that is itself a union is an *included union*: the including union's factory + also covers every case reachable through it, constructing the nested value (`new Outer(new Inner(leaf))`). + `ResultUnionModel.IncludedCases` carries that expansion (the case plus its innermost-first construction path) + and the emitter writes one factory pair per included case. Inclusion is automatic for every union-typed case and + is the supported way to avoid `RSG1006`. A union-typed case shared by two unions is composition, not a shared + leaf: it is **not** reported as `RSG1006` and its per-case `AsFailure` helper is not generated for any owner. A + case reachable through two different included unions is reported as `RSG1008` and its inclusion factory is + skipped, because no single construction path can be named. Expansion walks the union's own case symbols during + `ResultUnionDiagnostics.Analyze`, so it needs no opted-in included union and no symbol in cached state. - Adding a code fix means adding it to `src/src/SourceGenerator.CodeFixes`, not to the generator project: the code fix needs `Microsoft.CodeAnalysis.*.Workspaces`, which must not enter the generator's analyzer closure. The two projects stay independent (no project reference, no `InternalsVisibleTo`) so no reference cycle can diff --git a/README.md b/README.md index e395599..099801f 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,10 @@ The generator also declares a C# 14 extension block on the union itself, so the call site stays unambiguous: `TenantError.Failure(tenantNotFound)` produces `Result`, `TenantError.Failure(tenantNotFound)` produces `Result`, and `TenantError.Success()` / `TenantError.Success(tenant)` produce the success shapes. This union-receiver factory is -the shared-case safe form: when a case type belongs to more than one union, the per-case `AsFailure` helper is -generated once and reported as `RSG1006`, so the factory is the call that always binds to the union you name. +the shared-case safe form: when a **leaf** case type belongs to more than one union, the per-case `AsFailure` +helper is generated once and reported as `RSG1006`, so the factory is the call that always binds to the union you +name. A case type that is itself a union is an *included union*: the factory also covers its cases and builds the +nested value, so composition replaces leaf sharing and no `RSG1006` is raised for it. The generator cannot make a bare case value convert implicitly — C# forbids operators in a static class, conversion operators in extension members, and more than one user-defined conversion per sequence — so the diff --git a/docs/wiki/Diagnostics.md b/docs/wiki/Diagnostics.md index 5807bd1..490bff3 100644 --- a/docs/wiki/Diagnostics.md +++ b/docs/wiki/Diagnostics.md @@ -3,8 +3,8 @@ 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. +- **`ResultsDiagnosticAnalyzer` reports the per-target rules** (`RSG1000`–`RSG1004`, `RSG1007`, `RSG1008`) 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 @@ -20,8 +20,9 @@ The union rules live in one shared library (`Diagnostics/DiagnosticLibrary.cs`, | `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 `AsFailure` helper is skipped) | A case type is shared with another union, so its per-case helper is generated once; the union-receiver `Failure(...)` factory is generated for every union and is the shared-case safe form. | +| `RSG1006` | Warning | Generator | No (only the shared leaf case's `AsFailure` helper is skipped) | A **leaf** case type is shared with another union, so its per-case helper is generated once; the union-receiver `Failure(...)` factory is generated for every union and is the shared-case safe form. A union-typed case shared across unions is composition, not a shared leaf, and is not reported. | | `RSG1007` | Error | Analyzer | Yes | The union uses an `IUnionMembers` member provider, which the first implementation does not support. | +| `RSG1008` | Warning | Analyzer | No (only the ambiguous case's inclusion factory is skipped) | A case is reachable through two different included unions, so no inclusion factory can name its construction path. List the case through the union that directly declares it instead. | 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 @@ -47,7 +48,7 @@ The suppression is narrow on purpose: - 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. + suppression id rather than a rule, so `RSG1000`–`RSG1008` 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 diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index 6a15391..57debc9 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -60,8 +60,9 @@ public static class TenantErrorResultExtensions } ``` -The union-receiver factory names the union, so it stays unambiguous when a case type is shared with another union -(`RSG1006`). +The union-receiver factory names the union, so it stays unambiguous when a leaf case type is shared with another +union (`RSG1006`). A case type that is itself a union is an *included union*: the factory also covers its cases and +builds the nested value, so list the callee's union as a case instead of repeating its leaf cases. See [Union Errors](Union-Errors.md) for the modelling rules and [Source Generator](Source-Generator.md) for the generated shape. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 5087688..6ec37bc 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -52,7 +52,7 @@ This wiki is the project documentation hub. Packages are published under the `Pu - **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()` and `AsFailure()` helpers plus the union-receiver `Union.Failure(...)`/`Union.Success(...)` factories the language cannot express itself. -- **Compile-time diagnostics** — `RSG1000`–`RSG1007` report unsupported union shapes at build time, and the one +- **Compile-time diagnostics** — `RSG1000`–`RSG1008` 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. diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index 2b84548..d8b2a84 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -56,7 +56,7 @@ The framework assembly must never appear loose beside the merged analyzer — `F ## Analyzer release tracking -The generator bundled in `Purview.Results` ships public diagnostics (`RSG1000`–`RSG1007`), so it maintains the +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 `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. diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md index 641583d..e789792 100644 --- a/docs/wiki/Source-Generator.md +++ b/docs/wiki/Source-Generator.md @@ -3,8 +3,9 @@ The [`Purview.Results`](https://www.nuget.org/packages/Purview.Results) package ships an incremental Roslyn source generator that makes C# 15 **union** error cases ergonomic with `Result` and the value-less `Result`. It generates the `[GenerateResult]` attribute, the per-case `AsFailure()` and `AsFailure()` -helpers, the union-receiver `Union.Failure(...)` and `Union.Success(...)` factories for every union case, and the -diagnostics that keep unsupported shapes out of a build. It is not published as a separate package. +helpers, the union-receiver `Union.Failure(...)` and `Union.Success(...)` factories for every union case (including +the cases reachable through an included union), and the diagnostics that keep unsupported shapes out of a build. It +is not published as a separate package. ## Installation @@ -91,8 +92,15 @@ public static class TenantErrorResultExtensions The per-case `AsFailure` helpers let the *case* name the call (`new TenantNotFound(id).AsFailure()`); the union-receiver factory lets the *union* name it (`TenantError.Failure(new TenantNotFound(id))`). The -factory is the **shared-case safe** form: when a case type belongs to more than one union, its per-case `AsFailure` -helper is generated once and reported as `RSG1006`, while the factory is generated for every union. +factory is the **shared-case safe** form: when a **leaf** case type belongs to more than one union, its per-case +`AsFailure` helper is generated once and reported as `RSG1006`, while the factory is generated for every union. + +A case type that is itself a union is an **included union**. The factory then also covers every case reachable +through it and constructs the nested value (`RegisterTenantError.Failure(new BillingAccountMissing(id))` +builds `RegisterTenantError(BillingError(BillingAccountMissing))`). Inclusion is automatic and is the supported way +to avoid `RSG1006`: list the callee's union as a case instead of repeating its leaf cases. A union-typed case shared +by two unions is composition, so it is not reported as `RSG1006` and its ambiguous per-case helper is not generated +at all. A case reachable through two different included unions is reported as `RSG1008` and skipped. The generated helpers are pure static methods that call the existing `Result<...>.Failure`/`.Success` factories: there is no reflection, no `dynamic`, no runtime type discovery and no mutable static state. The generator does not diff --git a/docs/wiki/Union-Errors.md b/docs/wiki/Union-Errors.md index dca6912..6e8e806 100644 --- a/docs/wiki/Union-Errors.md +++ b/docs/wiki/Union-Errors.md @@ -67,11 +67,17 @@ Result created = TenantError.Success(tenant); ``` The per-case `AsFailure` helper is generated **once per case type**, not once per union, because C# does not apply -union conversions to an *instance* extension-method receiver (`CS1929`). A case type shared by two unions therefore -gets a single `AsFailure` helper, reported as `RSG1006`, which can bind to the wrong union. The union-receiver -factory is a **static** extension member (`extension(Union) { public static ... }`): the receiver is the union type -itself and the case is an argument, so it is generated for every union and every case and is the form to use when a -case type is shared. +union conversions to an *instance* extension-method receiver (`CS1929`). A **leaf** case type shared by two unions +therefore gets a single `AsFailure` helper, reported as `RSG1006`, which can bind to the wrong union. The +union-receiver factory is a **static** extension member (`extension(Union) { public static ... }`): the receiver is +the union type itself and the case is an argument, so it is generated for every union and every case and is the form +to use when a case type is shared. + +A case type that is itself a union is an **included union**, and sharing it is composition rather than a shared +leaf: the including union's factory also covers every case reachable through it (constructing the nested value +`new Outer(new Inner(leaf))`), no `RSG1006` is reported, and no ambiguous per-case helper is generated for it. A +case reachable through two different included unions cannot name a construction path, so it is reported as +`RSG1008` and skipped. ## Why there is no implicit conversion @@ -147,12 +153,14 @@ if (reserved.TryGetError(out var billingError)) return RegisterTenantError.Failure(billingError); ``` -List the callee's **union** as the case, not its leaf cases. A leaf listed in two unions is a shared case, which -raises `RSG1006` and generates the per-case `AsFailure` helper for only one of them, so a case-receiver call site -can bind to the wrong union; convert it with the union-receiver factory (`CallerUnion.Failure(leaf)`) instead. If the -caller must not expose the callee's types at all, map the callee's cases into the caller's own case types with -`MapError(error => error switch { ... })` instead — the compiler then forces the mapping to stay exhaustive as the -callee's union grows. +List the callee's **union** as the case, not its leaf cases. The union's own cases are then reachable through the +including union's factory, so `RegisterTenantError.Failure(new BillingAccountMissing(id))` constructs the +nested value directly. A leaf listed in two unions is a shared case, which raises `RSG1006` and generates the +per-case `AsFailure` helper for only one of them, so a case-receiver call site can bind to the wrong union; convert +it with the union-receiver factory (`CallerUnion.Failure(leaf)`) instead. A union-typed case shared by two unions is +composition, so it is not reported and its per-case helper is not generated at all. If the caller must not expose +the callee's types at all, map the callee's cases into the caller's own case types with `MapError(error => error +switch { ... })` instead — the compiler then forces the mapping to stay exhaustive as the callee's union grows. Nesting also works with the HTTP layer: the ASP.NET Core mapper resolves a union error to its innermost case, so a mapping for the leaf (`Map`) wins, and a mapping for the enclosing union @@ -164,7 +172,9 @@ mapping for the leaf (`Map`) wins, and a mapping for | --- | --- | | 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 per-case `AsFailure` helper is generated once, and the union-receiver `Failure(...)` factory is generated for both | +| A **leaf** case type shared by two unions | Yes — `RSG1006` warning; the per-case `AsFailure` helper is generated once, and the union-receiver `Failure(...)` factory is generated for both | +| A **union-typed** case shared by two unions (composition) | Yes — no warning; the including union's factory covers the included union's cases, and no ambiguous per-case helper is generated | +| A case reachable through two different included unions | Yes — `RSG1008` warning; that case's inclusion factory is skipped | | 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` | diff --git a/src/src/Examples.Basic/BillingError.cs b/src/src/Examples.Basic/BillingError.cs index fcc015c..83fa20a 100644 --- a/src/src/Examples.Basic/BillingError.cs +++ b/src/src/Examples.Basic/BillingError.cs @@ -27,9 +27,10 @@ namespace Purview.Results.Examples.Basic; /// service's error union, each as one case. /// /// -/// The case types are the two unions rather than their leaf cases, so a billing failure lifts into this -/// contract with one union conversion and no case type is shared with (which would -/// raise RSG1006). +/// The case types are the two unions rather than their leaf cases. A union-typed case is an included +/// union: the generator's factory covers the included union's cases and constructs the nested value +/// (RegisterTenantError.Failure<Tenant>(new BillingAccountMissing(id))), so no leaf case is shared +/// with and RSG1006 is never raised. /// [GenerateResult] readonly union RegisterTenantError(TenantError, BillingError); diff --git a/src/src/Examples.Basic/Program.cs b/src/src/Examples.Basic/Program.cs index 1bad424..98e7c4f 100644 --- a/src/src/Examples.Basic/Program.cs +++ b/src/src/Examples.Basic/Program.cs @@ -95,6 +95,13 @@ ShowRegistration("Register(hooli) again", registration.Register(hooliId, "Hooli")); ShowRegistration("guard(hooli)", registration.RegisterWithGuard(hooliId, "Hooli")); +// BillingAccountMissing is a case of BillingError, which RegisterTenantError includes, so the outer union's +// factory covers it directly and constructs the nested value RegisterTenantError(BillingError(...)). +ShowRegistration( + "inclusion(BillingAccountMissing)", + RegisterTenantError.Failure(new BillingAccountMissing(hooliId)) +); + Heading("9. Throw turns an expected failure into an exception at a boundary that must throw"); try diff --git a/src/src/Results/Sdk/.agents/agents/purview-results-union-author.agent.md b/src/src/Results/Sdk/.agents/agents/purview-results-union-author.agent.md index 5ef6d45..71f503b 100644 --- a/src/src/Results/Sdk/.agents/agents/purview-results-union-author.agent.md +++ b/src/src/Results/Sdk/.agents/agents/purview-results-union-author.agent.md @@ -1,6 +1,6 @@ --- name: Purview Results Union Author -description: "Specialist for modelling expected failures as C# 15 unions and wiring them into Purview.Results — declaring [GenerateResult] unions, replacing exception or boolean returns with Result, and resolving the RSG1000-RSG1007 diagnostics." +description: "Specialist for modelling expected failures as C# 15 unions and wiring them into Purview.Results — declaring [GenerateResult] unions, replacing exception or boolean returns with Result, and resolving the RSG1000-RSG1008 diagnostics." tools: [ "search/codebase", @@ -35,10 +35,13 @@ The most important rules are: - **One case type per meaning.** A case with a `string Reason` or an `int Code` invites string/int matching and defeats the point. - **One union per operation family**, so methods can come and go without changing the error contract. +- **Compose unions, do not repeat 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 only applies to a shared **leaf** case. - **Never generate or hand-write implicit conversions.** Five compiler rules block them (`CS0715`, `CS0556`, `CS9282`, `CS0246`, `CS0029`); the generated union-receiver `Union.Failure(...)` factory, the per-case `AsFailure()` / `AsFailure()` helpers, and the `(Union)case` cast are the whole toolbox. Prefer the - union-receiver factory when a case type is shared with another union (`RSG1006`), because it names the union. + union-receiver factory when a leaf case type is shared with another union (`RSG1006`), because it names the union. - **Keep the result transport-agnostic.** No HTTP types, status codes or `IResult` values in the union. - **`default` is a bug, not a failure.** Never let a method fall off the end of its body. @@ -61,7 +64,7 @@ The most important rules are: 1. Load `purview-results-union-errors` before editing. 2. Cases are `readonly record struct`s with meaningful payloads. 3. Every failure is created through a generated factory/helper or the cast form; never by hand-rolled conversion. -4. The affected projects must build with no `RSG1000`–`RSG1007` diagnostics. +4. The affected projects must build with no `RSG1000`–`RSG1008` diagnostics. 5. Update tests alongside the signatures, and add a compiler experiment rather than an assumption when a language behaviour is in question. 6. Do not edit generated code; change the union or the generator input instead. diff --git a/src/src/Results/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md b/src/src/Results/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md index 50eefc7..bc9e8cd 100644 --- a/src/src/Results/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md +++ b/src/src/Results/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md @@ -52,7 +52,7 @@ case types and a `Result` return type built with the generated h ### Verification -- Build the solution and confirm no `RSG1000`–`RSG1007` diagnostics. +- 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. diff --git a/src/src/Results/Sdk/.agents/skills/purview-results-union-errors/SKILL.md b/src/src/Results/Sdk/.agents/skills/purview-results-union-errors/SKILL.md index a3d1a94..988dcfe 100644 --- a/src/src/Results/Sdk/.agents/skills/purview-results-union-errors/SKILL.md +++ b/src/src/Results/Sdk/.agents/skills/purview-results-union-errors/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-results-union-errors -description: "Use when modelling expected failures as a C# 15 union and converting its cases into Purview.Results values — declaring [GenerateResult] unions, using the generated per-case AsFailure()/AsFailure() helpers and the union-receiver Union.Failure(...)/Union.Success(...) factories, reacting to the RSG1000-RSG1007 diagnostics, or explaining why a union case cannot convert implicitly." +description: "Use when modelling expected failures as a C# 15 union and converting its cases into Purview.Results values — declaring [GenerateResult] unions, using the generated per-case AsFailure()/AsFailure() helpers and the union-receiver Union.Failure(...)/Union.Success(...) factories, reacting to the RSG1000-RSG1008 diagnostics, or explaining why a union case cannot convert implicitly." --- # Modelling error unions for Purview.Results @@ -50,7 +50,8 @@ Result created = TenantError.Success(tenant); Prefer the union-receiver factory when a case type is shared with another union (`RSG1006`): the per-case `AsFailure` helper is generated once and can bind to the wrong union, while `Union.Failure(...)` always names the -union you want. +union you want. A case type that is itself a union is an *included union*: sharing it is composition, so it is not +reported, and the factory also covers the included union's cases by building the nested value. ## Why the helper is required @@ -95,10 +96,12 @@ Result Register(TenantId id, string name) => .Bind(quota => CreateTenant(quota, name), error => error); // BillingError -> RegisterTenantError ``` -List the union as a case, not its leaf cases: a leaf shared with the callee's union raises `RSG1006` and its per-case -helper is generated for only one of the unions, so a case-receiver call site can bind to the wrong union; convert it -with the union-receiver factory (`CallerUnion.Failure(leaf)`) instead. To keep the callee's types out -of the caller's contract, map its cases into the caller's own case types with `MapError` instead — the compiler +List the union as a case, not its leaf cases: the caller's union-receiver factory then covers the callee's cases and +builds the nested value (`CallerUnion.Failure(leaf)`), so no leaf is shared and no `RSG1006` is raised. A leaf listed +in two unions is a shared leaf: its per-case helper is generated for only one of them, so a case-receiver call site +can bind to the wrong union; convert it with the union-receiver factory instead. A case reachable through two +different included unions is reported as `RSG1008` and its inclusion factory is skipped. To keep the callee's types +out of the caller's contract, map its cases into the caller's own case types with `MapError` instead — the compiler then forces the mapping to stay exhaustive as the callee's union grows. The ASP.NET Core mapper resolves a union error to its innermost case, so a leaf mapping wins and an enclosing-union mapping still applies when the leaf has none. @@ -128,10 +131,12 @@ The package ships an analyzer and a generator that share one diagnostics library | `RSG1003` | Error | A type generated code must reference is not accessible (for example a `file` type) | | `RSG1004` | Error | The same union is configured more than once; the helpers are generated once | | `RSG1005` | Error | Two unions produce the same generated class name in one namespace | -| `RSG1006` | Warning | A case type is shared with another union, so its per-case `AsFailure` helper is generated once; the union-receiver `Failure(...)` factory is generated for every union | +| `RSG1006` | Warning | A **leaf** case type is shared with another union, so its per-case `AsFailure` helper is generated once; the union-receiver `Failure(...)` factory is generated for every union. A union-typed case shared across unions is composition and is not reported | | `RSG1007` | Error | The union uses an `IUnionMembers` member provider, which is not supported | +| `RSG1008` | Warning | A case is reachable through two different included unions, so no inclusion factory can name its construction path; that case's inclusion factory is skipped | -`RSG1004` and `RSG1006` are non-blocking: the union (or the shared case) is skipped and reported. +`RSG1004`, `RSG1006` and `RSG1008` are non-blocking: the union (or the shared/ambiguous case) is skipped and +reported. Unsupported by design: `IUnionMembers` member providers (`RSG1007`) and generic unions (`RSG1002`). @@ -166,5 +171,5 @@ compared by value. success carries no value. 4. Failures are produced with `new Case(...).AsFailure()` (or `AsFailure()` for a unit result, the union-receiver `Union.Failure(case)` / `Union.Failure(case)` factory, or the cast form). -5. The build is clean of `RSG1000`–`RSG1007`. +5. The build is clean of `RSG1000`–`RSG1008`. 6. The union is mapped to responses once, in the host (see `purview-results-http-mapping`). diff --git a/src/src/Results/Sdk/README.md b/src/src/Results/Sdk/README.md index ef92876..529aa14 100644 --- a/src/src/Results/Sdk/README.md +++ b/src/src/Results/Sdk/README.md @@ -130,9 +130,12 @@ Result ok = TenantError.Success(); Result created = TenantError.Success(tenant); ``` -Use the union-receiver factory when a case type is shared with another union: the per-case `AsFailure` helper is -generated once (for the union that sorts first) and reported as `RSG1006`, so it can bind to the wrong union, while -`Union.Failure(...)` always names the union you want. +Use the union-receiver factory when a **leaf** case type is shared with another union: the per-case `AsFailure` +helper is generated once (for the union that sorts first) and reported as `RSG1006`, so it can bind to the wrong +union, while `Union.Failure(...)` always names the union you want. A case type that is itself a union is an +*included union*: sharing it is composition, so it is not reported, and the factory also covers the included +union's cases by building the nested value +(`RegisterTenantError.Failure(new BillingAccountMissing(id))`). The generator cannot declare that conversion for you: C# forbids user-defined operators in a static class, forbids conversion operators in extension members, and permits only one user-defined conversion per conversion @@ -148,8 +151,10 @@ Result GetTenant(TenantId tenantId) => (TenantError)new Ten When one service calls another, widen the callee's error union into the caller's operation-family union with the widening `Bind`, or lift it at a guard with the generated helper. List the callee's union as a case rather than its -leaf cases, so no case type is shared between the two unions; if a case type must be shared, convert it with the -union-receiver `Union.Failure(...)` factory, which is generated for every union. +leaf cases: the caller's factory then covers the callee's cases by constructing the nested value, and a leaf is +never shared. If a **leaf** case type must be shared, convert it with the union-receiver `Union.Failure(...)` +factory, which is generated for every union. A case reachable through two different included unions is reported as +`RSG1008` and skipped. ## Examples diff --git a/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs b/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs index 2753555..12ecf30 100644 --- a/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs +++ b/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs @@ -37,8 +37,9 @@ namespace Purview.Results.SourceGenerator.CodeFixes; /// A fix is only offered when the rewritten call will actually bind: the converted type must be /// Purview.Results.Result<TValue, TError> or Purview.Results.Result<TError>, /// TError must be a union opted in with [GenerateResult] (so the factory exists), the expression -/// must be one of that union's case values, and the union must be reachable by its simple name at the call site -/// (the factory is generated into the union's own namespace). A value result gets +/// must be one of that union's case values — including a case reached through an included union, because the +/// factory covers those too — and the union must be reachable by its simple name at the call site (the factory +/// is generated into the union's own namespace). A value result gets /// Union.Failure<TValue>(case); a unit result gets Union.Failure(case). /// /// @@ -158,17 +159,35 @@ static bool IsCaseOf(INamedTypeSymbol union, ITypeSymbol? caseType) if (caseType is null) return false; + HashSet visited = [with(SymbolEqualityComparer.Default)]; + + return IsCaseOf(union, caseType, visited); + } + + static bool IsCaseOf(INamedTypeSymbol union, ITypeSymbol caseType, HashSet visited) + { + // A case type that is itself a union is an included union: its own cases are reachable through the + // union-receiver factory of the outer union too, so the rewrite binds for them as well. The visited + // set keeps a recursive union graph from looping. + if (!visited.Add(union)) + return false; + foreach (var constructor in union.InstanceConstructors) { if ( - constructor.DeclaredAccessibility == Accessibility.Public - && constructor.Parameters.Length == 1 - && constructor.Parameters[0].RefKind is RefKind.None or RefKind.In - && SymbolEqualityComparer.Default.Equals(constructor.Parameters[0].Type, caseType) + constructor.DeclaredAccessibility != Accessibility.Public + || constructor.Parameters.Length != 1 + || constructor.Parameters[0].RefKind is not (RefKind.None or RefKind.In) ) - { + continue; + + var parameterType = constructor.Parameters[0].Type; + + if (SymbolEqualityComparer.Default.Equals(parameterType, caseType)) + return true; + + if (parameterType is INamedTypeSymbol { IsUnion: true } included && IsCaseOf(included, caseType, visited)) return true; - } } return false; diff --git a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md index 2989dda..b5b2953 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md @@ -13,3 +13,4 @@ RSG1004 | Purview.Results.Usage | Error | Duplicate GenerateResult configuration RSG1005 | Purview.Results.Usage | Error | Generated helper class name collision RSG1006 | Purview.Results.Usage | Warning | Union case type is shared with another union RSG1007 | Purview.Results.Usage | Error | Unsupported union member provider +RSG1008 | Purview.Results.Usage | Warning | Union inclusion case is ambiguous diff --git a/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs b/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs index 53edd25..78a7ce3 100644 --- a/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs +++ b/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs @@ -149,6 +149,20 @@ static class DiagnosticLibrary description: "The first implementation resolves union cases from the union type's own public single-parameter constructors." ); + /// + /// A case is reachable through more than one of a union's included unions, so no inclusion factory can + /// be generated for it unambiguously. + /// + public static readonly DiagnosticDescriptor AmbiguousIncludedCase = new( + id: "RSG1008", + title: "Union inclusion case is ambiguous", + messageFormat: "The case type '{0}' is reachable through more than one included union of '{1}', so no inclusion factory is generated for it; convert it through the union that directly declares it instead", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "A union may include another union as a case, which exposes the included union's cases through the union-receiver factory. When the same case type is reachable through two included unions, no single factory can name the construction path, so the case is skipped and reported. Only that case's inclusion factory is skipped; the union is still generated." + ); + /// /// Gets every rule in the catalogue, which is exactly the analyzer's supported diagnostics. /// @@ -162,6 +176,7 @@ static class DiagnosticLibrary GeneratedClassNameCollision, SharedCaseType, UnsupportedMemberProvider, + AmbiguousIncludedCase, ]; /// @@ -188,6 +203,7 @@ static class DiagnosticLibrary /// RSG1005 (generated class-name collision): blocking for the colliding union, which is skipped. /// RSG1006 (shared case type): non-blocking; only the shared case's helper is skipped. /// RSG1007 (member provider): nothing can be generated. + /// RSG1008 (ambiguous included case): non-blocking; only the ambiguous case's inclusion factory is skipped. /// /// /// @@ -207,6 +223,7 @@ public static bool IsBlocking(DiagnosticDescriptor descriptor, DiagnosticScope s "RSG1005" => true, "RSG1006" => false, "RSG1007" => true, + "RSG1008" => false, _ => descriptor.DefaultSeverity == DiagnosticSeverity.Error, }; } diff --git a/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs b/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs index 0e4727b..c241ec2 100644 --- a/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs +++ b/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs @@ -8,6 +8,18 @@ namespace Purview.Results.SourceGenerator.Diagnostics; +/// +/// Describes one case a union exposes through an included (union-typed) case rather than declaring it +/// directly, together with the chain of unions that must be constructed to reach it. +/// +/// The transitively reachable case type. +/// +/// The chain of unions that must be constructed to reach the case, innermost first. The declaring union is +/// not part of the path; the generated factory wraps the case in each path union and finally in the +/// declaring union. +/// +readonly record struct ResultUnionIncludedCase(INamedTypeSymbol CaseType, ImmutableArray UnionPath); + /// /// The outcome of analyzing one opted-in target, shared by the analyzer (which reports /// ) and the source generator (which uses them to decide whether the target can be @@ -19,6 +31,9 @@ namespace Purview.Results.SourceGenerator.Diagnostics; /// Whether the union is public. /// Whether every usable case type is public. /// The usable case types, in constructor order. +/// +/// The case types reachable through the union's included (union-typed) cases, in discovery order. +/// /// Every finding, in discovery order. readonly record struct ResultUnionTargetAnalysis( bool HasGenerateResultAttribute, @@ -27,6 +42,7 @@ readonly record struct ResultUnionTargetAnalysis( bool UnionIsPublic, bool EveryCaseIsPublic, ImmutableArray CaseTypes, + ImmutableArray IncludedCases, ImmutableArray Diagnostics ) { @@ -134,6 +150,7 @@ public static ResultUnionTargetAnalysis Analyze(INamedTypeSymbol symbol, Cancell UnionIsPublic: false, EveryCaseIsPublic: false, CaseTypes: [], + IncludedCases: [], Diagnostics: [] ); } @@ -151,6 +168,7 @@ public static ResultUnionTargetAnalysis Analyze(INamedTypeSymbol symbol, Cancell UnionIsPublic: false, EveryCaseIsPublic: false, CaseTypes: [], + IncludedCases: [], Diagnostics: [ ResultDiagnostic.Create( @@ -223,12 +241,21 @@ public static ResultUnionTargetAnalysis Analyze(INamedTypeSymbol symbol, Cancell UnionIsPublic: unionIsPublic, EveryCaseIsPublic: false, CaseTypes: [], + IncludedCases: [], Diagnostics: diagnostics.ToImmutable() ); } var everyCaseIsPublic = true; var caseTypes = AnalyzeCases(symbol, diagnostics, ref everyCaseIsPublic, cancellationToken); + var includedCases = AnalyzeIncludedCases( + symbol, + caseTypes, + attributeLocation, + diagnostics, + ref everyCaseIsPublic, + cancellationToken + ); if (caseTypes.Length == 0) { @@ -249,6 +276,7 @@ public static ResultUnionTargetAnalysis Analyze(INamedTypeSymbol symbol, Cancell UnionIsPublic: unionIsPublic, EveryCaseIsPublic: everyCaseIsPublic, CaseTypes: caseTypes, + IncludedCases: includedCases, Diagnostics: diagnostics.ToImmutable() ); } @@ -312,6 +340,183 @@ CancellationToken cancellationToken return caseTypes.ToImmutable(); } + /// + /// Expands the union's included (union-typed) cases transitively, so the union-receiver factory can + /// convert a case of an included union by naming this union. + /// + /// + /// + /// A union-typed case is an inclusion: the value is wrapped in the included union, so the + /// runtime value is nested (Outer(Inner(leaf))). The recorded path is that nesting, innermost + /// first, and the generated factory wraps the case in each union in order. + /// + /// + /// A case reachable through two different included unions cannot be constructed unambiguously; it is + /// reported as RSG1008 and its inclusion factory is skipped. A case whose construction path + /// passes through an ambiguous union is skipped for the same reason. + /// + /// + static ImmutableArray AnalyzeIncludedCases( + INamedTypeSymbol union, + ImmutableArray caseTypes, + Location reportLocation, + ImmutableArray.Builder diagnostics, + ref bool everyCaseIsPublic, + CancellationToken cancellationToken + ) + { + HashSet directCases = new(SymbolEqualityComparer.Default); + foreach (var caseType in caseTypes) + directCases.Add(caseType); + + Dictionary> paths = new(SymbolEqualityComparer.Default); + HashSet ambiguous = [with(SymbolEqualityComparer.Default)]; + HashSet stack = [with(SymbolEqualityComparer.Default)]; + + foreach (var caseType in caseTypes) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (caseType.IsUnion) + Walk(caseType, []); + } + + if (paths.Count == 0) + return []; + + var results = ImmutableArray.CreateBuilder(paths.Count); + + foreach ( + var pair in paths.OrderBy(static pair => pair.Key.ToDisplayString(DisplayFormat), StringComparer.Ordinal) + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var caseType = pair.Key; + var path = pair.Value; + + // A case is ambiguous when it is reachable through two included unions, or when any union in its + // construction path is itself ambiguous: the second path would construct a different value. + if (ambiguous.Contains(caseType) || path.Any(ambiguous.Contains)) + continue; + + // Every type the generated factory references must be referenceable from generated code; a + // non-public one narrows the generated class rather than dropping the case. + var referenceable = TryGetReferenceableAccessibility(caseType, out var caseIsPublic); + foreach (var pathUnion in path) + { + if (!TryGetReferenceableAccessibility(pathUnion, out var pathIsPublic)) + { + referenceable = false; + break; + } + + caseIsPublic &= pathIsPublic; + } + + if (!referenceable) + continue; + + everyCaseIsPublic &= caseIsPublic; + results.Add(new ResultUnionIncludedCase(caseType, path)); + } + + foreach (var caseType in ambiguous) + { + diagnostics.Add( + ResultDiagnostic.Create( + DiagnosticLibrary.AmbiguousIncludedCase, + DiagnosticScope.Union, + reportLocation, + caseType.ToDisplayString(DisplayFormat), + union.ToDisplayString(DisplayFormat) + ) + ); + } + + return results.ToImmutable(); + + void Walk(INamedTypeSymbol currentUnion, ImmutableArray parentPath) + { + cancellationToken.ThrowIfCancellationRequested(); + + // The declaring union is never its own inclusion, and a union already on the current path is a + // cycle: neither can be constructed. + if (SymbolEqualityComparer.Default.Equals(currentUnion, union)) + return; + + if (!stack.Add(currentUnion)) + return; + + foreach (var caseType in GetUsableCaseTypes(currentUnion)) + { + if ( + SymbolEqualityComparer.Default.Equals(caseType, union) + || directCases.Contains(caseType) + || stack.Contains(caseType) + ) + continue; + + var path = ImmutableArray.Create(currentUnion).AddRange(parentPath); + + if (paths.TryGetValue(caseType, out var existing)) + { + if (!PathsEqual(existing, path)) + ambiguous.Add(caseType); + + continue; + } + + paths.Add(caseType, path); + + if (caseType.IsUnion) + Walk(caseType, path); + } + + stack.Remove(currentUnion); + } + } + + /// + /// Resolves an included union's usable case types without reporting findings, which are reported when + /// that union is itself analyzed as an opted-in target. + /// + static ImmutableArray GetUsableCaseTypes(INamedTypeSymbol union) + { + var caseTypes = ImmutableArray.CreateBuilder(); + + foreach (var constructor in union.InstanceConstructors) + { + if (!IsCaseConstructor(constructor)) + continue; + + var caseType = constructor.Parameters[0].Type; + if (ContainsTypeParameters(caseType)) + continue; + + if (!TryGetReferenceableAccessibility(caseType, out _)) + continue; + + caseTypes.Add((INamedTypeSymbol)caseType); + } + + return caseTypes.ToImmutable(); + } + + static bool PathsEqual(ImmutableArray left, ImmutableArray right) + { + if (left.Length != right.Length) + return false; + + for (var index = 0; index < left.Length; index++) + { + if (!SymbolEqualityComparer.Default.Equals(left[index], right[index])) + return false; + } + + return true; + } + /// /// Determines whether a constructor follows the language's union case pattern: a public, single /// by-value (or in) parameter constructor whose parameter type is therefore a case type. diff --git a/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs b/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs index 6890293..9dfa421 100644 --- a/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs +++ b/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs @@ -5,6 +5,10 @@ using Purview.Results.SourceGenerator.Helpers; using Purview.Results.SourceGenerator.Models; +// The language's union support is a preview feature of C# 15; ITypeSymbol.IsUnion is marked +// [Experimental] until the feature ships, and this discovery deliberately targets it. +#pragma warning disable RSEXPERIMENTAL006 + namespace Purview.Results.SourceGenerator.Discovery; /// @@ -52,6 +56,7 @@ CancellationToken cancellationToken var extensionClassName = CreateExtensionClassName(union); var cases = CreateCaseModels(analysis.CaseTypes); + var includedCases = CreateIncludedCaseModels(analysis.IncludedCases); return GeneratorResult.Create( new ResultUnionModel( @@ -68,7 +73,8 @@ CancellationToken cancellationToken : namespaceName + "." + extensionClassName + ".g.cs", Location: ToSourceLocation(union), Cases: cases, - HelperCases: cases + HelperCases: cases, + IncludedCases: includedCases ), diagnostics ); @@ -95,7 +101,33 @@ static EquatableArray CreateCaseModels(ImmutableArray string.CompareOrdinal(left.FullyQualifiedName, right.FullyQualifiedName)); + } + + static EquatableArray CreateIncludedCaseModels( + ImmutableArray includedCases + ) + { + if (includedCases.IsDefaultOrEmpty) + return EquatableArray.Empty; + + var cases = ImmutableArray.CreateBuilder(includedCases.Length); + + foreach (var includedCase in includedCases) + { + cases.Add( + new IncludedUnionCaseModel( + TypeReference.Create(includedCase.CaseType), + includedCase.CaseType.ToDisplayString(ResultUnionDiagnostics.DisplayFormat), + includedCase.UnionPath.Select(static unionType => new TypeIdentity(unionType)).ToImmutableArray() ) ); } diff --git a/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs b/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs index c65b56d..90f7ff7 100644 --- a/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs +++ b/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs @@ -169,6 +169,12 @@ static void EmitUnionFactory(CodeWriter writer, ResultUnionModel union) EmitValueFactory(body, union, unionCase); } + foreach (var includedCase in union.IncludedCases) + { + EmitIncludedUnitFactory(body, union, includedCase); + EmitIncludedValueFactory(body, union, includedCase); + } + EmitUnitSuccessFactory(body, union); EmitValueSuccessFactory(body, union); } @@ -246,6 +252,99 @@ options with ); } + /// + /// Emits the unit factory for a case reached through an included union, constructing the nested union + /// value so the case can be converted by naming this union. + /// + static void EmitIncludedUnitFactory(CodeWriter writer, ResultUnionModel union, IncludedUnionCaseModel includedCase) + { + var resultType = CreateUnitResultTypeReference(union); + var resultTypeName = resultType.RenderFullName; + + writer.XmlSummary( + $"Creates a failed unit result containing the {XmlInlineCode(DocText.Escape(includedCase.FullyQualifiedName))} case of {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}, reached through the union(s) that include it." + ); + writer.XmlParam( + PropertyLibrary.ErrorParameterName, + $"The {XmlInlineCode(DocText.Escape(includedCase.FullyQualifiedName))} case to convert." + ); + writer.XmlReturn( + $"A failed {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultUnitTypeName))} whose error is {XmlParamRef(PropertyLibrary.ErrorParameterName)}." + ); + + writer.Method( + PropertyLibrary.FailureFactoryName, + resultType, + union.Accessibility, + options => + options with + { + IsStatic = true, + Parameters = + [ + new ParameterDeclarationOptions(PropertyLibrary.ErrorParameterName, includedCase.CaseType), + ], + }, + body => body.Return($"{resultTypeName}.Failure({RenderIncludedValue(union, includedCase)})") + ); + } + + /// + /// Emits the value factory for a case reached through an included union, constructing the nested union + /// value so the case can be converted by naming this union. + /// + static void EmitIncludedValueFactory(CodeWriter writer, ResultUnionModel union, IncludedUnionCaseModel includedCase) + { + var resultType = CreateResultTypeReference(union); + var resultTypeName = resultType.RenderFullName; + + writer.XmlSummary( + $"Creates a failed result containing the {XmlInlineCode(DocText.Escape(includedCase.FullyQualifiedName))} case of {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}, reached through the union(s) that include it." + ); + writer.XmlTypeParam( + PropertyLibrary.ValueTypeParameterName, + "The type of the successful value of the created result." + ); + writer.XmlParam( + PropertyLibrary.ErrorParameterName, + $"The {XmlInlineCode(DocText.Escape(includedCase.FullyQualifiedName))} case to convert." + ); + writer.XmlReturn( + $"A failed {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultTypeName))} whose error is {XmlParamRef(PropertyLibrary.ErrorParameterName)}." + ); + + writer.Method( + PropertyLibrary.FailureFactoryName, + resultType, + union.Accessibility, + options => + options with + { + IsStatic = true, + GenericTypes = [new GenericTypeParameterOptions(PropertyLibrary.ValueTypeParameterName)], + Parameters = + [ + new ParameterDeclarationOptions(PropertyLibrary.ErrorParameterName, includedCase.CaseType), + ], + }, + body => body.Return($"{resultTypeName}.Failure({RenderIncludedValue(union, includedCase)})") + ); + } + + /// + /// Renders the nested union value for a case reached through an included union: the case wrapped in each + /// path union innermost first, and finally in the declaring union. + /// + static string RenderIncludedValue(ResultUnionModel union, IncludedUnionCaseModel includedCase) + { + var current = PropertyLibrary.ErrorParameterName; + + foreach (var unionType in includedCase.UnionPath) + current = $"new {unionType.RenderFullName}({current})"; + + return $"new {union.UnionType.RenderFullName}({current})"; + } + static void EmitUnitSuccessFactory(CodeWriter writer, ResultUnionModel union) { var resultType = CreateUnitResultTypeReference(union); diff --git a/src/src/SourceGenerator/Models/ResultUnionModel.cs b/src/src/SourceGenerator/Models/ResultUnionModel.cs index d513841..5d50aad 100644 --- a/src/src/SourceGenerator/Models/ResultUnionModel.cs +++ b/src/src/SourceGenerator/Models/ResultUnionModel.cs @@ -48,7 +48,11 @@ public Location ToLocation() => /// /// The fully-qualified display name of the case type, used for diagnostics and deterministic ordering. /// -readonly record struct ResultUnionCaseModel(TypeReference CaseType, string FullyQualifiedName) +/// +/// Whether the case type is itself a union. A union-typed case is an included union: its own +/// cases are reachable through it, and sharing it across unions is the intended composition pattern. +/// +readonly record struct ResultUnionCaseModel(TypeReference CaseType, string FullyQualifiedName, bool IsUnionCase) { /// /// Gets an empty case model. @@ -56,6 +60,31 @@ readonly record struct ResultUnionCaseModel(TypeReference CaseType, string Fully public static readonly ResultUnionCaseModel Empty; } +/// +/// Describes one case that a union exposes through an included union rather than declaring directly. +/// +/// +/// The reference to the transitively reachable case type. +/// +/// +/// The fully-qualified display name of the case type, used for deterministic ordering. +/// +/// +/// The chain of unions that must be constructed to reach the case, innermost first. The generated +/// factory wraps the case in each union in order and finally in the declaring union. +/// +readonly record struct IncludedUnionCaseModel( + TypeReference CaseType, + string FullyQualifiedName, + EquatableArray UnionPath +) +{ + /// + /// Gets an empty included case model. + /// + public static readonly IncludedUnionCaseModel Empty; +} + /// /// An immutable, value-equatable description of one opted-in union containing only the information /// required to generate its result helpers. @@ -78,8 +107,15 @@ readonly record struct ResultUnionCaseModel(TypeReference CaseType, string Fully /// /// /// The subset of whose per-case AsFailure helpers are generated for this -/// union. A case type shared with another union is owned by the union that sorts first, so it is absent here -/// for the others; the union-receiver factory still covers it through . +/// union. A leaf case type shared with another union is owned by the union that sorts first, so it is +/// absent here for the others; a union-typed case shared with another union is absent for every union, +/// because its per-case helper would be ambiguous and the union-receiver factory is the safe form. The +/// union-receiver factory still covers every case through . +/// +/// +/// The case types reachable through the union's included (union-typed) cases, in deterministic order. The +/// union-receiver factory is generated for each of these as well, so a case of an included union can be +/// converted by naming this union. /// readonly record struct ResultUnionModel( string Namespace, @@ -91,7 +127,8 @@ readonly record struct ResultUnionModel( string HintName, ResultSourceLocation Location, EquatableArray Cases, - EquatableArray HelperCases + EquatableArray HelperCases, + EquatableArray IncludedCases ) { /// diff --git a/src/src/SourceGenerator/README.md b/src/src/SourceGenerator/README.md index 45c33e5..4bd0adf 100644 --- a/src/src/SourceGenerator/README.md +++ b/src/src/SourceGenerator/README.md @@ -97,6 +97,34 @@ per-case `AsFailure` helper is generated once — for the union that sorts first case-receiver call can bind to the wrong union. The factory names the union by its receiver, so it is generated for every union and covers every one of the union's cases, including a shared one. +## Union inclusion + +A case type that is itself a union is an **included union**: the including union's factory also covers every case +reachable through it, so a case of an included union can be converted by naming the including union. The value is +constructed nested, innermost first: + +```csharp +[GenerateResult] +public readonly union BillingError(BillingAccountMissing, BillingServiceUnavailable); + +[GenerateResult] +public readonly union RegisterTenantError(TenantError, BillingError); + +// BillingAccountMissing is reachable through BillingError, so the factory covers it and builds the nested value. +Result result = + RegisterTenantError.Failure(new BillingAccountMissing(accountId)); +// equivalent to: +Result explicitNesting = + RegisterTenantError.Failure(new BillingError(new BillingAccountMissing(accountId))); +``` + +Inclusion is automatic for every union-typed case; no attribute is needed. It is also the way to **avoid `RSG1006` +entirely**: list the callee's union as a case instead of repeating its leaf cases. A union-typed case shared by two +unions is the intended composition pattern, so it is **not** reported as `RSG1006` and its ambiguous per-case +`AsFailure` helper is not generated for any union; the union-receiver factory is the only (and safe) form. A case +reachable through two different included unions cannot name a construction path, so it is reported as `RSG1008` and +its inclusion factory is skipped. + ## Why the generator exists C# composes a union *case* into its union and `Result` composes the *union* into a result, @@ -176,8 +204,8 @@ needs no generated code. The diagnostics 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. +- **`ResultsDiagnosticAnalyzer` reports the per-target rules** (`RSG1000`–`RSG1004`, `RSG1007`, `RSG1008`) 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 the @@ -192,8 +220,9 @@ The diagnostics live in one shared library (`Diagnostics/DiagnosticLibrary.cs` + | `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 `AsFailure` helper is skipped) | A case type is shared with another union, so its per-case helper is generated once; the union-receiver `Failure(...)` factory is generated for every union and is the shared-case safe form. | +| `RSG1006` | Warning | Generator | No (only the shared leaf case's `AsFailure` helper is skipped) | A **leaf** case type is shared with another union, so its per-case helper is generated once; the union-receiver `Failure(...)` factory is generated for every union and is the shared-case safe form. A union-typed case shared across unions is composition, not a shared leaf, and is not reported. | | `RSG1007` | Error | Analyzer | Yes | The union uses an `IUnionMembers` member provider, which the first implementation does not support. | +| `RSG1008` | Warning | Analyzer | No (only the ambiguous case's inclusion factory is skipped) | A case is reachable through two different included unions, so no inclusion factory can name its construction path. List the case through the union that directly declares it instead. | Case-level findings never block the union: the remaining cases are still generated, and the skipped case is reported. @@ -216,7 +245,7 @@ The suppression is narrow on purpose: - 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 this package reports. + suppression id rather than a rule, so `RSG1000`–`RSG1008` remain the only diagnostics this 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 diff --git a/src/src/SourceGenerator/Validation/ResultUnionValidator.cs b/src/src/SourceGenerator/Validation/ResultUnionValidator.cs index 225aa79..643e951 100644 --- a/src/src/SourceGenerator/Validation/ResultUnionValidator.cs +++ b/src/src/SourceGenerator/Validation/ResultUnionValidator.cs @@ -52,7 +52,6 @@ GeneratorContext context var unions = ImmutableArray.CreateBuilder(ordered.Length); HashSet configuredUnions = []; Dictionary generatedTypes = [with(StringComparer.Ordinal)]; - Dictionary caseOwners = []; foreach (var union in ordered) { @@ -78,36 +77,80 @@ GeneratorContext context } generatedTypes[generatedTypeKey] = union; + unions.Add(union); + } + + var generated = unions.ToImmutable(); + // The per-case AsFailure helper is generated once per case type, so the owner of each case type has + // to be known before the helpers are decided. The unions are in ordinal order, so the first owner + // recorded for a case type is the ordinal-first owner. + Dictionary> caseOwners = []; + + foreach (var union in generated) + { + foreach (var unionCase in union.Cases) + { + if (!caseOwners.TryGetValue(unionCase.CaseType, out var owners)) + { + owners = []; + caseOwners[unionCase.CaseType] = owners; + } + + owners.Add(union); + } + } + + var generatedUnions = ImmutableArray.CreateBuilder(generated.Length); + + foreach (var union in generated) + { var helperCases = ImmutableArray.CreateBuilder(union.Cases.Count); foreach (var unionCase in union.Cases) { - if (caseOwners.TryGetValue(unionCase.CaseType, out var caseOwner)) + var owners = caseOwners[unionCase.CaseType]; + + if (owners.Count == 1) { - Add( - diagnostics, - DiagnosticLibrary.SharedCaseType, - DiagnosticScope.Compilation, - union, - unionCase.FullyQualifiedName, - caseOwner.FullyQualifiedName, - union.FullyQualifiedName - ); + helperCases.Add(unionCase); + + continue; + } + + if (unionCase.IsUnionCase) + { + // A union-typed case is an inclusion: sharing it across unions is the composition pattern, + // and the union-receiver factory is the safe form. No per-case helper is generated for any + // owner and no diagnostic is reported, because the helper would bind to the wrong union. continue; } - caseOwners[unionCase.CaseType] = union; - helperCases.Add(unionCase); + if (owners[0].UnionType.Equals(union.UnionType)) + { + helperCases.Add(unionCase); + + continue; + } + + Add( + diagnostics, + DiagnosticLibrary.SharedCaseType, + DiagnosticScope.Compilation, + union, + unionCase.FullyQualifiedName, + owners[0].FullyQualifiedName, + union.FullyQualifiedName + ); } - // The union is always generated: its union-receiver factory covers every case in union.Cases, even - // one shared with another union. Only the shared case's per-case AsFailure helper is dropped, which - // is why the union keeps its own HelperCases subset. - unions.Add(union with { HelperCases = helperCases.ToImmutable() }); + // The union is always generated: its union-receiver factory covers every case in union.Cases and + // every case reachable through an included union, even one shared with another union. Only the + // per-case AsFailure helpers are dropped, which is why the union keeps its own HelperCases subset. + generatedUnions.Add(union with { HelperCases = helperCases.ToImmutable() }); } - return new ResultUnionGenerationModel(context, unions.ToImmutable(), diagnostics.ToImmutable()); + return new ResultUnionGenerationModel(context, generatedUnions.ToImmutable(), diagnostics.ToImmutable()); } /// diff --git a/src/tests/SourceGenerator.UnitTests/GeneratedResultsRuntimeTests.cs b/src/tests/SourceGenerator.UnitTests/GeneratedResultsRuntimeTests.cs index e4b3de4..00f917e 100644 --- a/src/tests/SourceGenerator.UnitTests/GeneratedResultsRuntimeTests.cs +++ b/src/tests/SourceGenerator.UnitTests/GeneratedResultsRuntimeTests.cs @@ -184,6 +184,37 @@ public async Task UnionFactoryValueFailure_GivenNotFoundCase_ProducesAValueFailu await Assert.That(result.Error is TenantNotFound).IsTrue(); } + [Test] + public async Task UnionFactoryFailure_GivenIncludedCase_ConstructsTheNestedUnion() + { + // Arrange + // BillingAccountMissing is not a direct case of RegisterTenantError: it is reachable through + // BillingError, which RegisterTenantError includes. The generated factory constructs the whole + // nested value. + BillingAccountMissing missing = new("account-1"); + + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result result = RegisterTenantError.Failure(missing); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + + // The active leaf is the included case, reached by matching through the included union. + BillingAccountMissing? activeCase = result.Error switch + { + BillingError billing => billing switch + { + BillingAccountMissing accountMissing => accountMissing, + _ => null, + }, + _ => null, + }; + + await Assert.That(activeCase).IsEqualTo(missing); + } + [Test] public async Task UnionFactorySuccess_ProducesAUnitSuccess() { diff --git a/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs b/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs index a030382..0f05f1b 100644 --- a/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs +++ b/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs @@ -52,9 +52,9 @@ namespace Purview.Results.SourceGenerator; /// unions, each as a single case. /// /// -/// The case types are unions, not their leaf cases, so a billing failure can be lifted into this contract with -/// one union conversion and no case type is shared with (which would raise -/// RSG1006). +/// The case types are unions, not their leaf cases. Each is an included union: the generated factory +/// also covers its cases and constructs the nested value, so a billing failure can be lifted into this contract +/// with one union conversion and no leaf case is shared with . /// [GenerateResult] public readonly union RegisterTenantError(TenantError, BillingError); diff --git a/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs b/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs index 4d1ee8b..e238ca9 100644 --- a/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs +++ b/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs @@ -24,7 +24,17 @@ await Assert .That( analyzer.SupportedDiagnostics.Select(static descriptor => descriptor.Id).Order(StringComparer.Ordinal) ) - .IsEquivalentTo(["RSG1000", "RSG1001", "RSG1002", "RSG1003", "RSG1004", "RSG1005", "RSG1006", "RSG1007"]); + .IsEquivalentTo([ + "RSG1000", + "RSG1001", + "RSG1002", + "RSG1003", + "RSG1004", + "RSG1005", + "RSG1006", + "RSG1007", + "RSG1008", + ]); } [Test] @@ -196,6 +206,40 @@ public interface IUnionMembers await Assert.That(result).HasDiagnostic("RSG1007"); } + [Test] + public async Task AnalyzeAsync_GivenAmbiguousIncludedCase_ReportsAmbiguousIncludedCase( + CancellationToken cancellationToken + ) + { + // Arrange + // Both LeftError and RightError include NotFound, so OuterError cannot name a single construction + // path for it. + const string source = """ + namespace Test + { + public readonly record struct NotFound(int Id); + + [GenerateResult] + public readonly union LeftError(NotFound); + + [GenerateResult] + public readonly union RightError(NotFound); + + [GenerateResult] + public readonly union OuterError(LeftError, RightError); + } + """; + + // Act + var result = await AnalyzeAsync([TestSources.GenerateResultAttributeDeclaration, source], cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic("RSG1008"); + + var diagnostic = result.Diagnostics.Single(static candidate => candidate.Id == "RSG1008"); + await Assert.That(diagnostic.Severity).IsEqualTo(DiagnosticSeverity.Warning); + } + [Test] public async Task AnalyzeAsync_GivenValidOptedInUnion_ReportsNothing(CancellationToken cancellationToken) { diff --git a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs index a8331a7..77608d1 100644 --- a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs @@ -174,6 +174,211 @@ CancellationToken cancellationToken await Assert.That(generated).Contains("Result.Failure(error)"); } + [Test] + public async Task GenerateAsync_GivenIncludedUnion_EmitsFlattenedFactoriesForTheIncludedCases( + CancellationToken cancellationToken + ) + { + // Arrange + // LookupError is a case of SetError, so its own case (NotFound) is reachable through it. The + // generator emits a union-receiver factory for NotFound on SetError that constructs the nested value. + const string source = """ + using Purview.Results; + + namespace Test + { + public readonly record struct NotFound(int Id); + + public readonly record struct Rejected(string Reason); + + [GenerateResult] + public readonly union LookupError(NotFound); + + [GenerateResult] + public readonly union SetError(LookupError, Rejected); + + public static class Usage + { + public static Result Included(NotFound error) => SetError.Failure(error); + + public static Result IncludedUnit(NotFound error) => SetError.Failure(error); + + public static Result Direct(Rejected error) => SetError.Failure(error); + } + } + """; + + // Act + var result = await GenerateAsync(source, cancellationToken); + + // Assert + result.AssertNoCompilationErrors(); + + var setTree = result.PrimarySyntaxTrees.Single(static tree => + tree.FilePath.EndsWith("SetErrorResultExtensions.g.cs", StringComparison.Ordinal) + ); + var setSource = (await setTree.GetTextAsync(cancellationToken)).ToString(); + + // The included case gets a factory, and its body constructs the nested union value. + await Assert.That(setSource).Contains("global::Test.NotFound error"); + await Assert.That(setSource).Contains("new global::Test.SetError(new global::Test.LookupError(error))"); + } + + [Test] + public async Task GenerateAsync_GivenSharedUnionCase_DropsThePerCaseHelperAndReportsNoSharedCaseWarning( + CancellationToken cancellationToken + ) + { + // Arrange + // LookupError is a union-typed case of both GetError and SetError. Sharing it is the composition + // pattern, so no RSG1006 is reported and no per-case AsFailure helper is generated for it on either + // union; the union-receiver factory is the safe form. + const string source = """ + using Purview.Results; + + namespace Test + { + public readonly record struct NotFound(int Id); + + [GenerateResult] + public readonly union LookupError(NotFound); + + [GenerateResult] + public readonly union GetError(LookupError); + + [GenerateResult] + public readonly union SetError(LookupError); + + public static class Usage + { + public static Result Get(LookupError error) => GetError.Failure(error); + + public static Result Set(LookupError error) => SetError.Failure(error); + } + } + """; + + // Act + var result = await GenerateAsync(source, cancellationToken); + + // Assert + result.AssertNoCompilationErrors(); + await Assert.That(result).DoesNotHaveDiagnostic("RSG1006"); + + var getTree = result.PrimarySyntaxTrees.Single(static tree => + tree.FilePath.EndsWith("GetErrorResultExtensions.g.cs", StringComparison.Ordinal) + ); + var getSource = (await getTree.GetTextAsync(cancellationToken)).ToString(); + + await Assert.That(getSource).DoesNotContain("this global::Test.LookupError error"); + await Assert.That(getSource).Contains("global::Test.LookupError error"); + } + + [Test] + public async Task GenerateAsync_GivenNestedIncludedUnions_ConstructsTheWholeChain( + CancellationToken cancellationToken + ) + { + // Arrange + // TopError -> MiddleError -> LeafError -> NotFound, so the included factory must construct the whole + // nested value, innermost first. + const string source = """ + using Purview.Results; + + namespace Test + { + public readonly record struct NotFound(int Id); + + [GenerateResult] + public readonly union LeafError(NotFound); + + [GenerateResult] + public readonly union MiddleError(LeafError); + + [GenerateResult] + public readonly union TopError(MiddleError); + + public static class Usage + { + public static Result Value(NotFound error) => TopError.Failure(error); + } + } + """; + + // Act + var result = await GenerateAsync(source, cancellationToken); + + // Assert + result.AssertNoCompilationErrors(); + + var topTree = result.PrimarySyntaxTrees.Single(static tree => + tree.FilePath.EndsWith("TopErrorResultExtensions.g.cs", StringComparison.Ordinal) + ); + var topSource = (await topTree.GetTextAsync(cancellationToken)).ToString(); + + await Assert + .That(topSource) + .Contains("new global::Test.TopError(new global::Test.MiddleError(new global::Test.LeafError(error)))"); + } + + [Test] + public async Task GenerateAsync_GivenAmbiguousIncludedCase_SkipsTheAmbiguousFactoryAndKeepsTheOthers( + CancellationToken cancellationToken + ) + { + // Arrange + // NotFound is reachable through both LeftError and RightError, so no single construction path can be + // named for it. The direct cases still get their factories. + const string source = """ + using Purview.Results; + + namespace Test + { + public readonly record struct NotFound(int Id); + + public readonly record struct Rejected(string Reason); + + [GenerateResult] + public readonly union LeftError(NotFound); + + [GenerateResult] + public readonly union RightError(NotFound); + + [GenerateResult] + public readonly union OuterError(LeftError, RightError, Rejected); + } + """; + + // Act + var result = await GenerateAsync( + source, + new ResultsSourceGeneratorTestOptions + { + AnalyzerTypes = [typeof(Purview.Results.SourceGenerator.Analyzers.ResultsDiagnosticAnalyzer)], + }, + cancellationToken + ); + + // Assert + result.AssertNoCompilationErrors(); + await Assert.That(result.AnalyzerResult).IsNotNull(); + + var analyzerDiagnostics = result + .AnalyzerResult!.Diagnostics.Select(static diagnostic => diagnostic.Id) + .ToArray(); + await Assert.That(analyzerDiagnostics).Contains("RSG1008"); + + var outerTree = result.PrimarySyntaxTrees.Single(static tree => + tree.FilePath.EndsWith("OuterErrorResultExtensions.g.cs", StringComparison.Ordinal) + ); + var outerSource = (await outerTree.GetTextAsync(cancellationToken)).ToString(); + + await Assert.That(outerSource).Contains("global::Test.LeftError error"); + await Assert.That(outerSource).Contains("global::Test.RightError error"); + await Assert.That(outerSource).Contains("global::Test.Rejected error"); + await Assert.That(outerSource).DoesNotContain("global::Test.NotFound error"); + } + [Test] public async Task GenerateAsync_GivenMultipleUnions_EmitsIndependentHelpersInDeterministicOrder( CancellationToken cancellationToken diff --git a/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs b/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs index f95a45d..3bc1f92 100644 --- a/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs +++ b/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs @@ -265,4 +265,47 @@ public static Result Set(NotFound error) await Assert.That(result.FixedCode).Contains("SetError.Failure(error)"); await Assert.That(result.RewriteCompiles).IsTrue(); } + + [Test] + public async Task UnionCaseResultCodeFixProvider_GivenCaseReachedThroughAnIncludedUnion_ShouldOfferTheFix( + CancellationToken cancellationToken + ) + { + // BillingAccountMissing is not a direct case of RegisterTenantError: it is reachable through the + // included BillingError union, whose cases the outer factory also covers. + const string source = """ + using Purview.Results; + + namespace Test + { + public readonly record struct AccountId(string Value); + + public readonly record struct TenantId(string Value); + + public readonly record struct Tenant(TenantId TenantId); + + public readonly record struct BillingAccountMissing(AccountId AccountId); + + [GenerateResult] + public readonly union BillingError(BillingAccountMissing); + + [GenerateResult] + public readonly union RegisterTenantError(BillingError); + + public static class Usage + { + public static Result Register(AccountId accountId) + { + return new BillingAccountMissing(accountId); + } + } + } + """; + + var result = await UnionCodeFixTestHarness.ApplyAsync(source, cancellationToken); + + await Assert.That(result.FixOffered).IsTrue(); + await Assert.That(result.FixedCode).Contains("RegisterTenantError.Failure("); + await Assert.That(result.RewriteCompiles).IsTrue(); + } } diff --git a/src/tests/SourceGenerator.UnitTests/UnionCompilerBehaviourTests.cs b/src/tests/SourceGenerator.UnitTests/UnionCompilerBehaviourTests.cs index 65c737d..ae4031c 100644 --- a/src/tests/SourceGenerator.UnitTests/UnionCompilerBehaviourTests.cs +++ b/src/tests/SourceGenerator.UnitTests/UnionCompilerBehaviourTests.cs @@ -320,6 +320,37 @@ public static implicit operator Result(TenantNotFound new OuterError(new InnerError(error)); + } + } + """; + + var result = await GenerateAsync([source, usage], cancellationToken); + + result.AssertNoCompilationErrors(); + } + [Test] public async Task Result_GivenUnionCastedCase_Compiles(CancellationToken cancellationToken) { From 7e8cdc80f3e4338a5351b6c71615e724a89ba356 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 6 Oct 2026 18:37:28 +0100 Subject: [PATCH 08/11] feat: add .net 10 support, drop forced preview features, and add observability Consuming these packages forced preview features on the whole consuming solution. EnablePreviewFeatures was set repo-wide, emitting [assembly: RequiresPreviewFeatures] into all four shipped assemblies. Because that attribute is assembly-wide, CA2252 - an error by default - fired on every member a consumer touched, including Result, which uses no union feature. An application could not adopt the result type at all without enabling preview features solution-wide, so partial adoption was impossible. The property was unnecessary. A union declaration is a language feature needing only LangVersion=preview, and nothing here uses a runtime API annotated [RequiresPreviewFeatures]. The requirement is now scoped per-project: using the result types and combinators needs nothing special, and declaring your own [GenerateResult] union needs LangVersion=preview in that project. Both paths are verified against the packed packages, the first with TreatWarningsAsErrors. All four packages now multi-target net10.0 and net11.0, so consumers choose their runtime rather than being forced onto .NET 11. .NET 8 and 9 are deliberately not targeted - both are near end of support. Unions still require .NET 11, which is a compiler constraint rather than a choice: a union declaration needs System.Runtime.CompilerServices.IUnion and UnionAttribute, neither of which exists before .NET 11. Everything else works on net10.0. The HTTP mapper's union unwrapping compiles only for net11.0; on net10.0 an error is its own single case, the answer the union path already gives for a non-union error. Runtime async is likewise scoped to net11.0 - on net10.0 Features=runtime-async=on lowers await to AsyncHelpers, which is evaluation-only and fails the build with SYSLIB5007. The ASP.NET Core success path no longer erases the value's type. DefaultResultsHttpMapper serialized through IResultValue.SuccessValue, which is object?, so TypedResults.Ok bound Ok. Under trimming or Native AOT that leaves the serializer with no JsonTypeInfo to resolve, and it erased the response type ASP.NET Core infers for OpenAPI. The value now reaches TypedResults with its static type through a new IResultValueVisitor and IResultValue.AcceptSuccess double dispatch - no reflection. All four packages are marked IsAotCompatible and verified clean under both the trim and AOT analyzers, independently confirming the "no reflection, no dynamic, no runtime type discovery" guarantee. Observability: metrics under the meter Purview.Results.AspNetCore - purview.results.failures.mapped, .unmapped and purview.results.uninitialized. The design calls an unmapped failure a host mapping gap rather than a domain outcome, but the only signal was a log message, so answering "how often, and for which error" meant scraping log text. Each measurement carries a purview.results.error_type tag, and the failure paths add the same tag to Activity.Current, enriching the request span ASP.NET Core already started rather than creating one. Every log statement now has a stable EventId via source-generated LoggerMessage. No telemetry package dependency is added: Meter and Activity come from the base class library. The error type is always present in telemetry, because metrics and traces stay inside your own infrastructure. Disclosing it in the HTTP response is now opt-in via IncludeErrorTypeInProblemDetails, defaulting to off - previously Type.FullName went to every client on the unmapped-failure path with no way to disable it. ResultsHttpOptions snapshots its mappings into a FrozenDictionary and an IReadOnlyList on first read. It previously held a plain Dictionary and List, writable through public methods for the process lifetime while the singleton mapper read them per request. Fixed: an array-typed union case crashed the generator and discarded every other union's output. The discovery code explicitly handles IArrayTypeSymbol when checking accessibility, then cast the case to INamedTypeSymbol - which an array is not - throwing InvalidCastException. With no catch-all the compiler reported CS8785 and dropped all generated source for the compilation: every AsFailure helper and union factory vanished, producing a cascade of unrelated CS1061/CS0103 errors with no indication of the cause, and AD0001 in the IDE. A single malformed union anywhere in a solution did this. Now reported as RSG1009 and skipped. A generator catch-all reports RSG9000 against the compilation rather than letting an unexpected failure escape; OperationCanceledException is rethrown, because the IDE cancels generation on every keystroke. Async combinators now await with ConfigureAwait(false), so they no longer resume on a captured synchronization context. All eleven diagnostics (RSG1000-RSG1009, RSG9000) moved into a new AnalyzerReleases.Shipped.md under Release 1.0.0 - there was no Shipped.md at all, so the catalogue claimed nothing had ever shipped. RSG2000 is deliberately absent from both files: suppression descriptors are not release-tracked. Docs: thread safety and observability recorded in Guarantees and Limitations, including that a result is safe by value but not in a shared mutable field, because a torn read of the three-field struct elides the null check [MemberNotNullWhen] promised. BREAKING CHANGE: IResultValue gained AcceptSuccess. Both in-box result types implement it; external implementations are not expected. --- .github/workflows/pr.yml | 2 +- AGENTS.md | 5 +- CHANGELOG.md | 159 ++++++++++++++++++ Directory.Packages.props | 7 + Justfile | 13 +- README.md | 23 ++- docs/wiki/Getting-Started.md | 17 +- docs/wiki/Guarantees-and-Limitations.md | 71 +++++++- docs/wiki/Release-Flow.md | 4 +- src/Directory.Build.props | 43 ++++- src/src/AspNetCore/AspNetCore.csproj | 9 + .../AspNetCore/DefaultResultsHttpMapper.cs | 140 ++++++++++++--- src/src/AspNetCore/ResultsHttpOptions.cs | 51 +++++- src/src/AspNetCore/ResultsHttpTelemetry.cs | 83 +++++++++ .../Examples.AspNetCore.Zod.csproj | 9 + .../Examples.AspNetCore.csproj | 9 + src/src/Examples.Basic/Examples.Basic.csproj | 9 + .../Examples.ValueObjects.Zod.csproj | 9 + src/src/Examples.Zod/Examples.Zod.csproj | 9 + .../Purview/Results/ResultExtensions.cs | 4 +- src/src/Results/IResultValue.cs | 20 +++ src/src/Results/IResultValueVisitor.cs | 36 ++++ src/src/Results/ResultT.cs | 27 +++ src/src/Results/ResultTError.cs | 17 ++ src/src/Results/Results.csproj | 14 ++ .../AnalyzerReleases.Shipped.md | 17 ++ .../AnalyzerReleases.Unshipped.md | 20 +-- .../Diagnostics/DiagnosticLibrary.cs | 41 +++++ .../Diagnostics/ResultUnionDiagnostics.cs | 27 ++- .../SourceGenerator/ResultsSourceGenerator.cs | 48 +++++- .../ZodResultsFailureMapper.cs | 35 ++-- .../ZodSharp.AspNetCore.csproj | 7 + .../ZodValidationProblems.cs | 22 +++ src/src/ZodSharp/ZodSharp.csproj | 8 + .../AspNetCore.UnitTests.csproj | 9 + .../DefaultResultsHttpMapperTests.cs | 26 ++- .../ResultsEndpointFilterTests.cs | 7 +- .../ResultsHttpFailuresTests.cs | 6 +- .../ResultsHttpOptionsSafetyTests.cs | 66 ++++++++ .../ResultsHttpTelemetryTests.cs | 152 +++++++++++++++++ .../Results.UnitTests/AcceptSuccessTests.cs | 113 +++++++++++++ .../ResultsDiagnosticAnalyzerTests.cs | 2 + .../ResultsSourceGeneratorArrayCaseTests.cs | 56 ++++++ .../SourceGenerator.UnitTests.csproj | 9 + .../ZodResultsFailureMapperTests.cs | 4 +- .../ZodSharp.AspNetCore.UnitTests.csproj | 9 + .../ZodSharpResultsHttpRegistrationTests.cs | 4 +- .../ZodSharp.UnitTests.csproj | 9 + 48 files changed, 1402 insertions(+), 85 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/src/AspNetCore/ResultsHttpTelemetry.cs create mode 100644 src/src/Results/IResultValueVisitor.cs create mode 100644 src/src/SourceGenerator/AnalyzerReleases.Shipped.md create mode 100644 src/tests/AspNetCore.UnitTests/ResultsHttpOptionsSafetyTests.cs create mode 100644 src/tests/AspNetCore.UnitTests/ResultsHttpTelemetryTests.cs create mode 100644 src/tests/Results.UnitTests/AcceptSuccessTests.cs create mode 100644 src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorArrayCaseTests.cs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 73b18d0..bec93ff 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -24,7 +24,7 @@ jobs: # 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]" - # All five library projects are packable, so pack and validate the produced packages. + # All four library projects are packable, so pack and validate the produced packages. run-pack: true validate-pack: true secrets: inherit diff --git a/AGENTS.md b/AGENTS.md index 95b43cf..1f6ec9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -398,9 +398,8 @@ just scrub just build ``` -`pipeline-local-release` runs the shared pipeline with `Release:Mode=LocalNuGet`. It is present in this -repository's `Justfile` but currently commented out; until it is re-enabled, run the same command directly -(after `just ensure-pipeline-tool`): +`pipeline-local-release` runs the shared pipeline with `Release:Mode=LocalNuGet`. It is a live recipe in this +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/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..af6b37c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,159 @@ +# Changelog + +All notable changes to this repository are recorded here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to +[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. + +## Unreleased + +### Added + +- 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`, + `TryGetError`, `GetErrorOrDefault` and `Throw()`, plus implicit conversions from `Success` and `TError`. + `Result.DiscardValue()` bridges a value result to a unit result. +- `Purview.Results.ZodSharp` integration with `Purview.ValueObjects`, so a type-level `[ZodRule]` carries its + code and origin through a result and into the HTTP mapping. Demonstrated by the new + `Examples.ValueObjects.Zod` example. +- Union inclusion: a union-typed case is expanded automatically, so an including union's generated factory also + covers every case reachable through it, constructing the nested value. This is the supported way to avoid + `RSG1006` without flattening a union. +- `RSG1008`, reported when a case is reachable through two different included unions, where no single + construction path can be named. +- `RSG1009`, reported when a union case is not a named type (an array, for example) so no helper or factory + can be declared against it. +- `RSG9000`, reported when the generator fails unexpectedly, instead of letting the exception escape. +- The union-receiver factory as a C# 14 extension block on the union type — `Union.Failure(case)`, + `Union.Failure(case)`, `Union.Success()` and `Union.Success(value)`. Because it names the union + by its receiver, it is safe for cases shared between unions, and the code fix rewrites `CS0029` to it. +- Examples recipe `just example-valueobjects-zod`. + +### Changed + +- **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 + consumer touched, including `Result`, which uses no union feature. A consuming application + could not adopt the result type at all without enabling preview features solution-wide, so partial adoption + was impossible. The property was unnecessary: union declarations are a *language* feature needing only + `LangVersion=preview`, and nothing here uses a runtime API annotated `[RequiresPreviewFeatures]` — every + project, including the union-declaring examples and tests, compiles without it. + + The requirement is now scoped and per-project: + - Using the result types and combinators needs **nothing special**. + - 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. +- **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. +- **The ASP.NET Core success path no longer erases the value's type.** `DefaultResultsHttpMapper` serialized + through `IResultValue.SuccessValue`, which is `object?`, so `TypedResults.Ok` bound `Ok`. Under + trimming or Native AOT that leaves the serializer with no `JsonTypeInfo` to resolve, and it also erased the + response type ASP.NET Core infers for OpenAPI. The value now reaches `TypedResults` with its static type + through the new `IResultValue.AcceptSuccess` double dispatch. + +### Added — observability + +- **Metrics under the meter `Purview.Results.AspNetCore`**: `purview.results.failures.mapped`, + `purview.results.failures.unmapped` and `purview.results.uninitialized`. The design calls an unmapped + failure a host mapping gap rather than a domain outcome, but the only signal was a log message — so + answering "how often, and for which error" meant scraping log text. Each measurement carries a + `purview.results.error_type` tag, and the failure paths add the same tag to `Activity.Current`, enriching + the request span ASP.NET Core already started rather than creating one. Register with + `metrics.AddMeter(ResultsHttpTelemetry.MeterName)`. +- The error type is **always** present in telemetry, because metrics and traces stay inside your own + infrastructure. Disclosing it in the HTTP *response* remains opt-in via + `IncludeErrorTypeInProblemDetails`, so a mapping gap stays diagnosable without being published. +- **Stable `EventId`s** on every log statement, via source-generated `LoggerMessage`: `1000` uninitialized + result, `1001` unmapped failure, `2000` a ZodSharp rule answering a multi-error failure. Previously none + had an identifier, so alerting meant matching message text. The generated path also removes the + hand-written `IsEnabled` guard the Debug call site needed. +- No telemetry package dependency is added: `Meter` and `Activity` come from the base class library through + the ASP.NET Core framework reference, so the repository's dependency-light position is unchanged. + +### Added — .NET 10 support + +- **All four packages now multi-target `net10.0` and `net11.0`**, so consumers choose their runtime rather + than being forced onto .NET 11. .NET 8 and 9 are deliberately not targeted — both are close to end of + support. Verified end to end: a `net10.0` console consumer and a `net10.0` ASP.NET Core minimal API both + build and run against the packed packages, with `TreatWarningsAsErrors`. +- **Unions still require .NET 11**, and that is a compiler constraint rather than a choice: a union + declaration needs `System.Runtime.CompilerServices.IUnion` and `UnionAttribute`, neither of which exists + before .NET 11. Everything else works on `net10.0` — the result types, the combinators, the ASP.NET Core + mapping and the ZodSharp bridge. The HTTP mapper's union unwrapping is compiled only for `net11.0`; on + `net10.0` an error is its own single case, which is the answer the union path already gives for a + non-union error. Projects that declare a union (the examples and the union-declaring test projects) are + 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. +- **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 + therefore gets the stable async lowering. + +### Changed — analyzer release tracking + +- **Every diagnostic moved from `AnalyzerReleases.Unshipped.md` to `AnalyzerReleases.Shipped.md`** under + `## Release 1.0.0`. There was no `Shipped.md` at all, so the catalogue claimed nothing had ever shipped; + all eleven rules (`RSG1000`–`RSG1009`, `RSG9000`) are now recorded. `RSG2000` is deliberately absent from + both files: it is a suppression id, and suppression descriptors are not release-tracked. + +### Added — `IResultValueVisitor` + +- 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). +- 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. + +### Fixed + +- **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 + catch-all in the pipeline the compiler reported `CS8785` and dropped **all** generated source for the + compilation: every `AsFailure` helper and union factory vanished, producing a cascade of unrelated + `CS1061`/`CS0103` errors with no indication of the cause, and `AD0001` in the IDE where analysis then + stopped. A single malformed union anywhere in a solution did this. Now reported as `RSG1009` and skipped; + the union's other cases still generate. +- **Added a generator catch-all (`RSG9000`).** An unexpected failure is now reported against the + compilation rather than allowed to escape, so the rest of the generated output survives and the cause is + identifiable. `OperationCanceledException` is rethrown, because the IDE cancels generation on every + keystroke. + +### Security + +- The source generator is now bundled into the `Purview.Results` package correctly. `Purview.Results` ships both + the IL-merged generator and `Purview.Results.SourceGenerator.CodeFixes.dll` under `analyzers/dotnet/cs/`. + +### Documentation + +- Recorded that results have **no serialization support** and that reflection-based `System.Text.Json` + serialization throws, in [Guarantees and Limitations](docs/wiki/Guarantees-and-Limitations.md). +- Added XML documentation to the four public implicit conversion operators. +- Corrected the `Examples.*` paths in the `Justfile` (`src/src`, not `src/examples`), the packable-project count + in `pr.yml`, the `pipeline-local-release` status in `AGENTS.md`, and the hardcoded version in + [Release Flow](docs/wiki/Release-Flow.md). + +## 1.0.0-prerelease.2 + +See the [`v1.0.0-prerelease.2`](https://github.com/purview-dev/results/releases/tag/v1.0.0-prerelease.2) +release notes. + +## 1.0.0-prerelease.1 + +Initial prerelease: `Result`, the `[GenerateResult]` source generator, the `CS0029` code fix, +and the ZodSharp and ASP.NET Core integrations. See the +[`v1.0.0-prerelease.1`](https://github.com/purview-dev/results/releases/tag/v1.0.0-prerelease.1) release notes. diff --git a/Directory.Packages.props b/Directory.Packages.props index b263bec..ab5eec5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,14 @@ + 11.0.0-rc.1.26425.128 + 10.0.12 2.0.1-prerelease.1 diff --git a/Justfile b/Justfile index 2cd891f..9cd068a 100644 --- a/Justfile +++ b/Justfile @@ -111,22 +111,27 @@ pack publish_folder=artifacts_folder *args: # Run the Basic example (result states, combinators and the generated AsFailure helpers) [group('Examples')] example-basic *args: - dotnet run --project src/examples/Examples.Basic {{ args }} + dotnet run --project src/src/Examples.Basic {{ args }} # Run the ZodSharp example (a validation outcome flowing through the result pipeline) [group('Examples')] example-zod *args: - dotnet run --project src/examples/Examples.Zod {{ args }} + dotnet run --project src/src/Examples.Zod {{ args }} # Run the ASP.NET Core example (result-to-response mapping), listening on http://localhost:5215 [group('Examples')] example-aspnetcore *args: - dotnet run --project src/examples/Examples.AspNetCore --urls http://localhost:5215 {{ args }} + dotnet run --project src/src/Examples.AspNetCore --urls http://localhost:5215 {{ args }} # Run the ASP.NET Core + ZodSharp example (validation problems from result failures), listening on http://localhost:5216 [group('Examples')] example-aspnetcore-zod *args: - dotnet run --project src/examples/Examples.AspNetCore.Zod --urls http://localhost:5216 {{ args }} + dotnet run --project src/src/Examples.AspNetCore.Zod --urls http://localhost:5216 {{ args }} + +# Run the ValueObjects + ZodSharp example (a type-level [ZodRule] flowing into the result and HTTP mapping) +[group('Examples')] +example-valueobjects-zod *args: + dotnet run --project src/src/Examples.ValueObjects.Zod {{ args }} # Open the solution in Visual Studio/ Registered application [group('Utilities')] diff --git a/README.md b/README.md index 099801f..c8999a9 100644 --- a/README.md +++ b/README.md @@ -270,10 +270,25 @@ The full documentation suite lives in [`docs/wiki`](docs/wiki/Getting-Started.md ## Requirements -- **.NET 11 SDK or later** — the runtime packages target `net11.0`; the source generator bundled in - `Purview.Results` targets `netstandard2.0` so any compiler host can load it. -- **C# 15 preview** — union declarations are a preview language feature, so consuming projects need an SDK with - union support and `LangVersion=preview` (the repository sets both). +- **.NET 10 or .NET 11** — the runtime packages multi-target `net10.0` and `net11.0`, so adopting them does + not force a runtime upgrade. The source generator bundled in `Purview.Results` targets `netstandard2.0` + so any compiler host can load it. .NET 8 and 9 are not targeted; both are close to end of support. +- **Unions require .NET 11.** The compiler needs `System.Runtime.CompilerServices.IUnion` and + `UnionAttribute`, which do not exist earlier, so a `[GenerateResult]` union declaration cannot compile for + `net10.0`. Everything else works on both: the result types, the combinators, the ASP.NET Core mapping and + the ZodSharp bridge. On `net10.0` an error type is simply a plain type rather than a union, which the HTTP + mapper already handles — register a mapping for the error type itself. +- **C# 15 preview — only to *declare* a union.** The requirement is per-project and scoped: + - Using `Result`, `Result` and the combinators needs **nothing special**. No + `LangVersion`, no `EnablePreviewFeatures`. + - Declaring your own `[GenerateResult]` union needs `LangVersion=preview` in **that** project, because the + union syntax is yours to compile. + + These packages deliberately do **not** set `EnablePreviewFeatures`. Doing so emits + `[assembly: RequiresPreviewFeatures]`, which makes CA2252 (an error by default) fire on every member a + consumer touches — assembly-wide, so it applied even to the non-union result type and made partial adoption + impossible. Nothing here uses a runtime API annotated `[RequiresPreviewFeatures]`, so the attribute was + removed. If you see CA2252 from these packages, that is a bug — please report it. ## Repository layout diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index 57debc9..a98232d 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -4,10 +4,19 @@ This guide installs the packages, models an error union, returns a result, and m ## 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). +- **.NET 10 or .NET 11** — the runtime packages multi-target `net10.0` and `net11.0`; the source generator + targets `netstandard2.0` so any compiler host can load it. .NET 8 and 9 are not targeted. +- **Unions require .NET 11**, because the compiler needs `System.Runtime.CompilerServices.IUnion` and + `UnionAttribute`. On `net10.0` the result types, combinators, ASP.NET Core mapping and ZodSharp bridge all + work; only the union declaration itself is unavailable, so map the error type directly instead of a case. +- **C# 15 preview — only to *declare* a union.** Using the result types and combinators needs nothing + special: no `LangVersion`, no `EnablePreviewFeatures`. A project that declares its own + `[GenerateResult]` union needs `LangVersion=preview` in that project, because the union syntax is yours to + compile. Adoption is therefore incremental — you can take `Result` into an existing + solution first and introduce unions per project later. + + These packages do not set `EnablePreviewFeatures`, so they do not force preview features on your + solution. See [Guarantees and Limitations](Guarantees-and-Limitations.md). ## 1. Reference the packages diff --git a/docs/wiki/Guarantees-and-Limitations.md b/docs/wiki/Guarantees-and-Limitations.md index 3e816e7..9af6656 100644 --- a/docs/wiki/Guarantees-and-Limitations.md +++ b/docs/wiki/Guarantees-and-Limitations.md @@ -25,6 +25,67 @@ change must not break. `Result.Success`/`.Failure` and `Result.Success`/`.Failure` factories, are public contract. +## Observability + +`Purview.Results.AspNetCore` emits metrics under the meter `Purview.Results.AspNetCore` +(`ResultsHttpTelemetry.MeterName`): + +| Instrument | Meaning | +| --- | --- | +| `purview.results.failures.mapped` | failures a registered mapping or fallback answered | +| `purview.results.failures.unmapped` | failures with no mapping, answered with the unmapped-failure response | +| `purview.results.uninitialized` | endpoints that returned a `default` result, which is always a bug | + +Each measurement carries a `purview.results.error_type` tag with the error's type name, and the failure +paths add the same tag to `Activity.Current` — enriching the request span ASP.NET Core already created +rather than starting one. The type name is always present in telemetry, because metrics and traces stay +inside your own infrastructure; disclosing it in the HTTP *response* is separately opt-in through +[`IncludeErrorTypeInProblemDetails`](#http-mapping-philosophy). + +```csharp +builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => metrics.AddMeter(ResultsHttpTelemetry.MeterName)); +``` + +Log messages carry stable event identifiers, so they can be filtered without matching message text: +`1000` uninitialized result, `1001` unmapped failure, `2000` a ZodSharp rule answering a multi-error +failure. No log statement records a validation payload or any user input. + +These use `Meter` and `Activity` from the base class library, available through the ASP.NET Core framework +reference, so no telemetry package dependency is added. + +## Thread safety + +- **A result is safe to pass between threads by value.** It is a `readonly record struct` with no mutable + state, so copying it — returning it, passing it as an argument, awaiting a `Task>` — is safe. +- **A result is _not_ safe to store in a shared mutable field and write concurrently.** A multi-field struct + is not written atomically: `Result` holds the value, the error and a state byte, so a + reader can observe a *torn* value where the state says `Success` while the value field is still the + previous one. + + This is worse than it first looks, because of the nullability annotations. `IsSuccess` is annotated + `[MemberNotNullWhen(true, nameof(Value))]`, so the compiler has been told a success implies a non-null + `Value` — and will elide the null check at your call site. A torn read therefore surfaces as a + `NullReferenceException` in your code, with nothing pointing back here. + + If you need to share a result through mutable state, synchronise it, or box it into a field of a reference + type and publish that reference (a reference assignment *is* atomic): + + ```csharp + // Not safe: concurrent writers can tear the struct. + static Result _cached; + + // Safe: the reference is published atomically. + static Tuple>? _cached; + ``` + +- `ResultsHttpOptions` is **startup configuration**. Its mapping dictionary and fallback list are frozen the + first time a request reads them, so a registration added after the first failure is handled is ignored + rather than corrupting a collection another thread is enumerating. Configure it in `AddResultsHttp` and do + not mutate it afterwards. +- The generated helpers and factories are static and stateless, and `DefaultResultsHttpMapper` holds no + per-request state, so both are safe to use concurrently. + ## Dependency guarantees - `Purview.Results` is dependency-free. @@ -70,7 +131,15 @@ change must not break. result failure into an exception — is available explicitly through `Throw()`. - No runtime union inspection: matching an error case is ordinary C# pattern matching. - No replacement for `Result` or `Result`: the generator only adds call-site ergonomics. -- No `Task`/`ValueTask`-specific async combinators beyond `MapAsync` and `BindAsync`. +- No `Task`/`ValueTask`-specific async combinators beyond `MapAsync` and `BindAsync`. The async combinators take + `Task` only, carry no `CancellationToken` overloads, and await with `ConfigureAwait(false)`. +- **No serialization support.** A result is a control-flow type, not a DTO, so neither `Result` + nor `Result` ships a `JsonConverter` and neither is designed to round-trip. Reflection-based + `System.Text.Json` serialization **throws** `InvalidOperationException`, because it probes whichever of + `Value`/`Error` does not describe the current state and those accessors throw by contract. Serialize the + outcome you want instead: unwrap with `Match`, `TryGetValue`/`TryGetError`, or — over HTTP — let + [`Purview.Results.AspNetCore`](AspNetCore-Integration.md) map the result to a response. `IResultValue` is the + non-throwing view available to infrastructure that has to inspect a result generically. ## Related diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index d8b2a84..494ac87 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -7,8 +7,8 @@ GitHub Actions workflows in `.github/workflows/`. Consuming repositories own con ## 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. +`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. ## Workflows diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 7808d79..6afebc1 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,11 +3,46 @@ Purview.Results https://github.com/purview-dev/results - - net11.0 + + + net10.0;net11.0 preview - true - $(Features);runtime-async=on + + + $(Features);runtime-async=on + + + + true + + + + net11.0 + + + net11.0 + + + net11.0 + + + net11.0 + + + net11.0 + + + true + + true Purview Results diff --git a/src/src/SourceGenerator/AnalyzerReleases.Shipped.md b/src/src/SourceGenerator/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..333672c --- /dev/null +++ b/src/src/SourceGenerator/AnalyzerReleases.Shipped.md @@ -0,0 +1,17 @@ +## Release 1.0.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +RSG1000 | Purview.Results.Usage | Error | GenerateResult target is not a union +RSG1001 | Purview.Results.Usage | Error | Union has no union cases +RSG1002 | Purview.Results.Usage | Error | Unsupported generic union configuration +RSG1003 | Purview.Results.Usage | Error | Union case type is not accessible +RSG1004 | Purview.Results.Usage | Error | Duplicate GenerateResult configuration +RSG1005 | Purview.Results.Usage | Error | Generated helper class name collision +RSG1006 | Purview.Results.Usage | Warning | Union case type is shared with another union +RSG1007 | Purview.Results.Usage | Error | Unsupported union member provider +RSG1008 | Purview.Results.Usage | Warning | Union inclusion case is ambiguous +RSG1009 | Purview.Results.Usage | Warning | Union case type is not a named type +RSG9000 | Purview.Results.Usage | Error | Unhandled exception in the results source generator diff --git a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md index b5b2953..afde190 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md @@ -1,16 +1,8 @@ ; Unshipped analyzer release ; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md - -### New Rules - -Rule ID | Category | Severity | Notes ---------|----------|----------|------- -RSG1000 | Purview.Results.Usage | Error | GenerateResult target is not a union -RSG1001 | Purview.Results.Usage | Error | Union has no union cases -RSG1002 | Purview.Results.Usage | Error | Unsupported generic union configuration -RSG1003 | Purview.Results.Usage | Error | Union case type is not accessible -RSG1004 | Purview.Results.Usage | Error | Duplicate GenerateResult configuration -RSG1005 | Purview.Results.Usage | Error | Generated helper class name collision -RSG1006 | Purview.Results.Usage | Warning | Union case type is shared with another union -RSG1007 | Purview.Results.Usage | Error | Unsupported union member provider -RSG1008 | Purview.Results.Usage | Warning | Union inclusion case is ambiguous +; +; Every rule is recorded in AnalyzerReleases.Shipped.md under Release 1.0.0. Add a new or changed +; diagnostic here, and move it across when the next version ships. +; +; Note RSG2000 is the suppression id a consumer adds to NoWarn to turn the CA1815 suppression off. +; Suppression descriptors are not release-tracked, so it must not be listed in either file. diff --git a/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs b/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs index 78a7ce3..d63ba83 100644 --- a/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs +++ b/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs @@ -163,6 +163,45 @@ static class DiagnosticLibrary description: "A union may include another union as a case, which exposes the included union's cases through the union-receiver factory. When the same case type is reachable through two included unions, no single factory can name the construction path, so the case is skipped and reported. Only that case's inclusion factory is skipped; the union is still generated." ); + /// + /// A case type the generated helpers cannot be written against, such as an array type. + /// + /// + /// Accessibility and type-parameter checking both accept an array type — an array of a referenceable + /// element type is itself referenceable — but the emitted helpers and the union-receiver factory are + /// declared on a named type. Before this rule existed the generator cast the case to + /// INamedTypeSymbol regardless and threw , which took the whole + /// compilation's generated output with it. + /// + public static readonly DiagnosticDescriptor UnsupportedCaseType = new( + id: "RSG1009", + title: "Unsupported union case type", + messageFormat: "The case type '{0}' is not a named type, so no failure helper or factory can be generated for it; wrap it in a record or struct case instead", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "A union case must be a named type for the generated helpers to be declared against it. An array type, for example, is skipped and reported; the union's other cases are still generated." + ); + + /// + /// The generator failed unexpectedly while processing a union. + /// + /// + /// Reported instead of letting an exception escape the generator. An escaping exception surfaces as + /// CS8785 and discards all generated output for the compilation — so one malformed union + /// removes every other union's helpers and produces a cascade of unrelated errors — and raises + /// AD0001 in the IDE, where analysis then stops. + /// + public static readonly DiagnosticDescriptor UnhandledException = new( + id: "RSG9000", + title: "Unhandled exception in the results source generator", + messageFormat: "The results source generator failed while processing '{0}': {1}", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "An unexpected failure is reported against the union being processed rather than allowed to escape, so the rest of the compilation's generated output survives and the cause is identifiable. Please report it." + ); + /// /// Gets every rule in the catalogue, which is exactly the analyzer's supported diagnostics. /// @@ -177,6 +216,8 @@ static class DiagnosticLibrary SharedCaseType, UnsupportedMemberProvider, AmbiguousIncludedCase, + UnsupportedCaseType, + UnhandledException, ]; /// diff --git a/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs b/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs index c241ec2..df3c122 100644 --- a/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs +++ b/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs @@ -333,8 +333,25 @@ CancellationToken cancellationToken continue; } + // The accessibility and type-parameter checks above both accept an array type, so the case can + // reach here without being a named type. The generated helpers and the union-receiver factory + // are declared against a named type, so report and skip rather than casting — the cast threw + // InvalidCastException out of the generator, which discarded every other union's output too. + if (caseType is not INamedTypeSymbol namedCaseType) + { + diagnostics.Add( + ResultDiagnostic.Create( + DiagnosticLibrary.UnsupportedCaseType, + DiagnosticScope.Case, + caseLocation, + displayName + ) + ); + continue; + } + everyCaseIsPublic &= caseIsPublic; - caseTypes.Add((INamedTypeSymbol)caseType); + caseTypes.Add(namedCaseType); } return caseTypes.ToImmutable(); @@ -497,7 +514,13 @@ static ImmutableArray GetUsableCaseTypes(INamedTypeSymbol unio if (!TryGetReferenceableAccessibility(caseType, out _)) continue; - caseTypes.Add((INamedTypeSymbol)caseType); + // An array type passes both checks above but is not a named type. This path walks included + // unions and reports nothing of its own, so the case is simply skipped; the declaring union's + // own analysis reports it as RSG1009. + if (caseType is not INamedTypeSymbol namedCaseType) + continue; + + caseTypes.Add(namedCaseType); } return caseTypes.ToImmutable(); diff --git a/src/src/SourceGenerator/ResultsSourceGenerator.cs b/src/src/SourceGenerator/ResultsSourceGenerator.cs index 69ed0d0..97e13c4 100644 --- a/src/src/SourceGenerator/ResultsSourceGenerator.cs +++ b/src/src/SourceGenerator/ResultsSourceGenerator.cs @@ -1,7 +1,9 @@ using Microsoft.CodeAnalysis; +using Purview.Results.SourceGenerator.Diagnostics; using Purview.Results.SourceGenerator.Discovery; using Purview.Results.SourceGenerator.Emit; using Purview.Results.SourceGenerator.Helpers; +using Purview.Results.SourceGenerator.Models; using Purview.Results.SourceGenerator.Validation; namespace Purview.Results.SourceGenerator; @@ -56,7 +58,51 @@ public void Initialize(IncrementalGeneratorInitializationContext context) context.RegisterSourceOutput( generationModel, - static (sourceProductionContext, model) => ResultUnionEmitter.Execute(sourceProductionContext, model) + static (sourceProductionContext, model) => ExecuteSafely(sourceProductionContext, model) ); } + + /// + /// Runs the emitter, reporting an unexpected failure instead of letting it escape. + /// + /// + /// An exception escaping a generator surfaces as CS8785 and discards all generated output + /// for the compilation: one malformed union removes every other union's AsFailure helpers and + /// factories, producing a cascade of unrelated CS1061/CS0103 errors with no indication of + /// the cause. In the IDE it raises AD0001 and analysis stops. Reporting RSG9000 instead + /// keeps the rest of the output intact and names the cause. + /// + /// is deliberately not caught: the IDE cancels generation on + /// every keystroke, and swallowing that would both report spurious errors and defeat cancellation. + /// + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1031:Do not catch general exception types", + Justification = "This is the generator's last line of defence. Any exception that escapes discards " + + "every generated source in the compilation (CS8785) and stops IDE analysis (AD0001), so the " + + "catch is deliberately broad. Cancellation is rethrown." + )] + static void ExecuteSafely(SourceProductionContext context, ResultUnionGenerationModel model) + { + try + { + ResultUnionEmitter.Execute(context, model); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticLibrary.UnhandledException, + location: null, + "the results source generator", + exception.Message + ) + ); + } + } } diff --git a/src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs b/src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs index 4c46c52..7b50d4d 100644 --- a/src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs +++ b/src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs @@ -28,7 +28,7 @@ namespace Purview.Results.ZodSharp.AspNetCore; /// the status code, title, detail and issues extension. /// /// -public sealed class ZodResultsFailureMapper( +public sealed partial class ZodResultsFailureMapper( IOptions problemOptions, IOptions rules, IOptions results, @@ -72,15 +72,8 @@ IResult Render( ZodErrorRule? rule ) { - if (rule is not null && errors.Length > 1 && logger.IsEnabled(LogLevel.Debug)) - { - logger.LogDebug( - "The validation error rule for '{Rule}' answered a failure carrying {ErrorCount} validation errors with status {StatusCode}.", - rule.Value, - errors.Length, - statusCode - ); - } + if (rule is not null && errors.Length > 1) + Log.RuleAnsweredMultipleErrors(logger, rule.Value, errors.Length, statusCode); return ZodValidationProblems.ToProblem( errors, @@ -89,4 +82,26 @@ IResult Render( _results.IncludeTraceId ? context.HttpContext.TraceIdentifier : null ); } + + /// + /// The log messages this mapper emits, with stable s. + /// + /// + /// Source-generated, so the identifier is fixed and the message allocates nothing when Debug is + /// disabled — which also removes the hand-written IsEnabled guard the call site used to need. + /// + static partial class Log + { + [LoggerMessage( + EventId = 2000, + Level = LogLevel.Debug, + Message = "The validation error rule for '{Rule}' answered a failure carrying {ErrorCount} validation errors with status {StatusCode}." + )] + internal static partial void RuleAnsweredMultipleErrors( + ILogger logger, + string rule, + int errorCount, + int statusCode + ); + } } diff --git a/src/src/ZodSharp.AspNetCore/ZodSharp.AspNetCore.csproj b/src/src/ZodSharp.AspNetCore/ZodSharp.AspNetCore.csproj index e06d752..143f4c7 100644 --- a/src/src/ZodSharp.AspNetCore/ZodSharp.AspNetCore.csproj +++ b/src/src/ZodSharp.AspNetCore/ZodSharp.AspNetCore.csproj @@ -6,6 +6,13 @@ results;result;aspnetcore;problemdetails;validation;zod;purview; + + + true + + diff --git a/src/src/ZodSharp.AspNetCore/ZodValidationProblems.cs b/src/src/ZodSharp.AspNetCore/ZodValidationProblems.cs index e7b07d1..88009f5 100644 --- a/src/src/ZodSharp.AspNetCore/ZodValidationProblems.cs +++ b/src/src/ZodSharp.AspNetCore/ZodValidationProblems.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Http; using ZodSharp; using ZodSharp.AspNetCore; @@ -47,6 +48,27 @@ public static IResult ToProblem( if (!string.IsNullOrEmpty(traceId)) problem.Extensions["traceId"] = traceId; + return WriteProblem(problem, defaultStatusCode); + } + + [UnconditionalSuppressMessage( + "Trimming", + "IL2026:RequiresUnreferencedCode", + Justification = "The serialized type is the concrete HttpValidationProblemDetails, not object, and it " + + "is the same type ASP.NET Core's own validation-problem results write, so a trimmed or AOT host " + + "already roots it. TypedResults.Json carries the requirement unconditionally regardless of T." + )] + [UnconditionalSuppressMessage( + "AOT", + "IL3050:RequiresDynamicCode", + Justification = "The serialized type is the concrete HttpValidationProblemDetails, not object, and it " + + "is the same type ASP.NET Core's own validation-problem results write, so a trimmed or AOT host " + + "already roots it. TypedResults.Json carries the requirement unconditionally regardless of T." + )] + static IResult WriteProblem(HttpValidationProblemDetails problem, int defaultStatusCode) + { + // Written as JSON rather than through TypedResults.Problem because that serializes the declared + // ProblemDetails shape and would drop the validation `errors` member this type carries. return TypedResults.Json( problem, contentType: "application/problem+json", diff --git a/src/src/ZodSharp/ZodSharp.csproj b/src/src/ZodSharp/ZodSharp.csproj index 775ffed..bcef141 100644 --- a/src/src/ZodSharp/ZodSharp.csproj +++ b/src/src/ZodSharp/ZodSharp.csproj @@ -4,6 +4,14 @@ It stays a leaf library: Results never depends on ZodSharp, and consumers opt in by referencing this package. --> + + + true + + true Purview Results ZodSharp diff --git a/src/tests/AspNetCore.UnitTests/AspNetCore.UnitTests.csproj b/src/tests/AspNetCore.UnitTests/AspNetCore.UnitTests.csproj index 16fc8d4..3bfa98e 100644 --- a/src/tests/AspNetCore.UnitTests/AspNetCore.UnitTests.csproj +++ b/src/tests/AspNetCore.UnitTests/AspNetCore.UnitTests.csproj @@ -1,4 +1,13 @@ + + + + net11.0 + diff --git a/src/tests/AspNetCore.UnitTests/DefaultResultsHttpMapperTests.cs b/src/tests/AspNetCore.UnitTests/DefaultResultsHttpMapperTests.cs index f741c97..c4b9729 100644 --- a/src/tests/AspNetCore.UnitTests/DefaultResultsHttpMapperTests.cs +++ b/src/tests/AspNetCore.UnitTests/DefaultResultsHttpMapperTests.cs @@ -346,8 +346,11 @@ error is ItemBillingUnavailable unavailable [Test] public async Task Map_GivenUnmappedNestedUnionFailure_NamesTheLeafCase() { - // Arrange - var mapper = ResultsHttpTestFactory.CreateMapper(); + // Arrange — naming the error type in the response is opt-in, because it discloses internal + // namespace topology to the caller. Enabled here to assert the resolution picks the leaf case. + var mapper = ResultsHttpTestFactory.CreateMapper(static options => + options.IncludeErrorTypeInProblemDetails = true + ); var context = ResultsHttpTestFactory.CreateContext(); // Act @@ -361,6 +364,25 @@ public async Task Map_GivenUnmappedNestedUnionFailure_NamesTheLeafCase() await Assert.That(response.Body).Contains(typeof(ItemBillingUnavailable).FullName!); } + [Test] + public async Task Map_GivenUnmappedFailure_DoesNotNameTheErrorTypeByDefault() + { + // Arrange — the default must not leak the error's CLR type name to the caller. + var mapper = ResultsHttpTestFactory.CreateMapper(); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(CreateNestedBillingFailure(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status500InternalServerError); + await Assert.That(response.Body).DoesNotContain(typeof(ItemBillingUnavailable).FullName!); + await Assert.That(response.Body).DoesNotContain("errorType"); + } + static Result CreateNestedBillingFailure() => ((HttpTestBillingError)new ItemBillingUnavailable("down")).AsFailure(); } diff --git a/src/tests/AspNetCore.UnitTests/ResultsEndpointFilterTests.cs b/src/tests/AspNetCore.UnitTests/ResultsEndpointFilterTests.cs index 1dab557..507d49d 100644 --- a/src/tests/AspNetCore.UnitTests/ResultsEndpointFilterTests.cs +++ b/src/tests/AspNetCore.UnitTests/ResultsEndpointFilterTests.cs @@ -73,7 +73,12 @@ public async Task Endpoint_GivenUnmappedFailure_ReturnsAnInternalServerErrorProb // Assert await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.InternalServerError); - await Assert.That(await response.Content.ReadAsStringAsync()).Contains(nameof(ItemRejected)); + // The unmapped-failure response is identified by its title, not by the error's CLR type name: + // naming the type is opt-in because it discloses internal type names to the caller. + var body = await response.Content.ReadAsStringAsync(); + + await Assert.That(body).Contains("is not mapped to an HTTP response"); + await Assert.That(body).DoesNotContain(nameof(ItemRejected)); } [Test] diff --git a/src/tests/AspNetCore.UnitTests/ResultsHttpFailuresTests.cs b/src/tests/AspNetCore.UnitTests/ResultsHttpFailuresTests.cs index 06c435f..5a66c8e 100644 --- a/src/tests/AspNetCore.UnitTests/ResultsHttpFailuresTests.cs +++ b/src/tests/AspNetCore.UnitTests/ResultsHttpFailuresTests.cs @@ -7,8 +7,10 @@ public sealed class ResultsHttpFailuresTests [Test] public async Task Map_GivenUnmappedCase_ReturnsAnInternalServerErrorProblem() { - // Arrange - var mapper = ResultsHttpTestFactory.CreateMapper(); + // Arrange — the error type is opt-in because it discloses internal type names to the caller. + var mapper = ResultsHttpTestFactory.CreateMapper(static options => + options.IncludeErrorTypeInProblemDetails = true + ); var context = ResultsHttpTestFactory.CreateContext(); // Act diff --git a/src/tests/AspNetCore.UnitTests/ResultsHttpOptionsSafetyTests.cs b/src/tests/AspNetCore.UnitTests/ResultsHttpOptionsSafetyTests.cs new file mode 100644 index 0000000..e0a0d98 --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/ResultsHttpOptionsSafetyTests.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Http; +using Purview.Results.AspNetCore; + +namespace Purview.Results.AspNetCore; + +/// +/// Covers the information-disclosure and shared-state behaviour of . +/// +public class ResultsHttpOptionsSafetyTests +{ + [Test] + public async Task IncludeErrorTypeInProblemDetails_DefaultsToOff() + { + // The unmapped-failure path fires when something unexpected happened, and the extension would carry + // the error's full CLR type name to whoever made the request. That has to be opt-in. + ResultsHttpOptions options = new(); + + await Assert.That(options.IncludeErrorTypeInProblemDetails).IsFalse(); + } + + [Test] + public async Task ThrowOnUnmappedFailure_DefaultsToOff() + { + ResultsHttpOptions options = new(); + + await Assert.That(options.ThrowOnUnmappedFailure).IsFalse(); + } + + [Test] + public async Task Fallbacks_OnceRead_IgnoresLaterAdditions() + { + // Arrange — the options are startup configuration and the mapper reading them is a singleton, so + // the list is frozen on first read rather than mutated while request threads enumerate it. + ResultsHttpOptions options = new(); + options.AddFallback(static (_, _) => null); + + // Act + var firstRead = options.Fallbacks; + options.AddFallback(static (_, _) => null); + var secondRead = options.Fallbacks; + + // Assert + await Assert.That(firstRead.Count).IsEqualTo(1); + await Assert.That(secondRead.Count).IsEqualTo(1); + await Assert.That(ReferenceEquals(firstRead, secondRead)).IsTrue(); + } + + [Test] + public async Task Fallbacks_ReadConcurrently_IsStable() + { + // Arrange + ResultsHttpOptions options = new(); + options.AddFallback(static (_, _) => null); + options.AddFallback(static (_, _) => null); + + // Act — enumerate from many threads at once. Against the previous plain List this is the shape that + // produced "Collection was modified" when a fallback was added after startup. + var counts = await Task.WhenAll( + Enumerable.Range(0, 64).Select(_ => Task.Run(() => options.Fallbacks.Count(static f => f is not null))) + ); + + // Assert + await Assert.That(counts.Distinct().Count()).IsEqualTo(1); + await Assert.That(counts[0]).IsEqualTo(2); + } +} diff --git a/src/tests/AspNetCore.UnitTests/ResultsHttpTelemetryTests.cs b/src/tests/AspNetCore.UnitTests/ResultsHttpTelemetryTests.cs new file mode 100644 index 0000000..9d141b0 --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/ResultsHttpTelemetryTests.cs @@ -0,0 +1,152 @@ +using System.Diagnostics.Metrics; +using Microsoft.AspNetCore.Http; + +namespace Purview.Results.AspNetCore; + +/// +/// Covers the metrics the HTTP mapper emits. +/// +/// +/// The design treats an unmapped failure as a host mapping gap rather than a domain outcome, which is only +/// actionable if it can be counted. Previously the only signal was a log message, so answering "how often, +/// and for which error" meant scraping log text. +/// +public class ResultsHttpTelemetryTests +{ + [Test] + public async Task Map_GivenUnmappedFailure_CountsIt() + { + // Arrange + using MeterListener listener = new(); + var measurements = Listen(listener, "purview.results.failures.unmapped"); + + var mapper = ResultsHttpTestFactory.CreateMapper(); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + _ = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(new ItemNotFound(7).AsFailure(), context), + context + ); + + // Assert — filtered by the error-type tag rather than counted outright. The Meter is process-static + // and other tests in this assembly exercise the same counters concurrently, so an exact total would + // be flaky. + var mine = ForErrorType(measurements, typeof(ItemNotFound)); + + await Assert.That(mine.Count).IsGreaterThanOrEqualTo(1); + await Assert.That(mine[0].Value).IsEqualTo(1L); + } + + [Test] + public async Task Map_GivenUnmappedFailure_TagsTheMeasurementWithTheErrorType() + { + // Arrange — the type name is the dimension that makes the count actionable. Metrics stay inside the + // host's own infrastructure, so unlike the HTTP response it is always included. + using MeterListener listener = new(); + var measurements = Listen(listener, "purview.results.failures.unmapped"); + + var mapper = ResultsHttpTestFactory.CreateMapper(); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + _ = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(new ItemNotFound(7).AsFailure(), context), + context + ); + + // Assert + var tag = measurements[0].Tags.Single(static pair => pair.Key == ResultsHttpTelemetry.ErrorTypeTag); + + await Assert.That(tag.Value).IsEqualTo(typeof(ItemNotFound).FullName); + } + + [Test] + public async Task Map_GivenMappedFailure_CountsItAsMappedNotUnmapped() + { + // Arrange + using MeterListener listener = new(); + // One Listen per listener: it registers the callback and starts the listener, so calling it twice + // overwrites the first subscription. + var mapped = Listen(listener, "purview.results.failures.mapped"); + + var mapper = ResultsHttpTestFactory.CreateMapper(static options => + options.Map(static _ => TypedResults.Problem(statusCode: StatusCodes.Status404NotFound)) + ); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(new ItemNotFound(7).AsFailure(), context), + context + ); + + // Assert — the 404 proves the mapped path ran, and the mapped counter recorded it against this + // error type. The unmapped counter is not asserted here: the counters are process-static and a + // sibling test deliberately produces an unmapped ItemNotFound, so it cannot be isolated. + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status404NotFound); + await Assert.That(ForErrorType(mapped, typeof(ItemNotFound)).Count).IsGreaterThanOrEqualTo(1); + } + + [Test] + public async Task Map_GivenUninitializedResult_CountsIt() + { + // Arrange — an endpoint returning default is always a bug, so it is counted separately. + using MeterListener listener = new(); + var measurements = Listen(listener, "purview.results.uninitialized"); + + var mapper = ResultsHttpTestFactory.CreateMapper(); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + _ = await ResultsHttpTestFactory.ExecuteAsync(mapper.Map(default(Result), context), context); + + // Assert — the uninitialized counter carries no error type, so this one is only tolerant of extras. + await Assert.That(measurements.Count).IsGreaterThanOrEqualTo(1); + } + + [Test] + public async Task MeterName_IsStable() + { + // Consumers register this name with their metrics pipeline, so it is public contract. + await Assert.That(ResultsHttpTelemetry.MeterName).IsEqualTo("Purview.Results.AspNetCore"); + await Assert.That(ResultsHttpTelemetry.ErrorTypeTag).IsEqualTo("purview.results.error_type"); + } + + /// + /// The measurements tagged with . + /// + static List<(long Value, KeyValuePair[] Tags)> ForErrorType( + List<(long Value, KeyValuePair[] Tags)> measurements, + Type errorType + ) => + [ + .. measurements.Where(measurement => + measurement.Tags.Any(tag => + tag.Key == ResultsHttpTelemetry.ErrorTypeTag && (string?)tag.Value == errorType.FullName + ) + ), + ]; + + /// + /// Subscribes to one instrument by name and collects its measurements. + /// + static List<(long Value, KeyValuePair[] Tags)> Listen(MeterListener listener, string instrument) + { + List<(long, KeyValuePair[])> measurements = []; + + listener.InstrumentPublished += (published, active) => + { + if (published.Meter.Name == ResultsHttpTelemetry.MeterName && published.Name == instrument) + active.EnableMeasurementEvents(published); + }; + + listener.SetMeasurementEventCallback( + (_, measurement, tags, _) => measurements.Add((measurement, tags.ToArray())) + ); + + listener.Start(); + + return measurements; + } +} diff --git a/src/tests/Results.UnitTests/AcceptSuccessTests.cs b/src/tests/Results.UnitTests/AcceptSuccessTests.cs new file mode 100644 index 0000000..5f1572f --- /dev/null +++ b/src/tests/Results.UnitTests/AcceptSuccessTests.cs @@ -0,0 +1,113 @@ +namespace Purview.Results; + +/// +/// Pins the double-dispatch contract of . +/// +/// +/// erases the value to . Infrastructure that has +/// to act on the value generically — serializing it — would then bind its generic parameter to +/// , leaving a trimmed or Native AOT host with no type to resolve, and in ASP.NET Core +/// erasing the response type OpenAPI infers. These tests assert the real type arrives instead. +/// +public class AcceptSuccessTests +{ + [Test] + public async Task AcceptSuccess_GivenValueResult_VisitsWithTheDeclaredValueType() + { + // Arrange + IResultValue result = Result.Success(42); + var visitor = new RecordingVisitor(); + + // Act + var visitedType = result.AcceptSuccess(visitor, 0); + + // Assert — int, not object. This is the whole point. + await Assert.That(visitedType).IsEqualTo(typeof(int)); + await Assert.That(visitor.CapturedValue).IsEqualTo(42); + } + + [Test] + public async Task AcceptSuccess_GivenReferenceTypeValue_VisitsWithTheDeclaredValueType() + { + // Arrange — a reference type must not widen to object either. + IResultValue result = Result.Success("acme"); + var visitor = new RecordingVisitor(); + + // Act + var visitedType = result.AcceptSuccess(visitor, 0); + + // Assert + await Assert.That(visitedType).IsEqualTo(typeof(string)); + await Assert.That(visitor.CapturedValue).IsEqualTo("acme"); + } + + [Test] + public async Task AcceptSuccess_GivenUnitResult_VisitsWithTheSuccessMarker() + { + // Arrange — the unit result's value is the marker, which is how infrastructure tells a payload-free + // success from one carrying a value. + IResultValue result = Result.Success(); + var visitor = new RecordingVisitor(); + + // Act + var visitedType = result.AcceptSuccess(visitor, 0); + + // Assert + await Assert.That(visitedType).IsEqualTo(typeof(Success)); + } + + [Test] + public async Task AcceptSuccess_PassesStateThroughWithoutCapturing() + { + // Arrange + IResultValue result = Result.Success(7); + var visitor = new RecordingVisitor(); + + // Act + _ = result.AcceptSuccess(visitor, 99); + + // Assert + await Assert.That(visitor.CapturedState).IsEqualTo(99); + } + + [Test] + public async Task AcceptSuccess_GivenNullVisitor_Throws() + { + IResultValue valueResult = Result.Success(1); + IResultValue unitResult = Result.Success(); + + await Assert.That(() => valueResult.AcceptSuccess(null!, 0)).Throws(); + await Assert.That(() => unitResult.AcceptSuccess(null!, 0)).Throws(); + } + + [Test] + public async Task AcceptSuccess_GivenFailure_DoesNotThrow() + { + // Arrange — like every other IResultValue member this must never throw, so infrastructure can call it + // before branching on IsSuccess. + IResultValue result = Result.Failure("nope"); + var visitor = new RecordingVisitor(); + + // Act + var visitedType = result.AcceptSuccess(visitor, 0); + + // Assert + await Assert.That(visitedType).IsEqualTo(typeof(int)); + await Assert.That(visitor.CapturedValue).IsEqualTo(0); + } + + sealed class RecordingVisitor : IResultValueVisitor + { + public object? CapturedValue { get; private set; } + + public int CapturedState { get; private set; } + + public Type VisitSuccess(TValue value, int state) + { + CapturedValue = value; + CapturedState = state; + + return typeof(TValue); + } + } +} diff --git a/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs b/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs index e238ca9..f5ee3ef 100644 --- a/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs +++ b/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs @@ -34,6 +34,8 @@ await Assert "RSG1006", "RSG1007", "RSG1008", + "RSG1009", + "RSG9000", ]); } diff --git a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorArrayCaseTests.cs b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorArrayCaseTests.cs new file mode 100644 index 0000000..aa78ba7 --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorArrayCaseTests.cs @@ -0,0 +1,56 @@ +namespace Purview.Results.SourceGenerator; + +/// +/// Covers an array-typed union case. +/// +/// +/// The discovery code explicitly handles IArrayTypeSymbol when deciding accessibility and when +/// looking for type parameters, and then cast the case type to INamedTypeSymbol — which an array is +/// not. An array-typed case therefore threw out of the generator, and +/// because there is no catch-all in the pipeline the compiler reported CS8785 and dropped +/// all generated output for the compilation: every AsFailure helper and union factory +/// vanished, producing a cascade of unrelated errors with no indication of the real cause. In the IDE the +/// analyzer raised AD0001 and stopped analysing. +/// +public class ResultsSourceGeneratorArrayCaseTests + : TUnitSourceGeneratorTestBase +{ + const string ArrayCaseSource = """ + namespace Test + { + public readonly record struct TenantId(string Value); + + public readonly record struct TenantNotFound(TenantId TenantId); + + [GenerateResult] + public readonly union TenantError(TenantNotFound, string[]); + } + """; + + [Test] + public async Task GenerateAsync_GivenArrayTypedCase_DoesNotThrowOutOfTheGenerator( + CancellationToken cancellationToken + ) + { + // Act — the generator must report or skip, never crash. A crash takes every other union in the + // compilation down with it. + var result = await GenerateAsync(ArrayCaseSource, cancellationToken); + + // Assert + await Assert.That(result).IsNotNull(); + } + + [Test] + public async Task GenerateAsync_GivenArrayTypedCase_StillEmitsHelpersForTheValidCases( + CancellationToken cancellationToken + ) + { + // Act + var result = await GenerateAsync(ArrayCaseSource, cancellationToken); + + // Assert — the named case is unaffected by the unusable one. + var generated = result.AssertSingleGeneratedSource(); + + await Assert.That(generated).Contains("this global::Test.TenantNotFound error"); + } +} diff --git a/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj b/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj index 626d400..eb0eba4 100644 --- a/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj +++ b/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj @@ -1,4 +1,13 @@ + + + + net11.0 + + + net11.0 + diff --git a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodSharpResultsHttpRegistrationTests.cs b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodSharpResultsHttpRegistrationTests.cs index 3a7cc65..c08461e 100644 --- a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodSharpResultsHttpRegistrationTests.cs +++ b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodSharpResultsHttpRegistrationTests.cs @@ -43,7 +43,9 @@ public async Task Map_GivenErrorThatIsNotAValidationCarrier_ReturnsTheUnmappedPr // Assert await Assert.That(statusCode).IsEqualTo(StatusCodes.Status500InternalServerError); - await Assert.That(body).Contains(nameof(ItemMissing)); + // Identified by the unmapped title rather than the error's CLR type name, which is opt-in. + await Assert.That(body).Contains("is not mapped to an HTTP response"); + await Assert.That(body).DoesNotContain(nameof(ItemMissing)); } [Test] diff --git a/src/tests/ZodSharp.UnitTests/ZodSharp.UnitTests.csproj b/src/tests/ZodSharp.UnitTests/ZodSharp.UnitTests.csproj index c8d6e8f..ceb5f77 100644 --- a/src/tests/ZodSharp.UnitTests/ZodSharp.UnitTests.csproj +++ b/src/tests/ZodSharp.UnitTests/ZodSharp.UnitTests.csproj @@ -1,4 +1,13 @@ + + + + net11.0 + - - net10.0;net11.0 + Supported preview