From 1012decef074400d7d4518d03c08f65388357a92 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:51:19 -0500 Subject: [PATCH] chore: initial commit --- .editorconfig | 390 ++++++++++++++ .gitea/workflows/ci.yml | 23 + .gitea/workflows/release.yml | 122 +++++ .gitignore | 482 ++++++++++++++++++ .vscode/settings.json | 9 + Directory.Build.props | 12 + LICENSE.md | 19 + README.md | 284 +++++++++++ StevanFreeborn.Results.slnx | 8 + global.json | 9 + src/Directory.Build.props | 6 + src/Directory.Packages.props | 8 + src/StevanFreeborn.Results/.editorconfig | 6 + src/StevanFreeborn.Results/Error.cs | 40 ++ src/StevanFreeborn.Results/IError.cs | 20 + src/StevanFreeborn.Results/Result.cs | 264 ++++++++++ .../ResultAsyncExtensions.cs | 290 +++++++++++ .../StevanFreeborn.Results.csproj | 29 ++ src/StevanFreeborn.Results/Unit.cs | 11 + tests/.editorconfig | 6 + tests/Directory.Packages.props | 8 + .../ErrorTests.cs | 57 +++ .../ResultAsyncExtensionsTests.cs | 174 +++++++ .../ResultTTests.cs | 183 +++++++ .../ResultTests.cs | 146 ++++++ .../StevanFreeborn.Results.Tests.csproj | 13 + 26 files changed, 2619 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 Directory.Build.props create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 StevanFreeborn.Results.slnx create mode 100644 global.json create mode 100644 src/Directory.Build.props create mode 100644 src/Directory.Packages.props create mode 100644 src/StevanFreeborn.Results/.editorconfig create mode 100644 src/StevanFreeborn.Results/Error.cs create mode 100644 src/StevanFreeborn.Results/IError.cs create mode 100644 src/StevanFreeborn.Results/Result.cs create mode 100644 src/StevanFreeborn.Results/ResultAsyncExtensions.cs create mode 100644 src/StevanFreeborn.Results/StevanFreeborn.Results.csproj create mode 100644 src/StevanFreeborn.Results/Unit.cs create mode 100644 tests/.editorconfig create mode 100644 tests/Directory.Packages.props create mode 100644 tests/StevanFreeborn.Results.Tests/ErrorTests.cs create mode 100644 tests/StevanFreeborn.Results.Tests/ResultAsyncExtensionsTests.cs create mode 100644 tests/StevanFreeborn.Results.Tests/ResultTTests.cs create mode 100644 tests/StevanFreeborn.Results.Tests/ResultTests.cs create mode 100644 tests/StevanFreeborn.Results.Tests/StevanFreeborn.Results.Tests.csproj diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c265c6f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,390 @@ +root = true + +# All files +[*] +indent_style = space +indent_size = 2 + +# Xml files +[*.xml] +indent_size = 2 + +# Xml project files +[*.{csproj,fsproj,vbproj,proj,slnx}] +indent_size = 2 + +# Xml config files +[*.{props,targets,config,nuspec}] +indent_size = 2 + +[*.json] +indent_size = 2 + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 2 +tab_width = 2 + +# New line preferences +insert_final_newline = false + +#### .NET Coding Conventions #### +[*.{cs,vb}] + +# Organize usings +dotnet_separate_import_directive_groups = true +dotnet_sort_system_directives_first = true +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_property = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent + +# Expression-level preferences +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_namespace_match_folder = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = false:silent +dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion + +# Field preferences +dotnet_style_readonly_field = true:warning + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:suggestion + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +#### C# Coding Conventions #### +[*.cs] + +# var preferences +csharp_style_var_elsewhere = true:suggestion +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_extended_property_pattern = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_anonymous_function = true:suggestion +csharp_prefer_static_local_function = true:warning +csharp_preferred_modifier_order = public,private,protected,internal,file,const,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion + +# Code-block preferences +csharp_prefer_braces = true:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_namespace_declarations = file_scoped:suggestion +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_top_level_statements = true:silent + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### +[*.{cs,vb}] + +# Naming rules + +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion +dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces +dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase + +dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion +dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters +dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase + +dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods +dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties +dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.events_should_be_pascalcase.symbols = events +dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables +dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase + +dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants +dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase + +dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion +dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters +dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase + +dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields +dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion +dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields +dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase + +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase + +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums +dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase + +# Symbol specifications + +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interfaces.required_modifiers = + +dotnet_naming_symbols.enums.applicable_kinds = enum +dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.enums.required_modifiers = + +dotnet_naming_symbols.events.applicable_kinds = event +dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.events.required_modifiers = + +dotnet_naming_symbols.methods.applicable_kinds = method +dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.methods.required_modifiers = + +dotnet_naming_symbols.properties.applicable_kinds = property +dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.properties.required_modifiers = + +dotnet_naming_symbols.public_fields.applicable_kinds = field +dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_fields.required_modifiers = + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_fields.required_modifiers = + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_fields.required_modifiers = static + +dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum +dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types_and_namespaces.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 = + +dotnet_naming_symbols.type_parameters.applicable_kinds = namespace +dotnet_naming_symbols.type_parameters.applicable_accessibilities = * +dotnet_naming_symbols.type_parameters.required_modifiers = + +dotnet_naming_symbols.private_constant_fields.applicable_kinds = field +dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_constant_fields.required_modifiers = const + +dotnet_naming_symbols.local_variables.applicable_kinds = local +dotnet_naming_symbols.local_variables.applicable_accessibilities = local +dotnet_naming_symbols.local_variables.required_modifiers = + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.applicable_accessibilities = local +dotnet_naming_symbols.local_constants.required_modifiers = const + +dotnet_naming_symbols.parameters.applicable_kinds = parameter +dotnet_naming_symbols.parameters.applicable_accessibilities = * +dotnet_naming_symbols.parameters.required_modifiers = + +dotnet_naming_symbols.public_constant_fields.applicable_kinds = field +dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_constant_fields.required_modifiers = const + +dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function +dotnet_naming_symbols.local_functions.applicable_accessibilities = * +dotnet_naming_symbols.local_functions.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascalcase.required_prefix = +dotnet_naming_style.pascalcase.required_suffix = +dotnet_naming_style.pascalcase.word_separator = +dotnet_naming_style.pascalcase.capitalization = pascal_case + +dotnet_naming_style.ipascalcase.required_prefix = I +dotnet_naming_style.ipascalcase.required_suffix = +dotnet_naming_style.ipascalcase.word_separator = +dotnet_naming_style.ipascalcase.capitalization = pascal_case + +dotnet_naming_style.tpascalcase.required_prefix = T +dotnet_naming_style.tpascalcase.required_suffix = +dotnet_naming_style.tpascalcase.word_separator = +dotnet_naming_style.tpascalcase.capitalization = pascal_case + +dotnet_naming_style._camelcase.required_prefix = _ +dotnet_naming_style._camelcase.required_suffix = +dotnet_naming_style._camelcase.word_separator = +dotnet_naming_style._camelcase.capitalization = camel_case + +dotnet_naming_style.camelcase.required_prefix = +dotnet_naming_style.camelcase.required_suffix = +dotnet_naming_style.camelcase.word_separator = +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_style.s_camelcase.required_prefix = s_ +dotnet_naming_style.s_camelcase.required_suffix = +dotnet_naming_style.s_camelcase.word_separator = +dotnet_naming_style.s_camelcase.capitalization = camel_case + diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..a6b2c1a --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI +on: + pull_request: + branches: [main] +jobs: + ci: + name: Build, Format, and Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore + - name: Check formatting + run: dotnet format --verify-no-changes + - name: Run tests + run: dotnet test --no-build --verbosity normal diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..f3f7f5d --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,122 @@ +name: Release +on: + push: + branches: [main] +jobs: + release: + name: Version, Build, and Release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Determine version + id: version + run: | + $commits = git log main..HEAD --format="%s" + $commitList = $commits -split "`n" + $major = 0 + $minor = 0 + $patch = 0 + + foreach ($message in $commitList) { + if ($message -match " BREAKING CHANGE|!:") { + $major = 1 + } elseif ($message -match "^feat(\(.*\))?:") { + $minor = 1 + } elseif ($message -match "^fix(\(.*\))?:") { + $patch = 1 + } + } + + if ($major -eq 1) { + $minor = 0 + $patch = 0 + } elseif ($minor -eq 1) { + $patch = 0 + } elseif ($patch -eq 0 -and $commitList.Count -gt 0 -and $commitList[0]) { + $patch = 1 + } + + $currentVersion = "0.0.0" + $tag = git describe --tags --abbrev=0 2>$null + + $hasExistingTag = $false + if ($tag) { + $hasExistingTag = $true + $currentVersion = $tag -replace "^v", "" + } + + $parts = $currentVersion.Split(".") + $majorCurrent = [int]$parts[0] + $minorCurrent = [int]$parts[1] + $patchCurrent = [int]$parts[2] + + $majorNew = $majorCurrent + $major + $minorNew = if ($major -eq 1) { 0 } else { $minorCurrent + $minor } + $patchNew = if ($major -eq 1 -or $minor -eq 1) { 0 } else { $patchCurrent + $patch } + + if ($majorNew -eq $majorCurrent -and $minorNew -eq $minorCurrent -and $patchNew -eq $patchCurrent) { + if (-not $hasExistingTag) { + $version = "0.0.0" + echo "VERSION=$version" >> $env:GITHUB_OUTPUT + Write-Host "First release: publishing version 0.0.0" + } else { + Write-Host "No version bump needed, skipping release" + exit 0 + } + } else { + $version = "$majorNew.$minorNew.$patchNew" + echo "VERSION=$version" >> $env:GITHUB_OUTPUT + } + shell: pwsh + - name: Update version in csproj + if: steps.version.outputs.VERSION + run: | + $version = "${{ steps.version.outputs.VERSION }}" + $csprojPath = "src/StevanFreeborn.Results/StevanFreeborn.Results.csproj" + $content = Get-Content $csprojPath -Raw + + if ($content -match ".*?") { + $content = $content -replace ".*?", "$version" + } elseif ($content -match "") { + $content = $content -replace "", "`n $version" + } + + Set-Content -Path $csprojPath -Value $content + - name: Create commit + if: steps.version.outputs.VERSION + run: | + git config user.name "gitea-actions[bot]" + git config user.email "gitea-actions[bot]@users.noreply.gitea.io" + git add -A + git commit -m "Bump version to ${{ steps.version.outputs.VERSION }}" || echo "No changes to commit" + - name: Create tag + if: steps.version.outputs.VERSION + run: | + git tag "v${{ steps.version.outputs.VERSION }}" + - name: Push changes and tags + if: steps.version.outputs.VERSION + run: | + git push origin main + git push origin v${{ steps.version.outputs.VERSION }} + - name: Restore dependencies + if: steps.version.outputs.VERSION + run: dotnet restore + - name: Build + if: steps.version.outputs.VERSION + run: dotnet build --configuration Release --no-restore + - name: Run tests + if: steps.version.outputs.VERSION + run: dotnet test --configuration Release --no-build --verbosity normal + - name: Pack + if: steps.version.outputs.VERSION + run: dotnet pack --configuration Release --no-build --output ./artifacts /p:PackageVersion=${{ steps.version.outputs.VERSION }} + - name: Push to NuGet + if: steps.version.outputs.VERSION + run: dotnet nuget push ./artifacts/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0808c4a --- /dev/null +++ b/.gitignore @@ -0,0 +1,482 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# 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 +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# 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/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..03157de --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "cSpell.words": [ + "accessibilities", + "ipascalcase", + "pascalcase", + "slnx", + "tpascalcase" + ] +} \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..1ade24a --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,12 @@ + + + enable + enable + latest + latest + All + true + true + true + + diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..307cc61 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,19 @@ +# Copyright (c) 2026 Stevan Freeborn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3151208 --- /dev/null +++ b/README.md @@ -0,0 +1,284 @@ +# StevanFreeborn.Results + +[![NuGet](https://img.shields.io/nuget/v/StevanFreeborn.Results.svg)](https://www.nuget.org/packages/StevanFreeborn.Results) +[![NuGet](https://img.shields.io/nuget/dt/StevanFreeborn.Results.svg)](https://www.nuget.org/packages/StevanFreeborn.Results) +[![Build](https://github.com/StevanFreeborn/stevanfreeborn.results/actions/workflows/ci.yaml/badge.svg)](https://github.com/StevanFreeborn/stevanfreeborn.results/actions/workflows/ci.yaml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +A minimalistic, AOT-compatible Result type library with support for custom error types, for railway-oriented programming and functional error handling in .NET. + +## Features + +- **Railway-Oriented Programming**: Chain operations that can fail without nested try-catch blocks +- **Custom Error Types**: Define your own error types that implement `IError` +- **AOT Compatible**: Works with Native AOT and trimming +- **Nullable Reference Types**: Full support for nullable reference types +- **No External Dependencies**: Lightweight with zero dependencies +- **Comprehensive XML Documentation**: Full IntelliSense support + +## Installation + +Install via NuGet: + +```bash +dotnet add package StevanFreeborn.Results +``` + +## Quick Start + +```csharp +using StevanFreeborn.Results; + +// Create a successful result +Result ok = Result.Ok(default); + +// Create a failed result +Result fail = Result.Fail(new Error("NotFound", "User not found")); + +// Work with results that have values +Result divisionResult = Divide(10, 2); + +if (divisionResult.IsSuccess) +{ + Console.WriteLine(divisionResult.Value); // 5 +} + +// Railway-oriented programming with Bind +Result GetUser(int id) => ...; +Result GetOrder(int orderId) => ...; + +// Chain operations without exceptions +Result GetUserOrder(int userId, int orderId) +{ + return GetUser(userId) + .Bind(user => GetOrder(user.OrderId)) + .Map(order => order.WithUserDetails(user)); +} +``` + +## Custom Error Types + +One of the key features is the ability to define your own error types: + +```csharp +// Define a custom error +public record DomainError(string Code, string Message) : IError; + +// Use with Result +Result GetUser(int id) +{ + if (id <= 0) + { + return Result.Fail(new DomainError("InvalidId", "User ID must be positive")); + } + + // ... fetch user + return Result.Ok(user); +} + +// Chain with custom errors +Result result = GetUser(1) + .Bind(user => GetOrder(user.OrderId)) + .MapError(e => new DomainError(e.Code, $"Failed to get order: {e.Message}")); +``` + +## IError Interface + +The `IError` interface is the foundation for custom error types: + +```csharp +public interface IError +{ + string Code { get; } + string Message { get; } +} +``` + +Any type implementing `IError` can be used with `Result`. + +## Built-in Error Class + +The library includes a built-in `Error` class that implements `IError`: + +```csharp +// Create an error +var error = new Error("NotFound", "User not found"); + +// With metadata +var errorWithMetadata = new Error( + "ValidationFailed", "Invalid input", + new Dictionary { { "Field", "email" } } +); +``` + +## Unit Type + +The `Unit` struct represents a void-like type for use when no value is needed: + +```csharp +// For operations that only succeed or fail without a value +Result operation = DoSomething(); +``` + +## Result Types + +### `Result` + +The main Result type with generic type parameters for both value and error: + +```csharp +// Creation +Result ok = Result.Ok(42); +Result fail = Result.Fail(new Error("Invalid", "Invalid input")); + +// Check status +if (result.IsSuccess) { /* ... */ } +if (result.IsFailure) { /* ... */ } + +// Access value (throws on failure) +int value = result.Value; + +// Access error (throws on success) +Error error = result.Error; +``` + +### Result without value + +For operations that don't return a value, use `Unit`: + +```csharp +Result ok = Result.Ok(default); +Result fail = Result.Fail(new Error("Failed", "Something went wrong")); +``` + +## Functional Operations + +### Map + +Transforms the value if success, propagates the error if failure. + +```csharp +Result ok = Result.Ok(5); +Result mapped = ok.Map(x => x.ToString()); // Result.Ok("5") +``` + +### MapError + +Transforms the error if failure, propagates the value if success. + +```csharp +Result fail = Result.Fail(new Error("NotFound", "Not found")); +Result mapped = fail.MapError(e => new Error("Unexpected", e.Message)); +``` + +### Bind + +Chains another operation that returns a Result. + +```csharp +Result GetUser(int id) => ...; +Result GetOrder(int userId) => ...; + +Result GetUserOrder(int userId) +{ + return GetUser(userId) + .Bind(user => GetOrder(user.Id)); +} +``` + +### Match + +Executes different functions based on success or failure. + +```csharp +Result result = Divide(10, 2); + +// Get a value +string message = result.Match( + onSuccess: value => $"Result: {value}", + onFailure: error => $"Error: {error.Message}" +); + +// Execute actions +result.Match( + onSuccess: value => Console.WriteLine(value), + onFailure: error => Console.WriteLine(error.Message) +); +``` + +### Try + +Wraps a function that may throw an exception in a Result. + +```csharp +// Simple usage with default error handler +Result result = Result.Try(() => File.ReadAllText("file.txt")); + +// Custom error handler +Result result = Result.Try( + () => File.ReadAllText("file.txt"), + ex => new Error("ReadError", ex.Message) +); +``` + +## Async Operations + +The library provides async extension methods for all functional operations. + +```csharp +// Async Map +Result user = await GetUserAsync(id); +Result userName = await user.MapAsync(u => GetNameAsync(u)); + +// Async Bind +Result order = await GetUserAsync(id) + .BindAsync(user => GetOrderAsync(user.OrderId)); + +// Async Match +string result = await result.MatchAsync( + onSuccess: async value => await ProcessAsync(value), + onFailure: async error => await HandleErrorAsync(error) +); + +// Async Try +Result result = await ResultAsyncExtensions.TryAsync( + () => HttpClient.GetStringAsync("https://api.example.com") +); +``` + +## Extension Methods + +### Result Methods + +| Method | Description | +|--------------------------------------------------|------------------------------------| +| `Map(Func)` | Transforms the value | +| `Map(Func, Func)` | Transforms the value or error | +| `MapError(Func)` | Transforms the error | +| `Bind(Func>)` | Chains a new Result | +| `Match(Func, Func)` | Pattern matching returning a value | +| `Match(Action, Action)` | Pattern matching executing actions | +| `Try(Func)` | Wraps a function that may throw | + +### Async Extensions + +| Method | Description | +|-----------------|---------------------------| +| `MapAsync` | Async version of Map | +| `MapErrorAsync` | Async version of MapError | +| `BindAsync` | Async version of Bind | +| `MatchAsync` | Async version of Match | +| `TryAsync` | Async version of Try | + +## Requirements + +- .NET Standard 2.1 +- .NET 10.0+ + +## License + +MIT License - see [LICENSE.md](LICENSE.md) for details. + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/StevanFreeborn.Results.slnx b/StevanFreeborn.Results.slnx new file mode 100644 index 0000000..b8a3a29 --- /dev/null +++ b/StevanFreeborn.Results.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/global.json b/global.json new file mode 100644 index 0000000..ebc8585 --- /dev/null +++ b/global.json @@ -0,0 +1,9 @@ +{ + "sdk": { + "version": "10.0.201", + "rollForward": "latestPatch" + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} \ No newline at end of file diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..6d18a9d --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props new file mode 100644 index 0000000..727d96f --- /dev/null +++ b/src/Directory.Packages.props @@ -0,0 +1,8 @@ + + + true + + + + + diff --git a/src/StevanFreeborn.Results/.editorconfig b/src/StevanFreeborn.Results/.editorconfig new file mode 100644 index 0000000..f5eafdf --- /dev/null +++ b/src/StevanFreeborn.Results/.editorconfig @@ -0,0 +1,6 @@ +[*.cs] + +dotnet_diagnostic.CA1000.severity = none +dotnet_diagnostic.CA1031.severity = none +dotnet_diagnostic.CA1510.severity = none +dotnet_diagnostic.CA2225.severity = none \ No newline at end of file diff --git a/src/StevanFreeborn.Results/Error.cs b/src/StevanFreeborn.Results/Error.cs new file mode 100644 index 0000000..e47b974 --- /dev/null +++ b/src/StevanFreeborn.Results/Error.cs @@ -0,0 +1,40 @@ +namespace StevanFreeborn.Results +{ + + /// + /// Represents an error with a code, message, and optional metadata. + /// +#pragma warning disable CA1716 // Identifiers should not match keywords + public sealed record Error : IError +#pragma warning restore CA1716 // Identifiers should not match keywords + { + /// + /// Gets the error code. + /// + public string Code { get; } + + /// + /// Gets the error message. + /// + public string Message { get; } + + /// + /// Gets optional metadata associated with the error. + /// + public IReadOnlyDictionary? Metadata { get; } + + /// + /// Initializes a new instance of the class with the specified code, message, and optional metadata. + /// + /// The error code. + /// The error message. + /// Optional metadata associated with the error. + public Error(string code, string message, IReadOnlyDictionary? metadata = null) + { + Code = code; + Message = message; + Metadata = metadata; + } + } + +} \ No newline at end of file diff --git a/src/StevanFreeborn.Results/IError.cs b/src/StevanFreeborn.Results/IError.cs new file mode 100644 index 0000000..efd9e15 --- /dev/null +++ b/src/StevanFreeborn.Results/IError.cs @@ -0,0 +1,20 @@ +namespace StevanFreeborn.Results +{ + + /// + /// Represents an error with a code and message. + /// + public interface IError + { + /// + /// Gets the error code. + /// + string Code { get; } + + /// + /// Gets the error message. + /// + string Message { get; } + } + +} \ No newline at end of file diff --git a/src/StevanFreeborn.Results/Result.cs b/src/StevanFreeborn.Results/Result.cs new file mode 100644 index 0000000..bfd3688 --- /dev/null +++ b/src/StevanFreeborn.Results/Result.cs @@ -0,0 +1,264 @@ +namespace StevanFreeborn.Results +{ + + /// + /// Represents a result with a value and error type, used for operations that either succeed with a value or fail with an error. + /// + /// The type of the value. + /// The type of the error. + public sealed class Result where TError : IError + { + /// + /// Gets a value indicating whether the result is successful. + /// + public bool IsSuccess { get; } + + /// + /// Gets a value indicating whether the result is a failure. + /// + public bool IsFailure => !IsSuccess; + + /// + /// Gets the value of the result. + /// + /// Thrown when accessing Value on a failed result. + public T Value => IsSuccess + ? _value + : throw new InvalidOperationException($"Cannot access Value on a failed result. Error: [{Error.Code}] {Error.Message}"); + + /// + /// Gets the error associated with the result. + /// + /// Thrown when accessing Error on a successful result. + public TError Error => _error ?? throw new InvalidOperationException("Cannot access Error on a successful result."); + + private readonly T _value; + private readonly TError? _error; + + internal Result(bool isSuccess, T value, TError? error) + { + IsSuccess = isSuccess; + _value = value; + _error = error; + } + + /// + /// Creates a successful result with the specified value. + /// + /// The value. + /// A successful . + public static Result Ok(T value) + { + return new Result(true, value, default); + } + + /// + /// Creates a failed result with the specified error. + /// + /// The error. + /// A failed . + public static Result Fail(TError error) + { + return new Result(false, default!, error); + } + + /// + /// Implicitly converts a value to a successful . + /// + /// The value to convert. + public static implicit operator Result(T value) + { + return Ok(value); + } + + /// + /// Implicitly converts an to a failed . + /// + /// The error to convert. + public static implicit operator Result(TError error) + { + return Fail(error); + } + + /// + /// Maps the value to a new type if the result is successful. + /// + /// The new type. + /// The function to map the value. + /// A new with the mapped value if successful, otherwise the current error. + /// Thrown when mapper is null. + public Result Map(Func mapper) + { + if (mapper is null) + { + throw new ArgumentNullException(nameof(mapper)); + } + + return IsSuccess ? Result.Ok(mapper(Value)) : Result.Fail(Error); + } + + /// + /// Maps the error to a new error if the result is a failure. + /// + /// The function to map the error. + /// A new with the mapped error if failed, otherwise the current result. + /// Thrown when mapper is null. + public Result MapError(Func mapper) where TNewError : IError + { + if (mapper is null) + { + throw new ArgumentNullException(nameof(mapper)); + } + + return IsFailure ? Result.Fail(mapper(Error)) : Result.Ok(Value); + } + + /// + /// Binds to a new result if the current result is successful. + /// + /// The new result type. + /// The function to bind to on success. + /// The result of the binder function if successful, otherwise the current error. + /// Thrown when binder is null. + public Result Bind(Func> binder) + { + if (binder is null) + { + throw new ArgumentNullException(nameof(binder)); + } + + return IsSuccess ? binder(Value) : Result.Fail(Error); + } + + /// + /// Matches the result and returns a value based on success or failure. + /// + /// The type of the result. + /// The function to execute on success. + /// The function to execute on failure. + /// The result of the appropriate function. + /// Thrown when onSuccess or onFailure is null. + public TResult Match(Func onSuccess, Func onFailure) + { + if (onSuccess is null) + { + throw new ArgumentNullException(nameof(onSuccess)); + } + + if (onFailure is null) + { + throw new ArgumentNullException(nameof(onFailure)); + } + + return IsSuccess ? onSuccess(Value) : onFailure(Error); + } + + /// + /// Matches the result and executes the appropriate action. + /// + /// The action to execute on success. + /// The action to execute on failure. + /// Thrown when onSuccess or onFailure is null. + public void Match(Action onSuccess, Action onFailure) + { + if (onSuccess is null) + { + throw new ArgumentNullException(nameof(onSuccess)); + } + + if (onFailure is null) + { + throw new ArgumentNullException(nameof(onFailure)); + } + + if (IsSuccess) + { + onSuccess(Value); + } + else + { + onFailure(Error); + } + } + + /// + /// Executes the specified action if the result is successful, and returns the current result. + /// + /// The action to execute on success. + /// The current result. + /// Thrown when action is null. + public Result Map(Action action) + { + if (action is null) + { + throw new ArgumentNullException(nameof(action)); + } + + if (IsSuccess) + { + action(Value); + } + return this; + } + + /// + /// Executes the specified action if the result is successful, and returns the current result. + /// + /// The action to execute on success. + /// The current result. + /// Thrown when action is null. + public Result Map(Action action) + { + if (action is null) + { + throw new ArgumentNullException(nameof(action)); + } + + if (IsSuccess) + { + action(); + } + return this; + } + + /// + /// Executes the specified function and wraps the result in a . + /// + /// The function to execute. + /// A successful result with the return value if no exception is thrown; otherwise, a failed result. + public static Result Try(Func func) + { + return Try(func, ex => (TError)(IError)new Error("UnexpectedError", ex.Message)); + } + + /// + /// Executes the specified function and wraps the result in a using the specified error handler. + /// + /// The function to execute. + /// The function to convert exceptions to errors. + /// A successful result with the return value if no exception is thrown; otherwise, a failed result. + /// Thrown when func or errorHandler is null. + public static Result Try(Func func, Func errorHandler) + { + if (func is null) + { + throw new ArgumentNullException(nameof(func)); + } + + if (errorHandler is null) + { + throw new ArgumentNullException(nameof(errorHandler)); + } + + try + { + return Ok(func()); + } + catch (Exception ex) + { + return Fail(errorHandler(ex)); + } + } + } + +} \ No newline at end of file diff --git a/src/StevanFreeborn.Results/ResultAsyncExtensions.cs b/src/StevanFreeborn.Results/ResultAsyncExtensions.cs new file mode 100644 index 0000000..6bc32b5 --- /dev/null +++ b/src/StevanFreeborn.Results/ResultAsyncExtensions.cs @@ -0,0 +1,290 @@ +namespace StevanFreeborn.Results +{ + + /// + /// Provides async extension methods for and . + /// + public static class ResultAsyncExtensions + { + /// + /// Maps the value to a new type asynchronously if the result is successful. + /// + /// The type of the value. + /// The new type. + /// The type of the error. + /// The result. + /// The async function to map the value. + /// A task containing a new with the mapped value if successful, otherwise the current error. + /// Thrown when result or mapper is null. + public static async Task> MapAsync(this Result result, Func> mapper) + where TError : IError + { + if (result is null) + { + throw new ArgumentNullException(nameof(result)); + } + + if (mapper is null) + { + throw new ArgumentNullException(nameof(mapper)); + } + + return result.IsSuccess ? await mapper(result.Value).ConfigureAwait(false) : result.Error; + } + + /// + /// Maps the error to a new error asynchronously if the result is a failure. + /// + /// The type of the value. + /// The type of the error. + /// The new error type. + /// The result. + /// The async function to map the error. + /// A task containing a new with the mapped error if failed, otherwise the current result. + /// Thrown when result or mapper is null. + public static async Task> MapErrorAsync(this Result result, Func> mapper) + where TError : IError + where TNewError : IError + { + if (result is null) + { + throw new ArgumentNullException(nameof(result)); + } + + if (mapper is null) + { + throw new ArgumentNullException(nameof(mapper)); + } + + return result.IsFailure ? await mapper(result.Error).ConfigureAwait(false) : Result.Ok(result.Value); + } + + /// + /// Executes the specified async action if the result is successful, and returns the current result. + /// + /// The type of the error. + /// The result. + /// The async action to execute on success. + /// A task containing the result. + /// Thrown when result or onSuccess is null. + public static async Task> MapAsync(this Result result, Func onSuccess) + where TError : IError + { + if (result is null) + { + throw new ArgumentNullException(nameof(result)); + } + + if (onSuccess is null) + { + throw new ArgumentNullException(nameof(onSuccess)); + } + + if (result.IsSuccess) + { + await onSuccess(result.Value).ConfigureAwait(false); + } + + return result; + } + + /// + /// Binds to a new async result if the current result is successful. + /// + /// The type of the error. + /// The result. + /// The async function to execute and bind to on success. + /// A task containing the result of the binder function if successful, otherwise the current result. + /// Thrown when result or onSuccess is null. + public static async Task> BindAsync(this Result result, Func>> onSuccess) + where TError : IError + { + if (result is null) + { + throw new ArgumentNullException(nameof(result)); + } + + if (onSuccess is null) + { + throw new ArgumentNullException(nameof(onSuccess)); + } + + return result.IsSuccess ? await onSuccess(result.Value).ConfigureAwait(false) : result; + } + + /// + /// Matches the result asynchronously and returns a value based on success or failure. + /// + /// The type of the result. + /// The type of the error. + /// The result. + /// The async function to execute on success. + /// The async function to execute on failure. + /// A task containing the result of the appropriate function. + /// Thrown when result, onSuccess, or onFailure is null. + public static async Task MatchAsync(this Result result, Func> onSuccess, Func> onFailure) + where TError : IError + { + if (result is null) + { + throw new ArgumentNullException(nameof(result)); + } + + if (onSuccess is null) + { + throw new ArgumentNullException(nameof(onSuccess)); + } + + if (onFailure is null) + { + throw new ArgumentNullException(nameof(onFailure)); + } + + return result.IsSuccess + ? await onSuccess(result.Value).ConfigureAwait(false) + : await onFailure(result.Error).ConfigureAwait(false); + } + + /// + /// Executes the specified async action and wraps the result in a . + /// + /// The async action to execute. + /// A task containing a successful result if no exception is thrown; otherwise, a failed result. + public static Task> TryAsync(Func action) + { + return TryAsync(action, ex => new Error("UnexpectedError", ex.Message)); + } + + /// + /// Executes the specified async action and wraps the result in a using the specified error handler. + /// + /// The async action to execute. + /// The function to convert exceptions to errors. + /// A task containing a successful result if no exception is thrown; otherwise, a failed result. + /// Thrown when action or errorHandler is null. + public static async Task> TryAsync(Func action, Func errorHandler) + { + if (action is null) + { + throw new ArgumentNullException(nameof(action)); + } + + if (errorHandler is null) + { + throw new ArgumentNullException(nameof(errorHandler)); + } + + try + { + await action().ConfigureAwait(false); + return Result.Ok(default); + } + catch (Exception ex) + { + return Result.Fail(errorHandler(ex)); + } + } + + /// + /// Binds to a new async result if the current result is successful. + /// + /// The type of the value. + /// The new result type. + /// The type of the error. + /// The result. + /// The async function to bind to on success. + /// A task containing the result of the binder function if successful, otherwise the current error. + /// Thrown when result or binder is null. + public static async Task> BindAsync(this Result result, Func>> binder) + where TError : IError + { + if (result is null) + { + throw new ArgumentNullException(nameof(result)); + } + + if (binder is null) + { + throw new ArgumentNullException(nameof(binder)); + } + + return result.IsSuccess ? await binder(result.Value).ConfigureAwait(false) : Result.Fail(result.Error); + } + + /// + /// Matches the result asynchronously and returns a value based on success or failure. + /// + /// The type of the value. + /// The type of the result. + /// The type of the error. + /// The result. + /// The async function to execute on success. + /// The async function to execute on failure. + /// A task containing the result of the appropriate function. + /// Thrown when result, onSuccess, or onFailure is null. + public static async Task MatchAsync(this Result result, Func> onSuccess, Func> onFailure) + where TError : IError + { + if (result is null) + { + throw new ArgumentNullException(nameof(result)); + } + + if (onSuccess is null) + { + throw new ArgumentNullException(nameof(onSuccess)); + } + + if (onFailure is null) + { + throw new ArgumentNullException(nameof(onFailure)); + } + + return result.IsSuccess + ? await onSuccess(result.Value).ConfigureAwait(false) + : await onFailure(result.Error).ConfigureAwait(false); + } + + /// + /// Executes the specified async function and wraps the result in a . + /// + /// The type of the value. + /// The async function to execute. + /// A task containing a successful result with the return value if no exception is thrown; otherwise, a failed result. + public static Task> TryAsync(Func> func) + { + return TryAsync(func, ex => new Error("UnexpectedError", ex.Message)); + } + + /// + /// Executes the specified async function and wraps the result in a using the specified error handler. + /// + /// The type of the value. + /// The async function to execute. + /// The function to convert exceptions to errors. + /// A task containing a successful result with the return value if no exception is thrown; otherwise, a failed result. + /// Thrown when func or errorHandler is null. + public static async Task> TryAsync(Func> func, Func errorHandler) + { + if (func is null) + { + throw new ArgumentNullException(nameof(func)); + } + + if (errorHandler is null) + { + throw new ArgumentNullException(nameof(errorHandler)); + } + + try + { + return Result.Ok(await func().ConfigureAwait(false)); + } + catch (Exception ex) + { + return Result.Fail(errorHandler(ex)); + } + } + } + +} \ No newline at end of file diff --git a/src/StevanFreeborn.Results/StevanFreeborn.Results.csproj b/src/StevanFreeborn.Results/StevanFreeborn.Results.csproj new file mode 100644 index 0000000..ba62683 --- /dev/null +++ b/src/StevanFreeborn.Results/StevanFreeborn.Results.csproj @@ -0,0 +1,29 @@ + + + netstandard2.1;net10.0 + true + true + true + + + StevanFreeborn.Results + 0.0.0 + StevanFreeborn + A minimalistic, AOT-compatible Result type library for railway-oriented programming and functional error handling. + result;railway;functional;error-handling;discriminated-union + MIT + https://github.com/StevanFreeborn/stevanfreeborn.results + git + true + true + true + snupkg + + + + + + + + + diff --git a/src/StevanFreeborn.Results/Unit.cs b/src/StevanFreeborn.Results/Unit.cs new file mode 100644 index 0000000..72bce74 --- /dev/null +++ b/src/StevanFreeborn.Results/Unit.cs @@ -0,0 +1,11 @@ +namespace StevanFreeborn.Results +{ + + /// + /// Represents a void-like type for use with Result when no value is needed. + /// + public readonly struct Unit + { + } + +} \ No newline at end of file diff --git a/tests/.editorconfig b/tests/.editorconfig new file mode 100644 index 0000000..707fe12 --- /dev/null +++ b/tests/.editorconfig @@ -0,0 +1,6 @@ +[*.cs] + +dotnet_diagnostic.CA1515.severity = none +dotnet_diagnostic.CA2007.severity = none +dotnet_diagnostic.CA2201.severity = none +dotnet_diagnostic.CA1707.severity = none diff --git a/tests/Directory.Packages.props b/tests/Directory.Packages.props new file mode 100644 index 0000000..cf8b59b --- /dev/null +++ b/tests/Directory.Packages.props @@ -0,0 +1,8 @@ + + + true + + + + + diff --git a/tests/StevanFreeborn.Results.Tests/ErrorTests.cs b/tests/StevanFreeborn.Results.Tests/ErrorTests.cs new file mode 100644 index 0000000..f6c2368 --- /dev/null +++ b/tests/StevanFreeborn.Results.Tests/ErrorTests.cs @@ -0,0 +1,57 @@ +namespace StevanFreeborn.Results.Tests; + +public class ErrorTests +{ + [Test] + public async Task Constructor_WhenCalled_ItShouldSetCode() + { + var error = new Error("code", "message"); + await Assert.That(error.Code).IsEqualTo("code"); + } + + [Test] + public async Task Constructor_WhenCalled_ItShouldSetMessage() + { + var error = new Error("code", "message"); + await Assert.That(error.Message).IsEqualTo("message"); + } + + [Test] + public async Task Constructor_WhenCalledWithMetadata_ItShouldSetMetadata() + { + var metadata = new Dictionary { { "key", "value" } }; + var error = new Error("code", "message", metadata); + await Assert.That(error.Metadata!.Count).IsEqualTo(1); + await Assert.That(error.Metadata["key"]).IsEqualTo("value"); + } + + [Test] + public async Task Constructor_WhenCalledWithNullMetadata_ItShouldSetMetadataToNull() + { + var error = new Error("code", "message", null); + await Assert.That(error.Metadata!).IsNull(); + } + + [Test] + public async Task Equals_WhenErrorsHaveSameCodeAndMessage_ItShouldReturnTrue() + { + var error1 = new Error("code", "message"); + var error2 = new Error("code", "message"); + await Assert.That(error1).IsEqualTo(error2); + } + + [Test] + public async Task Equals_WhenErrorsHaveDifferentCode_ItShouldReturnFalse() + { + var error1 = new Error("code1", "message"); + var error2 = new Error("code2", "message"); + await Assert.That(error1 == error2).IsFalse(); + } + + [Test] + public async Task Error_WhenCreated_ItShouldImplementIError() + { + var error = new Error("code", "message"); + await Assert.That(error).IsAssignableTo(); + } +} diff --git a/tests/StevanFreeborn.Results.Tests/ResultAsyncExtensionsTests.cs b/tests/StevanFreeborn.Results.Tests/ResultAsyncExtensionsTests.cs new file mode 100644 index 0000000..6b821a3 --- /dev/null +++ b/tests/StevanFreeborn.Results.Tests/ResultAsyncExtensionsTests.cs @@ -0,0 +1,174 @@ +using System.Globalization; + +namespace StevanFreeborn.Results.Tests; + +public class ResultAsyncExtensionsTests +{ + [Test] + public async Task MapAsync_WhenResultIsSuccess_ItShouldInvokeAsyncAction() + { + var result = Result.Ok(default); + var invoked = false; + await result.MapAsync(_ => { invoked = true; return Task.CompletedTask; }); + await Assert.That(invoked).IsTrue(); + } + + [Test] + public async Task MapAsync_WhenResultIsFailure_ItShouldSkipAsyncAction() + { + var result = Result.Fail(new Error("code", "message")); + var invoked = false; + await result.MapAsync(_ => { invoked = true; return Task.CompletedTask; }); + await Assert.That(invoked).IsFalse(); + } + + [Test] + public async Task MapAsync_WhenCalled_ItShouldReturnOriginalResult() + { + var result = Result.Ok(default); + var mapped = await result.MapAsync(_ => Task.CompletedTask); + await Assert.That(mapped.IsSuccess).IsTrue(); + } + + [Test] + public async Task BindAsync_WhenResultIsSuccess_ItShouldInvokeBinderAndReturnResult() + { + var result = Result.Ok(default); + var bound = await result.BindAsync(_ => Task.FromResult(Result.Fail(new Error("new_error", "new message")))); + await Assert.That(bound.IsFailure).IsTrue(); + } + + [Test] + public async Task BindAsync_WhenResultIsFailure_ItShouldReturnOriginalResult() + { + var result = Result.Fail(new Error("code", "message")); + var bound = await result.BindAsync(_ => Task.FromResult(Result.Ok(default))); + await Assert.That(bound.IsFailure).IsTrue(); + await Assert.That(bound.Error.Code).IsEqualTo("code"); + } + + [Test] + public async Task MatchAsync_WhenResultIsSuccess_ItShouldReturnOnSuccessValue() + { + var result = Result.Ok(default); + var matched = await result.MatchAsync(_ => Task.FromResult("success"), _ => Task.FromResult("failure")); + await Assert.That(matched).IsEqualTo("success"); + } + + [Test] + public async Task MatchAsync_WhenResultIsFailure_ItShouldReturnOnFailureValue() + { + var result = Result.Fail(new Error("code", "message")); + var matched = await result.MatchAsync(_ => Task.FromResult("success"), e => Task.FromResult(e.Code)); + await Assert.That(matched).IsEqualTo("code"); + } + + [Test] + public async Task TryAsync_WhenActionSucceeds_ItShouldReturnOk() + { + var result = await ResultAsyncExtensions.TryAsync(() => Task.CompletedTask); + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task TryAsync_WhenActionThrowsException_ItShouldReturnFailWithUnexpectedError() + { + var result = await ResultAsyncExtensions.TryAsync(() => throw new InvalidOperationException("test error")); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error.Code).IsEqualTo("UnexpectedError"); + } + + [Test] + public async Task TryAsync_WhenActionThrowsException_ItShouldUseCustomError() + { + var result = await ResultAsyncExtensions.TryAsync( + () => throw new InvalidOperationException("test"), + ex => new Error("custom", ex.Message)); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error.Code).IsEqualTo("custom"); + } + + [Test] + public async Task MapAsync_WhenResultIsSuccess_ItShouldTransformValue() + { + var result = Result.Ok(5); + var mapped = await result.MapAsync(async n => (await Task.FromResult(n)).ToString(CultureInfo.InvariantCulture)); + await Assert.That(mapped.IsSuccess).IsTrue(); + await Assert.That(mapped.Value).IsEqualTo("5"); + } + + [Test] + public async Task MapAsync_WhenResultIsFailure_ItShouldPropagateError() + { + var error = new Error("code", "message"); + var result = Result.Fail(error); + var mapped = await result.MapAsync(async n => (await Task.FromResult(n)).ToString(CultureInfo.InvariantCulture)); + await Assert.That(mapped.IsFailure).IsTrue(); + await Assert.That(mapped.Error).IsEqualTo(error); + } + + [Test] + public async Task BindAsync_WhenResultIsSuccess_ItShouldChainToBinderResult() + { + var result = Result.Ok(5); + var bound = await result.BindAsync(async n => await Task.FromResult(Result.Ok(n.ToString(CultureInfo.InvariantCulture)))); + await Assert.That(bound.IsSuccess).IsTrue(); + await Assert.That(bound.Value).IsEqualTo("5"); + } + + [Test] + public async Task BindAsync_WhenResultIsFailure_ItShouldPropagateError() + { + var error = new Error("code", "message"); + var result = Result.Fail(error); + var bound = await result.BindAsync(async n => await Task.FromResult(Result.Ok(n.ToString(CultureInfo.InvariantCulture)))); + await Assert.That(bound.IsFailure).IsTrue(); + await Assert.That(bound.Error).IsEqualTo(error); + } + + [Test] + public async Task MatchAsyncGeneric_WhenResultIsSuccess_ItShouldReturnOnSuccessValue() + { + var result = Result.Ok("test"); + var matched = await result.MatchAsync( + v => Task.FromResult(v.Length), + _ => Task.FromResult(0)); + await Assert.That(matched).IsEqualTo(4); + } + + [Test] + public async Task MatchAsyncGeneric_WhenResultIsFailure_ItShouldReturnOnFailureValue() + { + var result = Result.Fail(new Error("code", "message")); + var matched = await result.MatchAsync( + v => Task.FromResult(v.Length), + _ => Task.FromResult(-1)); + await Assert.That(matched).IsEqualTo(-1); + } + + [Test] + public async Task TryAsync_WhenFuncSucceeds_ItShouldReturnOkWithValue() + { + var result = await ResultAsyncExtensions.TryAsync(async () => await Task.FromResult(42)); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(42); + } + + [Test] + public async Task TryAsync_WhenFuncThrowsException_ItShouldReturnFailWithUnexpectedError() + { + var result = await ResultAsyncExtensions.TryAsync(() => throw new InvalidOperationException("test error")); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error.Code).IsEqualTo("UnexpectedError"); + } + + [Test] + public async Task TryAsync_WhenFuncThrowsException_ItShouldUseCustomError() + { + var result = await ResultAsyncExtensions.TryAsync( + () => throw new InvalidOperationException("test"), + ex => new Error("custom", ex.Message)); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error.Code).IsEqualTo("custom"); + } +} diff --git a/tests/StevanFreeborn.Results.Tests/ResultTTests.cs b/tests/StevanFreeborn.Results.Tests/ResultTTests.cs new file mode 100644 index 0000000..35e846f --- /dev/null +++ b/tests/StevanFreeborn.Results.Tests/ResultTTests.cs @@ -0,0 +1,183 @@ +using System.Globalization; + +namespace StevanFreeborn.Results.Tests; + +public class ResultTTests +{ + [Test] + public async Task Ok_WhenCalledWithValue_ItShouldReturnSuccessResult() + { + var result = Result.Ok("test"); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.IsFailure).IsFalse(); + await Assert.That(result.Value).IsEqualTo("test"); + } + + [Test] + public async Task Fail_WhenCalled_ItShouldReturnFailureResult() + { + var error = new Error("code", "message"); + var result = Result.Fail(error); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error).IsEqualTo(error); + } + + [Test] + public async Task Value_WhenResultIsFailure_ItShouldThrowInvalidOperationException() + { + var result = Result.Fail(new Error("code", "message")); + + Assert.Throws(() => _ = result.Value); + } + + [Test] + public async Task Error_WhenResultIsSuccess_ItShouldThrowInvalidOperationException() + { + var result = Result.Ok("test"); + + Assert.Throws(() => _ = result.Error); + } + + [Test] + public async Task ImplicitConversion_FromValue_ItShouldReturnSuccessResult() + { + Result result = "test"; + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo("test"); + } + + [Test] + public async Task ImplicitConversion_FromError_ItShouldReturnFailureResult() + { + var error = new Error("code", "message"); + Result result = error; + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error).IsEqualTo(error); + } + + [Test] + public async Task Map_WhenResultIsSuccess_ItShouldTransformValue() + { + var result = Result.Ok(5); + var mapped = result.Map(n => n.ToString(CultureInfo.InvariantCulture)); + await Assert.That(mapped.IsSuccess).IsTrue(); + await Assert.That(mapped.Value).IsEqualTo("5"); + } + + [Test] + public async Task Map_WhenResultIsFailure_ItShouldPropagateError() + { + var error = new Error("code", "message"); + var result = Result.Fail(error); + var mapped = result.Map(n => n.ToString(CultureInfo.InvariantCulture)); + await Assert.That(mapped.IsFailure).IsTrue(); + await Assert.That(mapped.Error).IsEqualTo(error); + } + + [Test] + public async Task MapError_WhenResultIsFailure_ItShouldTransformError() + { + var originalError = new Error("original", "original message"); + var result = Result.Fail(originalError); + var transformed = result.MapError(e => new Error("transformed", e.Message)); + await Assert.That(transformed.IsFailure).IsTrue(); + await Assert.That(transformed.Error.Code).IsEqualTo("transformed"); + } + + [Test] + public async Task MapError_WhenResultIsSuccess_ItShouldReturnUnchanged() + { + var result = Result.Ok(5); + var mapped = result.MapError(e => new Error("transformed", e.Message)); + await Assert.That(mapped.IsSuccess).IsTrue(); + await Assert.That(mapped.Value).IsEqualTo(5); + } + + [Test] + public async Task Bind_WhenResultIsSuccess_ItShouldChainToBinderResult() + { + var result = Result.Ok(5); + var bound = result.Bind(n => Result.Ok(n.ToString(CultureInfo.InvariantCulture))); + await Assert.That(bound.IsSuccess).IsTrue(); + await Assert.That(bound.Value).IsEqualTo("5"); + } + + [Test] + public async Task Bind_WhenResultIsFailure_ItShouldPropagateError() + { + var error = new Error("code", "message"); + var result = Result.Fail(error); + var bound = result.Bind(n => Result.Ok(n.ToString(CultureInfo.InvariantCulture))); + await Assert.That(bound.IsFailure).IsTrue(); + await Assert.That(bound.Error).IsEqualTo(error); + } + + [Test] + public async Task Bind_WhenResultIsSuccess_ItShouldReturnBinderFailureResult() + { + var result = Result.Ok(5); + var bound = result.Bind(_ => Result.Fail(new Error("bind_error", "bound failed"))); + await Assert.That(bound.IsFailure).IsTrue(); + await Assert.That(bound.Error.Code).IsEqualTo("bind_error"); + } + + [Test] + public async Task Match_WhenResultIsSuccess_ItShouldReturnOnSuccessValue() + { + var result = Result.Ok("test"); + var matched = result.Match(v => v.Length, _ => 0); + await Assert.That(matched).IsEqualTo(4); + } + + [Test] + public async Task Match_WhenResultIsFailure_ItShouldReturnOnFailureValue() + { + var result = Result.Fail(new Error("code", "message")); + var matched = result.Match(v => v.Length, e => -1); + await Assert.That(matched).IsEqualTo(-1); + } + + [Test] + public async Task Match_WhenResultIsSuccess_ItShouldInvokeOnSuccess() + { + var result = Result.Ok("test"); + var invoked = false; + result.Match(v => { invoked = true; }, _ => { }); + await Assert.That(invoked).IsTrue(); + } + + [Test] + public async Task Match_WhenResultIsFailure_ItShouldInvokeOnFailure() + { + var error = new Error("code", "message"); + var result = Result.Fail(error); + var invoked = false; + result.Match(_ => { }, e => { invoked = true; }); + await Assert.That(invoked).IsTrue(); + } + + [Test] + public async Task Try_WhenFuncSucceeds_ItShouldReturnOkWithValue() + { + var result = Result.Try(() => 42); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(42); + } + + [Test] + public async Task Try_WhenFuncThrowsException_ItShouldReturnFailWithUnexpectedError() + { + var result = Result.Try(() => throw new InvalidOperationException("test error")); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error.Code).IsEqualTo("UnexpectedError"); + } + + [Test] + public async Task Try_WhenFuncThrowsException_ItShouldUseCustomError() + { + var result = Result.Try(() => throw new InvalidOperationException("test"), ex => new Error("custom", ex.Message)); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error.Code).IsEqualTo("custom"); + } +} diff --git a/tests/StevanFreeborn.Results.Tests/ResultTests.cs b/tests/StevanFreeborn.Results.Tests/ResultTests.cs new file mode 100644 index 0000000..13a0f00 --- /dev/null +++ b/tests/StevanFreeborn.Results.Tests/ResultTests.cs @@ -0,0 +1,146 @@ +namespace StevanFreeborn.Results.Tests; + +public class ResultTests +{ + [Test] + public async Task Ok_WhenCalled_ItShouldReturnSuccessResult() + { + var result = Result.Ok(default); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.IsFailure).IsFalse(); + } + + [Test] + public async Task Fail_WhenCalled_ItShouldReturnFailureResult() + { + var error = new Error("code", "message"); + var result = Result.Fail(error); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error).IsEqualTo(error); + } + + [Test] + public async Task Error_WhenResultIsSuccess_ItShouldThrowInvalidOperationException() + { + Assert.Throws(() => _ = Result.Ok(default).Error); + } + + [Test] + public async Task ImplicitConversion_FromError_ItShouldReturnFailureResult() + { + var error = new Error("code", "message"); + Result result = error; + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error).IsEqualTo(error); + } + + [Test] + public async Task Map_WhenResultIsSuccess_ItShouldInvokeAction() + { + var result = Result.Ok(default); + var invoked = false; + result.Map(_ => invoked = true); + await Assert.That(invoked).IsTrue(); + } + + [Test] + public async Task Map_WhenResultIsFailure_ItShouldSkipAction() + { + var result = Result.Fail(new Error("code", "message")); + var invoked = false; + result.Map(_ => invoked = true); + await Assert.That(invoked).IsFalse(); + } + + [Test] + public async Task Map_WhenCalled_ItShouldReturnOriginalResult() + { + var result = Result.Ok(default); + var mapped = result.Map(_ => { }); + await Assert.That(mapped.IsSuccess).IsTrue(); + } + + [Test] + public async Task Bind_WhenResultIsSuccess_ItShouldInvokeBinder() + { + var result = Result.Ok(default); + var bound = result.Bind(_ => Result.Ok(default)); + await Assert.That(bound.IsSuccess).IsTrue(); + } + + [Test] + public async Task Bind_WhenResultIsFailure_ItShouldReturnOriginalResult() + { + var result = Result.Fail(new Error("code", "message")); + var bound = result.Bind(_ => Result.Ok(default)); + await Assert.That(bound.IsFailure).IsTrue(); + await Assert.That(bound.Error).IsEqualTo(result.Error); + } + + [Test] + public async Task Bind_WhenResultIsSuccess_ItShouldReturnBinderResult() + { + var result = Result.Ok(default); + var bound = result.Bind(_ => Result.Fail(new Error("new_error", "new message"))); + await Assert.That(bound.IsFailure).IsTrue(); + } + + [Test] + public async Task Match_WhenResultIsSuccess_ItShouldReturnOnSuccessValue() + { + var result = Result.Ok(default); + var matched = result.Match(_ => "success", _ => "failure"); + await Assert.That(matched).IsEqualTo("success"); + } + + [Test] + public async Task Match_WhenResultIsFailure_ItShouldReturnOnFailureValue() + { + var result = Result.Fail(new Error("code", "message")); + var matched = result.Match(_ => "success", e => e.Code); + await Assert.That(matched).IsEqualTo("code"); + } + + [Test] + public async Task Match_WhenResultIsSuccess_ItShouldInvokeOnSuccess() + { + var result = Result.Ok(default); + var invoked = false; + result.Match(_ => invoked = true, _ => { }); + await Assert.That(invoked).IsTrue(); + } + + [Test] + public async Task Match_WhenResultIsFailure_ItShouldInvokeOnFailure() + { + var error = new Error("code", "message"); + var result = Result.Fail(error); + var invoked = false; + result.Match(_ => { }, e => invoked = true); + await Assert.That(invoked).IsTrue(); + } + + [Test] + public async Task Try_WhenActionSucceeds_ItShouldReturnOk() + { + var result = Result.Try(() => default(Unit)); + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task Try_WhenActionThrowsException_ItShouldReturnFailWithUnexpectedError() + { + var result = Result.Try(() => throw new Exception("test error")); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error.Code).IsEqualTo("UnexpectedError"); + } + + [Test] + public async Task Try_WhenActionThrowsException_ItShouldUseCustomError() + { + var result = Result.Try(() => throw new Exception("test"), ex => new Error("custom", ex.Message)); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error.Code).IsEqualTo("custom"); + } +} diff --git a/tests/StevanFreeborn.Results.Tests/StevanFreeborn.Results.Tests.csproj b/tests/StevanFreeborn.Results.Tests/StevanFreeborn.Results.Tests.csproj new file mode 100644 index 0000000..f0dc5b5 --- /dev/null +++ b/tests/StevanFreeborn.Results.Tests/StevanFreeborn.Results.Tests.csproj @@ -0,0 +1,13 @@ + + + Exe + StevanFreeborn.Results.Tests + net10.0 + + + + + + + +