diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..01e314d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,196 @@ +[*.{cs,vb}] +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Naming styles + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case +dotnet_style_operator_placement_when_wrapping = beginning_of_line +tab_width = 4 +indent_size = 4 +end_of_line = crlf +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion +dotnet_style_namespace_match_folder = true:suggestion +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent +dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion +dotnet_style_readonly_field = true:suggestion +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent +dotnet_style_allow_multiple_blank_lines_experimental = true:silent +dotnet_style_allow_statement_immediately_after_block_experimental = true:silent +dotnet_code_quality_unused_parameters = all:suggestion +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_property = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_event = false:silent + +[*.cs] +csharp_indent_labels = one_less_than_current +csharp_using_directive_placement = outside_namespace:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_prefer_braces = true:silent +csharp_style_namespace_declarations = file_scoped:suggestion +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_prefer_system_threading_lock = true:suggestion +csharp_style_expression_bodied_methods = true:warning +csharp_style_expression_bodied_constructors = true:warning +csharp_style_expression_bodied_operators = true:warning +csharp_style_expression_bodied_properties = true:warning +csharp_style_expression_bodied_indexers = true:warning +csharp_style_expression_bodied_accessors = true:warning +csharp_style_expression_bodied_lambdas = true:warning +csharp_style_expression_bodied_local_functions = true:warning +csharp_space_around_binary_operators = before_and_after +csharp_style_prefer_unbound_generic_type_in_nameof = true:suggestion +csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent +csharp_style_conditional_delegate_call = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent +csharp_prefer_static_local_function = true:suggestion +csharp_prefer_static_anonymous_function = true:suggestion +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion +csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent +csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent +csharp_style_prefer_switch_expression = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_extended_property_pattern = true:suggestion +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = false:silent +csharp_style_var_elsewhere = false:silent +dotnet_diagnostic.SA1600.severity = none +dotnet_diagnostic.SA1633.severity = none +dotnet_diagnostic.SA1200.severity = none +dotnet_diagnostic.IDE0005.severity = warning +dotnet_diagnostic.SA1313.severity = none +dotnet_diagnostic.IDE1006.severity = warning +dotnet_diagnostic.IDE0055.severity = warning +dotnet_diagnostic.RCS1021.severity = warning +dotnet_diagnostic.SA1623.severity = none +dotnet_diagnostic.MA0004.severity = none +dotnet_diagnostic.SA1101.severity = none +dotnet_diagnostic.SA1503.severity = none +dotnet_diagnostic.SA1000.severity = none +dotnet_diagnostic.SA1009.severity = none +dotnet_diagnostic.RCS1194.severity = none +dotnet_diagnostic.SA1106.severity = none +dotnet_diagnostic.SA1602.severity = none +dotnet_diagnostic.SA1601.severity = none +dotnet_diagnostic.IDE0270.severity = warning +dotnet_diagnostic.CA2016.severity = warning +dotnet_diagnostic.MA0011.severity = none +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + +# CS1591: Missing XML comment for publicly visible type or member +dotnet_diagnostic.CS1591.severity = none +dotnet_diagnostic.SA1500.severity = none +dotnet_diagnostic.SA1502.severity = none +dotnet_diagnostic.SA1508.severity = none +dotnet_diagnostic.RCS0058.severity = warning +dotnet_diagnostic.SA1309.severity = none +dotnet_diagnostic.RCS0063.severity = warning +dotnet_diagnostic.RCS1251.severity = warning +dotnet_diagnostic.MA0089.severity = warning +dotnet_diagnostic.RCS1163.severity = warning +dotnet_diagnostic.SA1010.severity = none +dotnet_diagnostic.IDE0300.severity = warning +dotnet_diagnostic.MA0026.severity = none +dotnet_diagnostic.S1135.severity = none +dotnet_diagnostic.SA1011.severity = none +dotnet_diagnostic.RCS1097.severity = warning +dotnet_diagnostic.RCS1132.severity = warning +dotnet_diagnostic.RCS1016.severity = warning +dotnet_diagnostic.CA1000.severity = none +dotnet_diagnostic.CA1716.severity = none +dotnet_diagnostic.IDE0130.severity = warning +dotnet_diagnostic.CA1711.severity = none +dotnet_diagnostic.S2326.severity = none +dotnet_diagnostic.RCS1214.severity = warning +dotnet_diagnostic.MA0015.severity = none +dotnet_diagnostic.MA0097.severity = none +dotnet_diagnostic.IDE0008.severity = none +dotnet_diagnostic.SA0001.severity = none +dotnet_diagnostic.IDE0005.severity = none +dotnet_diagnostic.MA0048.severity = none +dotnet_diagnostic.IDE0011.severity = none +dotnet_diagnostic.SA1118.severity = none + +[**/Data/Migrations/*.cs] +generated_code = true +dotnet_analyzer_diagnostic.severity = none \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..c4d6ae2 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,34 @@ +name: Main + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + + code-validation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 11.0.x + dotnet-quality: preview + - name: Restore + run: dotnet restore + - name: Build + run: dotnet build --no-restore + - name: Test + run: dotnet test --no-build /p:CollectCoverage=true /p:ExcludeByAttribute="GeneratedCodeAttribute" /p:ExcludeByAttribute="CompilerGeneratedAttribute" /p:CoverletOutputFormat=\"json,cobertura\" /p:CoverletOutput=../../coverage/ /p:MergeWith=../../coverage/coverage.json + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage/coverage.cobertura.xml + fail_ci_if_error: true + verbose: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..24b9edd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release + +on: + push: + tags: + - '*.*.*' +jobs: + + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set current Tag + run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV + - name: Check current Tag + run: | + echo $RELEASE_VERSION + echo ${{ env.RELEASE_VERSION }} + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 11.0.x + dotnet-quality: preview + - name: Generate Package + run: | + dotnet clean -c Release + dotnet build -c Release + dotnet test -c Release --no-build --verbosity normal + dotnet pack src/UnionResult/UnionResult.csproj --output ./nupkgs -c Release --no-build /p:Version=${{ env.RELEASE_VERSION }} + - name: Push to NuGet + run: | + dotnet nuget push ./nupkgs/*.nupkg -k ${{ secrets.NUGET_API_KEY }} -s https://api.nuget.org/v3/index.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca328b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,404 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +*.idea + +.qodo +.qwen \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..6475f00 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,15 @@ + + + net11.0 + preview + enable + enable + latest + Recommended + true + true + true + all + moderate + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..33d5336 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,27 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index bb2140e..418c0e1 100644 --- a/README.md +++ b/README.md @@ -1 +1,194 @@ -# UnionResult \ No newline at end of file +

+ UnionResult +

+ +# UnionResult + +[![NuGet](https://img.shields.io/nuget/v/UnionResult.svg)](https://www.nuget.org/packages/UnionResult/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) +[![.NET 11](https://img.shields.io/badge/.NET-11.0--preview-512BD4)](https://dotnet.microsoft.com/) +[![C# union types](https://img.shields.io/badge/C%23-union%20types-informational)](https://learn.microsoft.com/dotnet/csharp/language-reference/builtin-types/union) +[![main](https://github.com/Adolfok3/UnionResult/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/Adolfok3/UnionResult/actions/workflows/main.yml) +[![tests](https://img.shields.io/github/actions/workflow/status/Adolfok3/UnionResult/main.yml?branch=main&label=tests)](https://github.com/Adolfok3/UnionResult/actions/workflows/main.yml) +[![tests count](https://img.shields.io/badge/tests-34%20passing-brightgreen)](./tests/UnionResult.Tests) +[![codecov](https://codecov.io/gh/Adolfok3/UnionResult/branch/main/graph/badge.svg)](https://codecov.io/gh/Adolfok3/UnionResult) + +A Result pattern implementation for .NET, built on top of C#'s new native `union` types. + +## Why + +`UnionResult` isn't about functional programming for its own sake - it exists to avoid unnecessary exception overhead in everyday application code. Throwing and catching exceptions is expensive and meant for truly exceptional situations, not for outcomes an operation can expect and a caller should routinely handle (validation failures, "not found", a downstream call that failed). `Result`/`Result` let a method return both possible outcomes explicitly, so the caller decides how to react instead of wrapping every call in `try`/`catch`. + +Several packages already implement this pattern for .NET. What sets `UnionResult` apart is the implementation: instead of a hand-rolled discriminated-union struct, it's built directly on top of C#'s new native `union` type (introduced as a preview language feature in .NET 11) - success/failure is a real, compiler-recognized union with exhaustive pattern-matching support, not a struct that merely looks like one. + +## Requirements + +Union types are a **preview C# language feature**. To use this package your project needs: + +```xml +net11.0 +preview +``` + +and the .NET 11 preview SDK. + +## Installation + +```bash +dotnet add package UnionResult +``` + +Or via the Package Manager Console: + +```powershell +Install-Package UnionResult +``` + +## Usage + +### `Result` - an operation that returns a value + +```csharp +using UnionResult; + +Result Divide(int a, int b) => + b == 0 + ? Result.Failure(new DivideByZeroException()) + : Result.Success(a / b); + +var result = Divide(10, 2); + +var message = result.IsSuccess + ? $"Result: {result.AsValue()}" + : $"Failed: {result.AsException().Message}"; +``` + +### `Result` - an operation with no return value + +```csharp +using UnionResult; + +Result Save(User user) => + repository.TrySave(user, out var error) + ? Result.Success() + : Result.Failure(error); + +var result = Save(user); + +if (result.IsFailure) +{ + logger.LogError(result.AsException(), "Failed to save user"); +} +``` + +### Implicit conversions + +`Success`/`Failure` aren't the only way to build a result - since `Result` and `Result` are real C# unions, a value of a case type converts implicitly, so you can just `return` it directly: + +```csharp +using UnionResult; + +Result Divide(int a, int b) +{ + if (b == 0) + { + return new DivideByZeroException(); // implicitly converts to Result + } + + return a / b; // implicitly converts to Result +} + +Result r = 42; // same conversion, assigned directly +``` + +The same applies to the failure case of the non-generic `Result` (there's no implicit conversion for "success with no value", so that side still calls `Result.Success()` explicitly): + +```csharp +Result Save(User user) +{ + if (!repository.TrySave(user, out var error)) + { + return error; // implicitly converts to Result + } + + return Result.Success(); +} +``` + +### A more realistic example: replacing exceptions at a service boundary + +```csharp +public interface IUserRepository +{ + Result GetById(int id); +} + +public class UserService(IUserRepository repository) +{ + public string GetDisplayName(int id) + { + var result = repository.GetById(id); + return result.IsSuccess ? result.AsValue().Name : "Unknown user"; + } +} +``` + +Here `GetById` communicates "this can fail" through its return type. The caller isn't forced to guess whether the repository throws, and no `try`/`catch` is needed just to keep the app running when a lookup fails. + +### Pattern matching + +Because `Result` is a real C# union, it also supports exhaustive pattern matching: + +```csharp +var message = result switch +{ + int value => $"Result: {value}", + Exception ex => $"Failed: {ex.Message}", +}; +``` + +### API reference + +| Member | Description | +|---|---| +| `Result.Success(T value)` / `Result.Success()` | Creates a successful result. | +| `Result.Failure(Exception)` / `Result.Failure(Exception)` | Creates a failed result. Throws `ArgumentNullException` if `exception` is `null`. | +| `IsSuccess` / `IsFailure` | Whether the result is a success or a failure. | +| `AsValue()` *(`Result` only)* | Returns the success value, or throws `InvalidOperationException` if the result is a failure. | +| `AsException()` | Returns the failure's exception, or throws `InvalidOperationException` if the result is a success. | +| `TryGetValue(out T value)` / `TryGetValue(out Exception value)` | Non-throwing variants of `AsValue()`/`AsException()`. | +| `HasValue` | Whether the result holds any case at all (`false` only for `default(Result)`). | + +## Benchmarks + +`UnionResult` was benchmarked against [`Divino.OperationResult`](https://github.com/victorDivino/operationResult), a similar Result-pattern package, across three scenarios: a value-type payload (`int`), a reference-type payload (`Product`), and no payload at all. Each scenario measures creating a result (`Create`) and reading an already-built one (`Read`), for both the success and failure paths. + +**Environment:** BenchmarkDotNet v0.15.8, Windows 11, AMD Ryzen 7 7800X3D 4.20GHz, .NET SDK 11.0.100-preview.5, in-process toolchain. + +| Category | Operation | UnionResult | OperationResult | Allocated | Winner | +|---|---|---:|---:|:---:|---| +| **Value type (int)** | Create Success | 0.0044 ns | 0.0000 ns | 0 B (both) | tie (noise) | +| | Create Failure | 0.2049 ns | 0.6265 ns | 0 B (both) | UnionResult (~3x) | +| | Read Success | 0.4157 ns | 0.0023 ns | 0 B (both) | OperationResult | +| | Read Failure | 0.6244 ns | 0.2201 ns | 0 B (both) | OperationResult (~3x) | +| **Reference type (Product)** | Create Success | 0.1977 ns | 0.2250 ns | 0 B (both) | tie | +| | Create Failure | 0.2374 ns | 1.1618 ns | 0 B (both) | UnionResult (~5x) | +| | Read Success | 0.6298 ns | 0.2062 ns | 0 B (both) | OperationResult (~3x) | +| | Read Failure | 0.6286 ns | 0.2114 ns | 0 B (both) | OperationResult (~3x) | +| **No payload** | Create Success | 0.0001 ns | 0.0024 ns | 0 B (both) | tie (noise) | +| | Create Failure | 0.2248 ns | 0.6071 ns | 0 B (both) | UnionResult (~2.7x) | +| | Read Success | 0.0327 ns | 0.0005 ns | 0 B (both) | tie (noise) | +| | Read Failure | 0.6227 ns | 0.2338 ns | 0 B (both) | OperationResult (~2.7x) | + +**Takeaways:** + +- Neither package allocates in any scenario measured. +- `UnionResult` matches or beats `OperationResult` on every `Create` scenario - notably ~3-5x faster creating a failure. +- `OperationResult` is consistently faster to *read* an already-built result (~0.2-0.4 ns) - `AsValue()`/`AsException()` validate the result's state before returning, which is the cost of that safety check. +- Values under ~0.03 ns aren't meaningful differences - that's the measurement floor of the benchmark itself. + +The benchmark source lives in [`benchmarks/UnionResult.Benchmarks`](./benchmarks/UnionResult.Benchmarks). + +## License + +MIT - see [LICENSE](./LICENSE). diff --git a/UnionResult.slnx b/UnionResult.slnx new file mode 100644 index 0000000..557898b --- /dev/null +++ b/UnionResult.slnx @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..4f044d8 Binary files /dev/null and b/assets/icon.png differ diff --git a/benchmarks/UnionResult.Benchmarks/ExceptionOnlyResultBenchmarks.cs b/benchmarks/UnionResult.Benchmarks/ExceptionOnlyResultBenchmarks.cs new file mode 100644 index 0000000..701d0e4 --- /dev/null +++ b/benchmarks/UnionResult.Benchmarks/ExceptionOnlyResultBenchmarks.cs @@ -0,0 +1,52 @@ +using BenchmarkDotNet.Attributes; +using UnionResult; + +namespace UnionResult.Benchmarks; + +/// +/// Compares the non-generic Result from each package: no payload, just a +/// success/failure signal plus (on failure) the causing exception. +/// +[Config(typeof(InProcessConfig))] +public class ExceptionOnlyResultBenchmarks +{ + private static readonly InvalidOperationException FailureException = new("boom"); + + private Result _unionSuccess; + private Result _unionFailure; + private OperationResult.Result _operationSuccess; + private OperationResult.Result _operationFailure; + + [GlobalSetup] + public void Setup() + { + _unionSuccess = Result.Success(); + _unionFailure = Result.Failure(FailureException); + _operationSuccess = OperationResult.Result.Success(); + _operationFailure = OperationResult.Result.Error(FailureException); + } + + [Benchmark] + public bool UnionResultCreateSuccess() => Result.Success().IsSuccess; + + [Benchmark] + public bool OperationResultCreateSuccess() => OperationResult.Result.Success().IsSuccess; + + [Benchmark] + public Exception UnionResultCreateFailure() => Result.Failure(FailureException).AsException(); + + [Benchmark] + public Exception OperationResultCreateFailure() => OperationResult.Result.Error(FailureException).Exception!; + + [Benchmark] + public bool UnionResultReadSuccess() => _unionSuccess.IsSuccess; + + [Benchmark] + public bool OperationResultReadSuccess() => _operationSuccess.IsSuccess; + + [Benchmark] + public Exception UnionResultReadFailure() => _unionFailure.AsException(); + + [Benchmark] + public Exception OperationResultReadFailure() => _operationFailure.Exception!; +} diff --git a/benchmarks/UnionResult.Benchmarks/InProcessConfig.cs b/benchmarks/UnionResult.Benchmarks/InProcessConfig.cs new file mode 100644 index 0000000..0eb2f23 --- /dev/null +++ b/benchmarks/UnionResult.Benchmarks/InProcessConfig.cs @@ -0,0 +1,21 @@ +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.Emit; + +namespace UnionResult.Benchmarks; + +/// +/// Runs benchmarks in-process instead of spawning a separate generated project. +/// BenchmarkDotNet 0.15.x does not yet recognize the net11.0 preview runtime moniker, +/// which makes its out-of-process SDK validation throw; running in-process skips +/// that validation entirely while still using MemoryDiagnoser for allocation data. +/// +public sealed class InProcessConfig : ManualConfig +{ + public InProcessConfig() + { + AddJob(Job.Default.WithToolchain(InProcessEmitToolchain.Instance)); + AddDiagnoser(MemoryDiagnoser.Default); + } +} diff --git a/benchmarks/UnionResult.Benchmarks/Product.cs b/benchmarks/UnionResult.Benchmarks/Product.cs new file mode 100644 index 0000000..55271d0 --- /dev/null +++ b/benchmarks/UnionResult.Benchmarks/Product.cs @@ -0,0 +1,3 @@ +namespace UnionResult.Benchmarks; + +public sealed record Product(int Id, string Name, decimal Price); diff --git a/benchmarks/UnionResult.Benchmarks/Program.cs b/benchmarks/UnionResult.Benchmarks/Program.cs new file mode 100644 index 0000000..4cf403e --- /dev/null +++ b/benchmarks/UnionResult.Benchmarks/Program.cs @@ -0,0 +1,9 @@ +using BenchmarkDotNet.Running; +using UnionResult.Benchmarks; + +BenchmarkRunner.Run( +[ + typeof(ValueTypeResultBenchmarks), + typeof(ReferenceTypeResultBenchmarks), + typeof(ExceptionOnlyResultBenchmarks), +]); diff --git a/benchmarks/UnionResult.Benchmarks/Properties/launchSettings.json b/benchmarks/UnionResult.Benchmarks/Properties/launchSettings.json new file mode 100644 index 0000000..9714da5 --- /dev/null +++ b/benchmarks/UnionResult.Benchmarks/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "UnionResult.Benchmarks": { + "commandName": "Project", + "hotReloadEnabled": false + } + } +} \ No newline at end of file diff --git a/benchmarks/UnionResult.Benchmarks/ReferenceTypeResultBenchmarks.cs b/benchmarks/UnionResult.Benchmarks/ReferenceTypeResultBenchmarks.cs new file mode 100644 index 0000000..bdb6693 --- /dev/null +++ b/benchmarks/UnionResult.Benchmarks/ReferenceTypeResultBenchmarks.cs @@ -0,0 +1,57 @@ +using BenchmarkDotNet.Attributes; +using UnionResult; + +namespace UnionResult.Benchmarks; + +/// +/// Compares the two packages when T is a reference type (Product). Neither package +/// needs to box a reference type, so this isolates the pure wrapping/reading overhead +/// of each Result shape from the boxing cost measured in . +/// Create and Read are benchmarked separately for the same reason as the value-type case: +/// it keeps the two costs (producing vs consuming a Result) from being conflated. Both use +/// the non-boxing accessor (AsValue()/.Value), matching normal API usage. +/// +[Config(typeof(InProcessConfig))] +public class ReferenceTypeResultBenchmarks +{ + private static readonly Product SuccessValue = new(1, "Keyboard", 199.90m); + private static readonly InvalidOperationException FailureException = new("boom"); + + private Result _unionSuccess; + private Result _unionFailure; + private OperationResult.Result _operationSuccess; + private OperationResult.Result _operationFailure; + + [GlobalSetup] + public void Setup() + { + _unionSuccess = Result.Success(SuccessValue); + _unionFailure = Result.Failure(FailureException); + _operationSuccess = OperationResult.Result.Success(SuccessValue); + _operationFailure = OperationResult.Result.Error(FailureException); + } + + [Benchmark] + public Product UnionResultCreateSuccess() => Result.Success(SuccessValue).AsValue(); + + [Benchmark] + public Product OperationResultCreateSuccess() => OperationResult.Result.Success(SuccessValue).Value!; + + [Benchmark] + public Exception UnionResultCreateFailure() => Result.Failure(FailureException).AsException(); + + [Benchmark] + public Exception OperationResultCreateFailure() => OperationResult.Result.Error(FailureException).Exception!; + + [Benchmark] + public Product UnionResultReadSuccess() => _unionSuccess.AsValue(); + + [Benchmark] + public Product OperationResultReadSuccess() => _operationSuccess.Value!; + + [Benchmark] + public Exception UnionResultReadFailure() => _unionFailure.AsException(); + + [Benchmark] + public Exception OperationResultReadFailure() => _operationFailure.Exception!; +} diff --git a/benchmarks/UnionResult.Benchmarks/UnionResult.Benchmarks.csproj b/benchmarks/UnionResult.Benchmarks/UnionResult.Benchmarks.csproj new file mode 100644 index 0000000..895694f --- /dev/null +++ b/benchmarks/UnionResult.Benchmarks/UnionResult.Benchmarks.csproj @@ -0,0 +1,17 @@ + + + + Exe + false + + + + + + + + + + + + diff --git a/benchmarks/UnionResult.Benchmarks/ValueTypeResultBenchmarks.cs b/benchmarks/UnionResult.Benchmarks/ValueTypeResultBenchmarks.cs new file mode 100644 index 0000000..5b0e38d --- /dev/null +++ b/benchmarks/UnionResult.Benchmarks/ValueTypeResultBenchmarks.cs @@ -0,0 +1,62 @@ +using BenchmarkDotNet.Attributes; +using UnionResult; + +namespace UnionResult.Benchmarks; + +/// +/// Compares the two packages when T is a value type (int). Result<T> stores T and +/// Exception in their own typed fields behind a tag (the union "non-boxing access +/// pattern" via TryGetValue), so AsValue()/AsException() never box; OperationResult.Result<T> +/// likewise stores T directly in a typed field. Both packages still expose a boxing +/// `object? Value` property (required by IUnion for UnionResult; OperationResult has no +/// such requirement but its own `.Value` is already unboxed for value types), but neither +/// AsValue() nor a normal consumer needs to go through it. +/// +/// Create and Read are benchmarked separately so the cost of producing a Result isn't +/// conflated with the cost of consuming one. Both go through the non-boxing accessor +/// (AsValue()/.Value) so the numbers reflect how the API is actually meant to be used. +/// +[Config(typeof(InProcessConfig))] +public class ValueTypeResultBenchmarks +{ + private static readonly int SuccessValue = 42; + private static readonly InvalidOperationException FailureException = new("boom"); + + private Result _unionSuccess; + private Result _unionFailure; + private OperationResult.Result _operationSuccess; + private OperationResult.Result _operationFailure; + + [GlobalSetup] + public void Setup() + { + _unionSuccess = Result.Success(SuccessValue); + _unionFailure = Result.Failure(FailureException); + _operationSuccess = OperationResult.Result.Success(SuccessValue); + _operationFailure = OperationResult.Result.Error(FailureException); + } + + [Benchmark] + public int UnionResultCreateSuccess() => Result.Success(SuccessValue).AsValue(); + + [Benchmark] + public int OperationResultCreateSuccess() => OperationResult.Result.Success(SuccessValue).Value; + + [Benchmark] + public Exception UnionResultCreateFailure() => Result.Failure(FailureException).AsException(); + + [Benchmark] + public Exception OperationResultCreateFailure() => OperationResult.Result.Error(FailureException).Exception!; + + [Benchmark] + public int UnionResultReadSuccess() => _unionSuccess.AsValue(); + + [Benchmark] + public int OperationResultReadSuccess() => _operationSuccess.Value; + + [Benchmark] + public Exception UnionResultReadFailure() => _unionFailure.AsException(); + + [Benchmark] + public Exception OperationResultReadFailure() => _operationFailure.Exception!; +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..bec06e4 --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,25 @@ + + + + true + + + + + + + + + + + + + + + <_Parameter1>$(MSBuildProjectName).Tests + + + <_Parameter1>DynamicProxyGenAssembly2 + + + diff --git a/src/UnionResult/Result.cs b/src/UnionResult/Result.cs new file mode 100644 index 0000000..f8a05bd --- /dev/null +++ b/src/UnionResult/Result.cs @@ -0,0 +1,157 @@ +using System.Runtime.CompilerServices; + +namespace UnionResult; + +/// +/// Represents the outcome of an operation that returns a value of type +/// : either a success carrying that value, or a failure +/// carrying the that caused it. Use it in place of throwing for +/// failures that are an expected, ordinary outcome of the operation - the caller decides +/// how to react instead of having to catch an exception. +/// +/// The type of the value returned on success. +/// +/// +/// Result<int> Divide(int a, int b) => +/// b == 0 +/// ? Result<int>.Failure(new DivideByZeroException()) +/// : Result<int>.Success(a / b); +/// +/// var result = Divide(10, 2); +/// var message = result.IsSuccess +/// ? $"Result: {result.AsValue()}" +/// : $"Failed: {result.AsException().Message}"; +/// +/// +/// +/// Because is a C# union type, it also supports exhaustive +/// pattern matching: result switch { int value => ..., Exception ex => ... }. +/// +[Union] +public readonly struct Result : IUnion +{ + private readonly T _value; + private readonly Exception? _exception; + private readonly State _state; + + public Result(T value) + { + _value = value; + _state = State.Success; + } + + public Result(Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); + + _value = default!; + _exception = exception; + _state = State.Failure; + } + + private enum State : byte + { + Empty, + Success, + Failure, + } + + public object? Value => _state switch + { + State.Success => _value, + State.Failure => _exception, + _ => null, + }; + + public bool HasValue => _state != State.Empty; + + public bool IsSuccess => _state == State.Success; + + public bool IsFailure => _state == State.Failure; + + public static Result Success(T value) => new(value); + + public static Result Failure(Exception exception) => new(exception); + + public bool TryGetValue(out T value) + { + value = _value!; + return _state == State.Success; + } + + public bool TryGetValue(out Exception value) + { + value = _exception!; + return _state == State.Failure; + } + + public T AsValue() => ResultAccessor.OrThrow( + TryGetValue(out T value), value, "Result does not contain a value."); + + public Exception AsException() => ResultAccessor.OrThrow( + TryGetValue(out Exception value), value, "Result does not contain an exception."); +} + +/// +/// Represents the outcome of an operation that has no return value: either a plain +/// success, or a failure carrying the that caused it. Use it in +/// place of throwing for failures that are an expected, ordinary outcome of the +/// operation - the caller decides how to react instead of having to catch an exception. +/// +/// +/// +/// Result Save(User user) => +/// repository.TrySave(user, out var error) +/// ? Result.Success() +/// : Result.Failure(error); +/// +/// var result = Save(user); +/// if (result.IsFailure) +/// { +/// logger.LogError(result.AsException(), "Failed to save user"); +/// } +/// +/// +[Union] +public readonly struct Result : IUnion +{ + private readonly Exception? _exception; + + public Result(Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); + + _exception = exception; + } + + public object? Value => _exception; + + public bool HasValue => _exception is not null; + + public bool IsSuccess => _exception is null; + + public bool IsFailure => _exception is not null; + + public static Result Success() => default; + + public static Result Failure(Exception exception) => new(exception); + + public bool TryGetValue(out Exception value) + { + value = _exception!; + return _exception is not null; + } + + public Exception AsException() => ResultAccessor.OrThrow( + TryGetValue(out Exception value), value, "Result does not contain an exception."); +} + +/// +/// Shared "throw if not found" helper for the TryGetValue-based accessors above, so +/// AsValue/AsException don't each repeat their own throw expression. +/// +file static class ResultAccessor +{ + public static TValue OrThrow(bool found, TValue value, string message) => + found ? value : throw new InvalidOperationException(message); +} diff --git a/src/UnionResult/UnionResult.csproj b/src/UnionResult/UnionResult.csproj new file mode 100644 index 0000000..3ec16dc --- /dev/null +++ b/src/UnionResult/UnionResult.csproj @@ -0,0 +1,12 @@ + + + 1.0.0-preview.1 + icon.png + README.md + + + + + + + diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 0000000..4d515a2 --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,25 @@ + + + + false + true + $(NoWarn);CA1707 + + + + + + + + + + + + + + + + + + + diff --git a/tests/UnionResult.Tests/ResultOfTTests.cs b/tests/UnionResult.Tests/ResultOfTTests.cs new file mode 100644 index 0000000..7a6cb53 --- /dev/null +++ b/tests/UnionResult.Tests/ResultOfTTests.cs @@ -0,0 +1,216 @@ +using System.Diagnostics; + +namespace UnionResult.Tests; + +public class ResultOfTTests +{ + [Fact] + public void Success_ReturnsResultThatIsSuccessAndCarriesTheValue() + { + // Act + var result = Result.Success(42); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.IsFailure.Should().BeFalse(); + result.AsValue().Should().Be(42); + } + + [Fact] + public void Failure_ReturnsResultThatIsFailureAndCarriesTheException() + { + // Arrange + var exception = new InvalidOperationException("boom"); + + // Act + var result = Result.Failure(exception); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.IsFailure.Should().BeTrue(); + result.AsException().Should().BeSameAs(exception); + } + + [Fact] + public void Failure_WithNullException_ThrowsArgumentNullException() + { + // Act + var act = () => Result.Failure(null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void AsValue_OnFailure_ThrowsInvalidOperationException() + { + // Arrange + var result = Result.Failure(new InvalidOperationException("boom")); + + // Act + var act = result.AsValue; + + // Assert + act.Should().Throw() + .WithMessage("Result does not contain a value."); + } + + [Fact] + public void AsException_OnSuccess_ThrowsInvalidOperationException() + { + // Arrange + var result = Result.Success(42); + + // Act + var act = () => result.AsException(); + + // Assert + act.Should().Throw() + .WithMessage("Result does not contain an exception."); + } + + [Fact] + public void Success_WithReferenceTypeValue_RoundTripsTheSameInstance() + { + // Arrange + var payload = new List { "a", "b" }; + + // Act + var result = Result>.Success(payload); + + // Assert + result.AsValue().Should().BeSameAs(payload); + } + + [Fact] + public void Success_WithDefaultStructValue_IsReadableAsValue() + { + // Act + var result = Result.Success(0); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.AsValue().Should().Be(0); + } + + [Fact] + public void Success_WithNullReferenceValue_IsReadableAsValue() + { + // The tag-based discriminator (rather than a "Value is T value" pattern check) + // means a success built from a null reference round-trips correctly. + + // Act + var result = Result.Success(null); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.AsValue().Should().BeNull(); + } + + [Fact] + public void Default_OfValueTypeResult_IsNeitherSuccessNorFailure() + { + // An uninitialized (default) Result has tag 0, which the tag-based + // discriminator reports as neither success nor failure. + + // Act + var result = default(Result); + var asValue = result.AsValue; + var asException = result.AsException; + + // Assert + result.IsSuccess.Should().BeFalse(); + result.IsFailure.Should().BeFalse(); + asValue.Should().Throw(); + asException.Should().Throw(); + } + + [Fact] + public void Success_WhenTIsAnExceptionType_IsStillClassifiedAsSuccess() + { + // The tag-based discriminator (rather than "Value is Exception") means + // Result can safely wrap a T that is itself an Exception (or subtype). + + // Arrange + var payload = new ArgumentException("payload, not a real failure"); + + // Act + var result = Result.Success(payload); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.IsFailure.Should().BeFalse(); + result.AsValue().Should().BeSameAs(payload); + + var asException = result.AsException; + asException.Should().Throw(); + } + + [Fact] + public void Constructor_WithValue_IsEquivalentToSuccess() + { + // Act + var result = new Result(7); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.AsValue().Should().Be(7); + } + + [Fact] + public void Constructor_WithException_IsEquivalentToFailure() + { + // Arrange + var exception = new InvalidOperationException("boom"); + + // Act + var result = new Result(exception); + + // Assert + result.IsFailure.Should().BeTrue(); + result.AsException().Should().BeSameAs(exception); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(int.MaxValue)] + [InlineData(int.MinValue)] + public void Success_RoundTripsVariousIntValues(int value) + { + // Act + var result = Result.Success(value); + + // Assert + result.AsValue().Should().Be(value); + } + + [Fact] + public void Success_WithComplexObjectGraph_PreservesReferenceEquality() + { + // Arrange + var record = new Person("Ada", 36); + + // Act + var result = Result.Success(record); + + // Assert + result.AsValue().Should().Be(record); + result.AsValue().Should().BeSameAs(record); + } + + [Fact] + public void Failure_PreservesTheConcreteExceptionType() + { + // Arrange + var exception = new UnreachableException("Some error message"); + + // Act + var result = Result.Failure(exception); + + // Assert + result.AsException().Should().BeOfType(); + } + + private sealed record Person(string Name, int Age); +} diff --git a/tests/UnionResult.Tests/ResultTests.cs b/tests/UnionResult.Tests/ResultTests.cs new file mode 100644 index 0000000..148a127 --- /dev/null +++ b/tests/UnionResult.Tests/ResultTests.cs @@ -0,0 +1,112 @@ +using System.Diagnostics; + +namespace UnionResult.Tests; + +public class ResultTests +{ + [Fact] + public void Success_ReturnsResultThatIsSuccess() + { + // Act + var result = Result.Success(); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.IsFailure.Should().BeFalse(); + } + + [Fact] + public void Failure_ReturnsResultThatIsFailure() + { + // Arrange + var exception = new InvalidOperationException("boom"); + + // Act + var result = Result.Failure(exception); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public void AsException_OnFailure_ReturnsTheOriginalExceptionInstance() + { + // Arrange + var exception = new InvalidOperationException("boom"); + var result = Result.Failure(exception); + + // Act + var caught = result.AsException(); + + // Assert + caught.Should().BeSameAs(exception); + } + + [Fact] + public void AsException_OnSuccess_ThrowsInvalidOperationException() + { + // Arrange + var result = Result.Success(); + + // Act + var act = () => result.AsException(); + + // Assert + act.Should().Throw() + .WithMessage("Result does not contain an exception."); + } + + [Fact] + public void Default_BehavesAsSuccess() + { + // Act + var result = default(Result); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.IsFailure.Should().BeFalse(); + } + + [Fact] + public void Failure_WithNullException_ThrowsArgumentNullException() + { + // Act + var act = () => Result.Failure(null!); + + // Assert + act.Should().Throw(); + } + + [Theory] + [InlineData(typeof(ArgumentException))] + [InlineData(typeof(InvalidOperationException))] + [InlineData(typeof(NotSupportedException))] + public void Failure_PreservesTheConcreteExceptionType(Type exceptionType) + { + // Arrange + var exception = (Exception)Activator.CreateInstance(exceptionType)!; + + // Act + var result = Result.Failure(exception); + + // Assert + result.AsException().Should().BeOfType(exceptionType); + } + + [Fact] + public void Failure_PreservesInnerExceptionAndMessage() + { + // Arrange + var inner = new UnreachableException("inner failure"); + var exception = new InvalidOperationException("outer failure", inner); + + // Act + var result = Result.Failure(exception); + + // Assert + var caught = result.AsException(); + caught.Message.Should().Be("outer failure"); + caught.InnerException.Should().BeSameAs(inner); + } +} diff --git a/tests/UnionResult.Tests/ResultUsageScenariosTests.cs b/tests/UnionResult.Tests/ResultUsageScenariosTests.cs new file mode 100644 index 0000000..3f412ca --- /dev/null +++ b/tests/UnionResult.Tests/ResultUsageScenariosTests.cs @@ -0,0 +1,136 @@ +using NSubstitute.ExceptionExtensions; + +namespace UnionResult.Tests; + +/// +/// Exercises Result/Result<T> the way a consuming application would: a +/// dependency mocked with NSubstitute returns a Result instead of throwing, and +/// business logic branches on IsSuccess/IsFailure. +/// +public class ResultUsageScenariosTests +{ + public interface IUserRepository + { + Result GetById(int id); + + Result Save(User user); + } + + [Fact] + public void GetDisplayName_WhenRepositoryReturnsSuccess_ReturnsUserName() + { + // Arrange + var repository = Substitute.For(); + repository.GetById(1).Returns(Result.Success(new User(1, "Ada Lovelace"))); + var service = new UserService(repository); + + // Act + var name = service.GetDisplayName(1); + + // Assert + name.Should().Be("Ada Lovelace"); + repository.Received(1).GetById(1); + } + + [Fact] + public void GetDisplayName_WhenRepositoryReturnsFailure_ReturnsFallback() + { + // Arrange + var repository = Substitute.For(); + repository.GetById(99).Returns(Result.Failure(new KeyNotFoundException("user 99 not found"))); + var service = new UserService(repository); + + // Act + var name = service.GetDisplayName(99); + + // Assert + name.Should().Be("Unknown user"); + } + + [Fact] + public void TrySave_WhenRepositorySucceeds_ReturnsTrueAndNoError() + { + // Arrange + var repository = Substitute.For(); + repository.Save(Arg.Any()).Returns(Result.Success()); + var service = new UserService(repository); + + // Act + var saved = service.TrySave(new User(2, "Grace Hopper"), out var error); + + // Assert + saved.Should().BeTrue(); + error.Should().BeNull(); + } + + [Fact] + public void TrySave_WhenRepositoryFails_ReturnsFalseAndPropagatesException() + { + // Arrange + var repository = Substitute.For(); + var dbException = new InvalidOperationException("constraint violation"); + repository.Save(Arg.Any()).Returns(Result.Failure(dbException)); + var service = new UserService(repository); + + // Act + var saved = service.TrySave(new User(3, "Grace Hopper"), out var error); + + // Assert + saved.Should().BeFalse(); + error.Should().BeSameAs(dbException); + } + + [Fact] + public void Repository_ThrowingInsteadOfReturningFailure_IsNotSwallowedByTheService() + { + // Result formalizes *expected* failures; it must not mask a dependency that + // throws instead of returning Result.Failure. + + // Arrange + var repository = Substitute.For(); + repository.GetById(Arg.Any()).Throws(new TimeoutException("db timeout")); + var service = new UserService(repository); + + // Act + var act = () => service.GetDisplayName(1); + + // Assert + act.Should().Throw().WithMessage("db timeout"); + } + + [Fact] + public void MultipleConfiguredCalls_EachArgumentYieldsItsOwnConfiguredResult() + { + // Arrange + var repository = Substitute.For(); + repository.GetById(1).Returns(Result.Success(new User(1, "Ada Lovelace"))); + repository.GetById(2).Returns(Result.Failure(new KeyNotFoundException())); + var service = new UserService(repository); + + // Act + var nameForFirstUser = service.GetDisplayName(1); + var nameForSecondUser = service.GetDisplayName(2); + + // Assert + nameForFirstUser.Should().Be("Ada Lovelace"); + nameForSecondUser.Should().Be("Unknown user"); + } + + public sealed record User(int Id, string Name); + + public sealed class UserService(IUserRepository repository) + { + public string GetDisplayName(int id) + { + var result = repository.GetById(id); + return result.IsSuccess ? result.AsValue().Name : "Unknown user"; + } + + public bool TrySave(User user, out Exception? error) + { + var result = repository.Save(user); + error = result.IsFailure ? result.AsException() : null; + return result.IsSuccess; + } + } +} diff --git a/tests/UnionResult.Tests/UnionResult.Tests.csproj b/tests/UnionResult.Tests/UnionResult.Tests.csproj new file mode 100644 index 0000000..00e678f --- /dev/null +++ b/tests/UnionResult.Tests/UnionResult.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + +