Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
55 changes: 55 additions & 0 deletions Light.GuardClauses.SingleFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4878,6 +4878,53 @@ public static TimeSpan MustBePositive(this TimeSpan parameter, Func<TimeSpan, Ex
return parameter;
}

/// <summary>
/// Ensures that the specified <paramref name = "parameter"/> is positive (greater than zero) or exactly equals
/// <see cref = "System.Threading.Timeout.InfiniteTimeSpan"/>, or otherwise throws an
/// <see cref = "ArgumentOutOfRangeException"/>.
/// </summary>
/// <param name = "parameter">The value to be checked.</param>
/// <param name = "parameterName">The name of the parameter (optional).</param>
/// <param name = "message">The message that will be passed to the resulting exception (optional).</param>
/// <exception cref = "ArgumentOutOfRangeException">
/// Thrown when <paramref name = "parameter"/> is neither positive nor exactly equal to
/// <see cref = "System.Threading.Timeout.InfiniteTimeSpan"/>.
/// </exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static TimeSpan MustBePositiveOrInfinite(this TimeSpan parameter, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null)
{
if (!(parameter > TimeSpan.Zero || parameter == System.Threading.Timeout.InfiniteTimeSpan))
{
Throw.MustBePositiveOrInfinite(parameter, parameterName, message);
}

return parameter;
}

/// <summary>
/// Ensures that the specified <paramref name = "parameter"/> is positive (greater than zero) or exactly equals
/// <see cref = "System.Threading.Timeout.InfiniteTimeSpan"/>, or otherwise throws your custom exception.
/// </summary>
/// <param name = "parameter">The value to be checked.</param>
/// <param name = "exceptionFactory">
/// The delegate that creates your custom exception. <paramref name = "parameter"/> is passed to this delegate.
/// </param>
/// <exception cref = "Exception">
/// Your custom exception thrown when <paramref name = "parameter"/> is neither positive nor exactly equal to
/// <see cref = "System.Threading.Timeout.InfiniteTimeSpan"/>.
/// </exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("exceptionFactory:null => halt")]
public static TimeSpan MustBePositiveOrInfinite(this TimeSpan parameter, Func<TimeSpan, Exception> exceptionFactory)
{
if (!(parameter > TimeSpan.Zero || parameter == System.Threading.Timeout.InfiniteTimeSpan))
{
Throw.CustomException(exceptionFactory, parameter);
}

return parameter;
}

/// <summary>
/// Ensures that the specified stream supports reading and returns the original stream, or otherwise throws an
/// <see cref = "ArgumentException"/>. Validation reads only <see cref = "Stream.CanRead"/> and performs no I/O.
Expand Down Expand Up @@ -12947,6 +12994,14 @@ public static void MustBeLessThanOrEqualTo<T>(T parameter, T boundary, [CallerAr
[DoesNotReturn]
public static void MustBePositive<T>(T parameter, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null) => throw new ArgumentOutOfRangeException(parameterName, message ?? $"{parameterName ?? "The value"} must be positive, but it actually is {parameter}.");
/// <summary>
/// Throws the default <see cref = "ArgumentOutOfRangeException"/> indicating that a <see cref = "TimeSpan"/> must
/// be positive or equal to <see cref = "System.Threading.Timeout.InfiniteTimeSpan"/>, using the optional parameter
/// name and message.
/// </summary>
[ContractAnnotation("=> halt")]
[DoesNotReturn]
public static void MustBePositiveOrInfinite(TimeSpan parameter, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null) => throw new ArgumentOutOfRangeException(parameterName, message ?? $"{parameterName ?? "The value"} must be positive or equal to Timeout.InfiniteTimeSpan, but it actually is {parameter}.");
/// <summary>
/// Throws the default <see cref = "ArgumentOutOfRangeException"/> indicating that a value must not be approximately
/// equal to another value within a specified tolerance, using the optional parameter name and message.
/// </summary>
Expand Down
41 changes: 41 additions & 0 deletions ai-plans/0175-must-be-positive-or-infinite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Positive Timeout or Infinite Sentinel

## Rationale

Parent issue #162 (point 6) identifies two BrilliantMessaging guards that accept either a positive `TimeSpan` or the standard .NET infinite-timeout sentinel. Callers can express this with a generic predicate, but doing so obscures the `Timeout.InfiniteTimeSpan` convention and produces a less-specific failure contract.

Add `MustBePositiveOrInfinite` as a fluent `TimeSpan` assertion on every supported target framework. The guard will make timeout validation explicit while retaining the return-value, exception, custom-factory, and source-export conventions of the existing sign guards.

## Acceptance Criteria

- [x] `MustBePositiveOrInfinite` returns the original `TimeSpan` when it is greater than `TimeSpan.Zero` or exactly equals `Timeout.InfiniteTimeSpan`, and rejects zero and every other negative value identically on .NET Standard 2.0, .NET Standard 2.1, and .NET 10.
- [x] Default failures throw `ArgumentOutOfRangeException` and preserve the existing sign-guard contract: caller-argument-expression parameter names, optional custom messages, and a generated message that identifies the rejected value and the two accepted forms.
- [x] A custom-exception-factory overload accepts `Func<TimeSpan, Exception>`, passes the original value to the factory, invokes it only for an invalid value, and throws `ArgumentNullException` through the existing `Throw.CustomException` convention when a failing check receives a null factory.
- [x] Automated tests cover positive boundary values, the exact infinite sentinel, zero, representative negative values on both sides of the sentinel, `TimeSpan.MinValue`, return values, default exception details, custom factory behavior, factories not invoked on success, and null-factory behavior.
- [x] The source-export whitelist catalog and committed settings contain `MustBePositiveOrInfinite`; focused source-export tests verify the assertion and its throw-helper dependency in portable and modern output, as well as trimming of the custom-exception-factory overload when configured.
- [x] The committed .NET Standard 2.0 single-file distribution is regenerated with the assertion and validates for both supported source-export targets.
- [x] The comparable/range assertion documentation lists `MustBePositiveOrInfinite` and defines “infinite” as exact equality with `Timeout.InfiniteTimeSpan`; the package release notes mention the new assertion.
- [x] The complete solution restores and builds without warnings in Release configuration, and all automated tests pass on the pinned SDK.

## Technical Details

Add a dedicated `TimeSpan` assertion following the structure, annotations, XML documentation, parameter ordering, and aggressive-inlining convention of the existing `MustBePositive(TimeSpan)` overloads. The exact public shape is:

```csharp
public static TimeSpan MustBePositiveOrInfinite(
this TimeSpan parameter,
[CallerArgumentExpression("parameter")] string? parameterName = null,
string? message = null
);

public static TimeSpan MustBePositiveOrInfinite(
this TimeSpan parameter,
Func<TimeSpan, Exception> exceptionFactory
);
```

Use the predicate `parameter > TimeSpan.Zero || parameter == Timeout.InfiniteTimeSpan`. “Infinite” is deliberately limited to the BCL sentinel from `System.Threading`; it does not mean `TimeSpan.MaxValue` as a separate category (that value already succeeds because it is positive), nor does it admit arbitrary negative durations. Because equality is value-based, any `TimeSpan` equal to negative one millisecond is the sentinel regardless of how it was constructed. Values one tick above or below it remain negative and must fail.

Route default failures through a new non-returning `Throw.MustBePositiveOrInfinite` helper that constructs `ArgumentOutOfRangeException` consistently with `Throw.MustBePositive`; no new public exception type is needed. The factory overload should evaluate the same predicate and delegate failures to `Throw.CustomException`.

Register the assertion in `AssertionWhitelist` and the committed source-export settings, add focused source-export coverage for dependency retention and factory trimming, and regenerate the root `Light.GuardClauses.SingleFile.cs`. Add guard tests alongside the comparable assertions, and update `docs/assertion-overview.md` plus the current package release notes. No microbenchmarks are required because the guard adds only a comparison and an equality check.
3 changes: 3 additions & 0 deletions docs/assertion-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ UUIDv7 validation checks the version-7 nibble and RFC/IETF `10xx` variant bits d
| `MustNotBeGreaterThan`, `MustNotBeGreaterThanOrEqualTo` | Reject values above or at an upper boundary |
| `MustNotBeLessThan`, `MustNotBeLessThanOrEqualTo` | Reject values below or at a lower boundary |
| `MustBePositive`, `MustBeNegative` | Require a value greater than, or less than, zero |
| `MustBePositiveOrInfinite` | Requires a positive `TimeSpan` or exact equality with `Timeout.InfiniteTimeSpan` |
| `MustNotBePositive`, `MustNotBeNegative` | Require a value less than or equal to, or greater than or equal to, zero |
| `MustNotBeZero` | Rejects a value that compares equal to zero |
| `IsIn`, `MustBeIn` | Test or require membership in a `Range<T>` |
Expand All @@ -74,6 +75,8 @@ UUIDv7 validation checks the version-7 nibble and RFC/IETF `10xx` variant bits d

The five sign guard families have concrete overloads for `int`, `long`, `decimal`, `float`, `double`, and `TimeSpan` on all package targets. `MustBePositive`, `MustNotBePositive`, and `MustNotBeZero` additionally have concrete `sbyte`, `byte`, `short`, `ushort`, `uint`, and `ulong` overloads on every target, while `MustBeNegative` and `MustNotBeNegative` add concrete `sbyte` and `short` overloads — unsigned overloads are omitted for these two families because their predicates would be constant. The .NET 10 asset adds the generic `INumber<T>` overloads listed above. All checks compare the value against zero with the type's comparison operators. Consequently, `NaN` is rejected by the four sign guards and accepted by `MustNotBeZero`, positive and negative infinity satisfy the guards matching their sign (compose with `MustBeFinite` to reject non-finite values), and negative zero — including `decimal`'s signed zero representations — behaves exactly like zero. `MustNotBeZero` uses exact equality; tolerance-based comparisons remain the domain of the approximation guards.

`MustBePositiveOrInfinite` is available for `TimeSpan` on every package target. “Infinite” means exact value equality with `Timeout.InfiniteTimeSpan` (negative one millisecond); values even one tick above or below that sentinel are rejected unless they are positive.

Create ranges with the `Range<T>` fluent API:

```csharp
Expand Down
1 change: 1 addition & 0 deletions src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
--------------------------------

- new assertions: MustBeAssignableTo, MustBeConcreteClass, MustBeUri, ObjectDisposed
- new TimeSpan assertion: MustBePositiveOrInfinite accepts positive durations or Timeout.InfiniteTimeSpan
- expanded sign-guard support on all target frameworks: MustBePositive, MustNotBePositive, and MustNotBeZero for sbyte, byte, short, ushort, uint, and ulong; MustBeNegative and MustNotBeNegative for sbyte and short
</PackageReleaseNotes>
</PropertyGroup>
Expand Down
64 changes: 64 additions & 0 deletions src/Light.GuardClauses/Check.MustBePositiveOrInfinite.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using System;
using System.Runtime.CompilerServices;
using System.Threading;
using JetBrains.Annotations;
using Light.GuardClauses.ExceptionFactory;

namespace Light.GuardClauses;

public static partial class Check
{
/// <summary>
/// Ensures that the specified <paramref name="parameter" /> is positive (greater than zero) or exactly equals
/// <see cref="System.Threading.Timeout.InfiniteTimeSpan" />, or otherwise throws an
/// <see cref="ArgumentOutOfRangeException" />.
/// </summary>
/// <param name="parameter">The value to be checked.</param>
/// <param name="parameterName">The name of the parameter (optional).</param>
/// <param name="message">The message that will be passed to the resulting exception (optional).</param>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when <paramref name="parameter" /> is neither positive nor exactly equal to
/// <see cref="System.Threading.Timeout.InfiniteTimeSpan" />.
/// </exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static TimeSpan MustBePositiveOrInfinite(
this TimeSpan parameter,
[CallerArgumentExpression("parameter")] string? parameterName = null,
string? message = null
)
{
if (!(parameter > TimeSpan.Zero || parameter == Timeout.InfiniteTimeSpan))
{
Throw.MustBePositiveOrInfinite(parameter, parameterName, message);
}

return parameter;
}

/// <summary>
/// Ensures that the specified <paramref name="parameter" /> is positive (greater than zero) or exactly equals
/// <see cref="System.Threading.Timeout.InfiniteTimeSpan" />, or otherwise throws your custom exception.
/// </summary>
/// <param name="parameter">The value to be checked.</param>
/// <param name="exceptionFactory">
/// The delegate that creates your custom exception. <paramref name="parameter" /> is passed to this delegate.
/// </param>
/// <exception cref="Exception">
/// Your custom exception thrown when <paramref name="parameter" /> is neither positive nor exactly equal to
/// <see cref="System.Threading.Timeout.InfiniteTimeSpan" />.
/// </exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("exceptionFactory:null => halt")]
public static TimeSpan MustBePositiveOrInfinite(
this TimeSpan parameter,
Func<TimeSpan, Exception> exceptionFactory
)
{
if (!(parameter > TimeSpan.Zero || parameter == Timeout.InfiniteTimeSpan))
{
Throw.CustomException(exceptionFactory, parameter);
}

return parameter;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using JetBrains.Annotations;

namespace Light.GuardClauses.ExceptionFactory;

public static partial class Throw
{
/// <summary>
/// Throws the default <see cref="ArgumentOutOfRangeException" /> indicating that a <see cref="TimeSpan" /> must
/// be positive or equal to <see cref="System.Threading.Timeout.InfiniteTimeSpan" />, using the optional parameter
/// name and message.
/// </summary>
[ContractAnnotation("=> halt")]
[DoesNotReturn]
public static void MustBePositiveOrInfinite(
TimeSpan parameter,
[CallerArgumentExpression("parameter")] string? parameterName = null,
string? message = null
) =>
throw new ArgumentOutOfRangeException(
parameterName,
message ??
$"{parameterName ?? "The value"} must be positive or equal to Timeout.InfiniteTimeSpan, but it actually is {parameter}."
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,70 @@ public static void SignGuardWhitelistsUseTargetSpecificSurface()
);
}

[Theory]
[InlineData(SourceTargetFramework.NetStandard2_0)]
[InlineData(SourceTargetFramework.Net10_0)]
public static void MustBePositiveOrInfiniteWhitelistRetainsGuardAndThrowHelper(
SourceTargetFramework targetFramework
)
{
using var temporaryDirectory = new TemporaryDirectory();
var targetFile = Path.Combine(
temporaryDirectory.DirectoryPath,
$"MustBePositiveOrInfinite-{targetFramework}.cs"
);

SourceFileMerger.CreateSingleSourceFile(
CreateOptions(
targetFile,
CreateWhitelist(includedAssertions: [new ("MustBePositiveOrInfinite", true)]),
targetFramework
)
);
var sourceCode = File.ReadAllText(targetFile);

sourceCode.Should().Contain("public static TimeSpan MustBePositiveOrInfinite(");
sourceCode.Should().Contain("public static void MustBePositiveOrInfinite(");
sourceCode.Should()
.Contain(
"MustBePositiveOrInfinite(this TimeSpan parameter, Func<TimeSpan, Exception> exceptionFactory)"
);
sourceCode.Should().Contain("public static void CustomException<T>(");
sourceCode.Should().NotContain("public static TimeSpan MustBePositive(");
sourceCode.Should().NotContain("public static void MustBePositive<T>(");
}

[Theory]
[InlineData(SourceTargetFramework.NetStandard2_0)]
[InlineData(SourceTargetFramework.Net10_0)]
public static void MustBePositiveOrInfiniteWhitelistTrimsCustomExceptionFactory(
SourceTargetFramework targetFramework
)
{
using var temporaryDirectory = new TemporaryDirectory();
var targetFile = Path.Combine(
temporaryDirectory.DirectoryPath,
$"MustBePositiveOrInfiniteWithoutFactory-{targetFramework}.cs"
);

SourceFileMerger.CreateSingleSourceFile(
CreateOptions(
targetFile,
CreateWhitelist(includedAssertions: [new ("MustBePositiveOrInfinite", false)]),
targetFramework
)
);
var sourceCode = File.ReadAllText(targetFile);

sourceCode.Should().Contain("public static TimeSpan MustBePositiveOrInfinite(");
sourceCode.Should().Contain("public static void MustBePositiveOrInfinite(");
sourceCode.Should()
.NotContain(
"MustBePositiveOrInfinite(this TimeSpan parameter, Func<TimeSpan, Exception> exceptionFactory)"
);
sourceCode.Should().NotContain("public static void CustomException<T>(");
}

[Fact]
public static void MustContainKeyWhitelistExportsGuardWithExceptionAndThrowHelper()
{
Expand Down
Loading
Loading