Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions benchmarks/Wolfgang.TryPattern.Benchmarks/TryBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public async Task RunAsync_Action_Success()
{
for (var i = 0; i < OperationCount; i++)
{
await Try.RunAsync(async () => await Task.CompletedTask);
await Try.RunAsync(() => { });
}
}

Expand All @@ -43,11 +43,7 @@ public async Task RunAsync_Action_WithException()
{
for (var i = 0; i < OperationCount; i++)
{
await Try.RunAsync(async () =>
{
await Task.CompletedTask;
throw new InvalidOperationException();
});
await Try.RunAsync(() => throw new InvalidOperationException());
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<!--
Suppress analyzer rules that are noisy or non-applicable in benchmark code.
These are firing on the Windows CI runner despite being relaxed in
.editorconfig's [benchmarks/**/*.cs] section, so we suppress at the project
level for explicit, reliable enforcement.
-->
<NoWarn>$(NoWarn);
MA0004; <!-- ConfigureAwait - benchmarks intentionally use SynchronizationContext-equivalent default -->
VSTHRD200; <!-- Async naming - benchmark methods don't follow 'Async' suffix convention -->
S6966 <!-- Sync vs async - benchmarks intentionally compare both -->
</NoWarn>
</PropertyGroup>

<ItemGroup>
Expand Down
11 changes: 11 additions & 0 deletions examples/CSharp.DotNet8.Example/CSharp.DotNet8.Example.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<!--
Suppress analyzer rules that are noisy or non-applicable in example code.
These are firing on the Windows CI runner despite being relaxed in
.editorconfig's [examples/**/*.cs] section, so we suppress at the project
level for explicit, reliable enforcement.
-->
<NoWarn>$(NoWarn);
MA0004; <!-- ConfigureAwait - examples are end-user code, ConfigureAwait shouldn't be required -->
S6966 <!-- Sync vs async - examples can demonstrate either form -->
</NoWarn>
</PropertyGroup>

<ItemGroup>
Expand Down
2 changes: 1 addition & 1 deletion examples/CSharp.DotNet8.Example/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ private static async Task Main()
}

// Try counting words in the file content
wordCountResult = Try.Run(() => GetWordCount(null));
wordCountResult = Try.Run(() => GetWordCount(content: null));

// If word count failed, print the error message
if (wordCountResult.Failed)
Expand Down
2 changes: 2 additions & 0 deletions examples/VB.DotNet8.Example/Program.vb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ Imports Wolfgang.TryPattern
<ExcludeFromCodeCoverage>
Friend Module Module1
Public Sub Main()
#Disable Warning RS0030 ' GetResult() is the standard VB sync entry-point pattern (no async Main support)
MainAsync().GetAwaiter().GetResult()
#Enable Warning RS0030
End Sub

Private Async Function MainAsync() As Task
Expand Down
16 changes: 10 additions & 6 deletions src/Wolfgang.TryPattern/Result.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,14 @@ protected Result
public static Result Failure(string errorMessage) =>
string.IsNullOrWhiteSpace(errorMessage)
? throw new ArgumentException("errorMessage cannot be empty", nameof(errorMessage))
: new Result(false, errorMessage);
: new Result(succeeded: false, errorMessage);
Comment thread
Chris-Wolfgang marked this conversation as resolved.



/// <summary>
/// Creates a successful <see cref="Result"/>.
/// </summary>
public static Result Success() => new(true, string.Empty);
public static Result Success() => new(succeeded: true, string.Empty);
Comment thread
Chris-Wolfgang marked this conversation as resolved.



Expand Down Expand Up @@ -154,6 +154,10 @@ public static bool AllSucceeded(params Result[]? results) =>
/// <see cref="Result.ErrorMessage"/> property will contain a message as to why. If the operation succeeded the
/// <see cref="Result{T}.Value"/> property will contain the return value from the function.
/// </summary>
[System.Diagnostics.CodeAnalysis.SuppressMessage(
"Design",
"CA1000:Do not declare static members on generic types",
Justification = "Result<T>.Failure / Result<T>.Success are factory methods central to the public API; consumers explicitly specify T at the call site by design.")]
public class Result<T> : Result
{

Expand Down Expand Up @@ -196,12 +200,12 @@ public class Result<T> : Result
public static new Result<T?> Failure(string errorMessage) =>
string.IsNullOrWhiteSpace(errorMessage)
? throw new ArgumentException("errorMessage cannot be empty", nameof(errorMessage))
: new Result<T?>(false, errorMessage, default!);
: new Result<T?>(succeeded: false, errorMessage, default!);
Comment thread
Chris-Wolfgang marked this conversation as resolved.
#else
public static new Result<T> Failure(string errorMessage) =>
string.IsNullOrWhiteSpace(errorMessage)
? throw new ArgumentException("errorMessage cannot be empty", nameof(errorMessage))
: new Result<T>(false, errorMessage, default!);
: new Result<T>(succeeded: false, errorMessage, default!);
Comment thread
Chris-Wolfgang marked this conversation as resolved.
#endif


Expand All @@ -210,9 +214,9 @@ public class Result<T> : Result
/// Creates a successful <see cref="Result"/> with specified value.
/// </summary>
#if NET5_0_OR_GREATER
public static Result<T?> Success(T? value) => new(true, string.Empty, value);
public static Result<T?> Success(T? value) => new(succeeded: true, string.Empty, value);
Comment thread
Chris-Wolfgang marked this conversation as resolved.
#else
public static Result<T> Success(T value) => new(true, string.Empty, value);
public static Result<T> Success(T value) => new(succeeded: true, string.Empty, value);
Comment thread
Chris-Wolfgang marked this conversation as resolved.
#endif


Expand Down
40 changes: 38 additions & 2 deletions src/Wolfgang.TryPattern/Try.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public static class Try
/// <returns>
/// A <see cref="Result"/> that indicates if the action was successful.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="action"/> is null.</exception>
public static Result Run(Action action)
{
if (action == null)
Expand Down Expand Up @@ -47,6 +48,7 @@ public static Result Run(Action action)
/// A <see cref="Result{T}"/> indicating if the function was successful or not and the result of
/// the function if it was.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="function"/> is null.</exception>
#if NET5_0_OR_GREATER
public static Result<T?> Run<T>(Func<T> function)
{
Expand Down Expand Up @@ -89,10 +91,17 @@ public static Result<T> Run<T>(Func<T>? function)
/// Executes the specified action asynchronously, catching any exception that may occur.
/// </summary>
/// <param name="action">The action to execute.</param>
/// <param name="token">The CancellationToken to monitor.</param>
/// <param name="token">
/// A <see cref="CancellationToken"/> that is passed to <see cref="Task.Run(Action, CancellationToken)"/>.
/// If cancellation is observed (either before <paramref name="action"/> starts or while it runs),
/// an <see cref="OperationCanceledException"/> is propagated to the caller rather than wrapped
/// in a failed <see cref="Result"/>.
/// </param>
/// <returns>
/// A <see cref="Task"/> of <see cref="Result"/> representing the asynchronous operation.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="action"/> is null.</exception>
Comment thread
Chris-Wolfgang marked this conversation as resolved.
Outdated
Comment thread
Chris-Wolfgang marked this conversation as resolved.
/// <exception cref="OperationCanceledException">Cancellation was observed via <paramref name="token"/>.</exception>
Comment thread
Chris-Wolfgang marked this conversation as resolved.
Outdated
public static async Task<Result> RunAsync(Action action, CancellationToken token = default)
{
if (action == null)
Expand Down Expand Up @@ -122,10 +131,25 @@ public static async Task<Result> RunAsync(Action action, CancellationToken token
/// </summary>
/// <typeparam name="T">The return type of the function.</typeparam>
/// <param name="function">The function to execute.</param>
/// <param name="token">The CancellationToken to monitor.</param>
/// <param name="token">
/// A <see cref="CancellationToken"/> that is checked before <paramref name="function"/> is
/// invoked. If cancellation is already requested, an <see cref="OperationCanceledException"/>
/// is thrown without invoking <paramref name="function"/>. The token is not threaded into
/// <paramref name="function"/> itself; if you need cancellation inside the function, capture
/// the token in the lambda's closure. If <paramref name="function"/> observes the token during
/// execution (e.g. via <see cref="Task.Delay(int, CancellationToken)"/>) and the resulting
/// <see cref="OperationCanceledException"/> escapes, it is also propagated to the caller
/// rather than wrapped in a failed <see cref="Result{T}"/>.
/// </param>
/// <returns>
/// A <see cref="Task"/> of <see cref="Result{T}"/> representing the asynchronous operation.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="function"/> is null.</exception>
/// <exception cref="OperationCanceledException">
/// <paramref name="token"/> was already canceled when this method was called, or
/// <paramref name="function"/> observed cancellation during execution and let an
/// <see cref="OperationCanceledException"/> escape.
/// </exception>
#if NET5_0_OR_GREATER
public static async Task<Result<T?>> RunAsync<T>(Func<Task<T?>> function, CancellationToken token = default)
{
Expand All @@ -134,6 +158,12 @@ public static async Task<Result> RunAsync(Action action, CancellationToken token
throw new ArgumentNullException(nameof(function));
}

// Observe the token before invoking. The delegate signature has no token parameter, so we
// cannot flow it through; the best we can do is fail fast if cancellation was already
// requested. ThrowIfCancellationRequested propagates OperationCanceledException directly
// to the caller (it runs before the try/catch and is intentionally not wrapped in a Result).
token.ThrowIfCancellationRequested();
Comment thread
Chris-Wolfgang marked this conversation as resolved.

Comment thread
Chris-Wolfgang marked this conversation as resolved.
Comment thread
Chris-Wolfgang marked this conversation as resolved.
try
{
var result = await function().ConfigureAwait(false);
Expand All @@ -156,6 +186,12 @@ public static async Task<Result<T>> RunAsync<T>(Func<Task<T>> function, Cancella
throw new ArgumentNullException(nameof(function));
}

// Observe the token before invoking. The delegate signature has no token parameter, so we
// cannot flow it through; the best we can do is fail fast if cancellation was already
// requested. ThrowIfCancellationRequested propagates OperationCanceledException directly
// to the caller (it runs before the try/catch and is intentionally not wrapped in a Result).
token.ThrowIfCancellationRequested();

try
{
var result = await function().ConfigureAwait(false);
Expand Down
8 changes: 4 additions & 4 deletions tests/Wolfgang.TryPattern.Tests/ResultTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ private class TestResult(bool succeeded, string? errorMessage) : Result(succeede
[Fact]
public void Ctor_when_passed_true_and_empty_string_does_not_throw_Exception()
{
var unused = new TestResult(true, string.Empty);
var unused = new TestResult(succeeded: true, string.Empty);
Comment thread
Chris-Wolfgang marked this conversation as resolved.
}


Expand All @@ -21,7 +21,7 @@ public void Ctor_when_passed_true_and_empty_string_does_not_throw_Exception()
[InlineData("Test error")]
public void Ctor_when_passed_true_and_non_empty_string_throw_InvalidOperationException(string? message)
{
var ex = Assert.Throws<ArgumentException>(() => new TestResult(true, message));
var ex = Assert.Throws<ArgumentException>(() => new TestResult(succeeded: true, message));
Comment thread
Chris-Wolfgang marked this conversation as resolved.
Assert.Equal("errorMessage", ex.ParamName);
}

Expand All @@ -30,7 +30,7 @@ public void Ctor_when_passed_true_and_non_empty_string_throw_InvalidOperationExc
[Fact]
public void Ctor_when_passed_false_and_message_does_not_throw_Exception()
{
var unused = new TestResult(false, "Test error");
var unused = new TestResult(succeeded: false, "Test error");
Comment thread
Chris-Wolfgang marked this conversation as resolved.
}


Expand All @@ -41,7 +41,7 @@ public void Ctor_when_passed_false_and_message_does_not_throw_Exception()
[InlineData("")]
public void Ctor_when_passed_false_and_no_message_throw_InvalidOperationException(string? message)
{
var ex = Assert.Throws<ArgumentException>(() => new TestResult(false, message));
var ex = Assert.Throws<ArgumentException>(() => new TestResult(succeeded: false, message));
Comment thread
Chris-Wolfgang marked this conversation as resolved.
Assert.Equal("errorMessage", ex.ParamName);
}

Expand Down
42 changes: 42 additions & 0 deletions tests/Wolfgang.TryPattern.Tests/RunAsyncFuncTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -358,4 +358,46 @@ async Task<int> Function()

await Assert.ThrowsAsync<TaskCanceledException>(() => task);
}



[Fact]
public async Task RunAsync_Func_when_token_is_already_canceled_throws_OperationCanceledException_without_invoking_function()
{
// Arrange
using var cts = new CancellationTokenSource();
cts.Cancel();
var wasInvoked = false;

Task<int> Function()
{
wasInvoked = true;
return Task.FromResult(42);
}

// Act & Assert
await Assert.ThrowsAsync<OperationCanceledException>(() => Try.RunAsync((Func<Task<int>>)Function, cts.Token));
Assert.False(wasInvoked, "function should not be invoked when the token is already canceled");
}



[Fact]
public async Task RunAsync_Func_nullable_when_token_is_already_canceled_throws_OperationCanceledException_without_invoking_function()
{
// Arrange
using var cts = new CancellationTokenSource();
cts.Cancel();
var wasInvoked = false;

Task<int?> Function()
{
wasInvoked = true;
return Task.FromResult<int?>(42);
}

// Act & Assert
await Assert.ThrowsAsync<OperationCanceledException>(() => Try.RunAsync((Func<Task<int?>>)Function, cts.Token));
Assert.False(wasInvoked, "function should not be invoked when the token is already canceled");
}
}
2 changes: 1 addition & 1 deletion tests/Wolfgang.TryPattern.Tests/RunFuncTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public void Run_Func_WithNullableStringFunction_ReturnsResult()
{
// Arrange
const string expectedValue = "Hello, World!";
static string Function() => expectedValue;
static string? Function() => expectedValue;

// Act
var result = Try.Run(Function);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,27 @@
<IsTestProject>true</IsTestProject>
<Copyright>Copyright 2025 Chris Wolfgang</Copyright>

<!--
Suppress analyzer rules that are noisy or non-applicable in test code.
These are firing on the Windows CI runner despite being relaxed in
.editorconfig's [tests/**/*.cs] section, so we suppress at the project
level to make the policy explicit and unambiguous.
-->
<NoWarn>$(NoWarn);
VSTHRD200; <!-- Async naming - tests use descriptive names without 'Async' suffix -->
VSTHRD003; <!-- Awaiting Task started outside context - common test pattern -->
VSTHRD103; <!-- Sync calls when async exists - tests need both -->
AsyncFixer01; <!-- Single-await async methods - test pattern -->
MA0004; <!-- ConfigureAwait - not needed in tests -->
S6966; <!-- Sync vs async - test fixtures use both -->
S2190; <!-- Loop without break - false positive on tests that throw to exit loop -->
S2930; <!-- Dispose CTS - tests are short-lived; CTS goes out of scope -->
S3928; <!-- ArgumentException paramName - test fixture exceptions are illustrative -->
MA0015; <!-- Same as S3928 from Meziantou -->
MA0012; <!-- NullReferenceException is reserved - tests intentionally throw it to verify handling -->
S2699; <!-- Test must have assertion - xUnit treats unhandled exceptions as failures, so 'no exception thrown' tests are valid without explicit asserts -->
S1481 <!-- Unused local 'unused' - intentional 'does not throw' pattern; rename masks the test's purpose -->
</NoWarn>
Comment thread
Chris-Wolfgang marked this conversation as resolved.
</PropertyGroup>

<!-- Common packages for all frameworks -->
Expand Down
Loading