diff --git a/Light.GuardClauses.SingleFile.cs b/Light.GuardClauses.SingleFile.cs index d746365..8b76a70 100644 --- a/Light.GuardClauses.SingleFile.cs +++ b/Light.GuardClauses.SingleFile.cs @@ -4878,6 +4878,53 @@ public static TimeSpan MustBePositive(this TimeSpan parameter, Func + /// Ensures that the specified is positive (greater than zero) or exactly equals + /// , or otherwise throws an + /// . + /// + /// The value to be checked. + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// + /// Thrown when is neither positive nor exactly equal to + /// . + /// + [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; + } + + /// + /// Ensures that the specified is positive (greater than zero) or exactly equals + /// , or otherwise throws your custom exception. + /// + /// The value to be checked. + /// + /// The delegate that creates your custom exception. is passed to this delegate. + /// + /// + /// Your custom exception thrown when is neither positive nor exactly equal to + /// . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("exceptionFactory:null => halt")] + public static TimeSpan MustBePositiveOrInfinite(this TimeSpan parameter, Func exceptionFactory) + { + if (!(parameter > TimeSpan.Zero || parameter == System.Threading.Timeout.InfiniteTimeSpan)) + { + Throw.CustomException(exceptionFactory, parameter); + } + + return parameter; + } + /// /// Ensures that the specified stream supports reading and returns the original stream, or otherwise throws an /// . Validation reads only and performs no I/O. @@ -12947,6 +12994,14 @@ public static void MustBeLessThanOrEqualTo(T parameter, T boundary, [CallerAr [DoesNotReturn] public static void MustBePositive(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}."); /// + /// Throws the default indicating that a must + /// be positive or equal to , using the optional parameter + /// name and message. + /// + [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}."); + /// /// Throws the default indicating that a value must not be approximately /// equal to another value within a specified tolerance, using the optional parameter name and message. /// diff --git a/ai-plans/0175-must-be-positive-or-infinite.md b/ai-plans/0175-must-be-positive-or-infinite.md new file mode 100644 index 0000000..49985bd --- /dev/null +++ b/ai-plans/0175-must-be-positive-or-infinite.md @@ -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`, 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 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. diff --git a/docs/assertion-overview.md b/docs/assertion-overview.md index 6479db6..7816680 100644 --- a/docs/assertion-overview.md +++ b/docs/assertion-overview.md @@ -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` | @@ -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` 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` fluent API: ```csharp diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 3535a46..08a1d9e 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -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 diff --git a/src/Light.GuardClauses/Check.MustBePositiveOrInfinite.cs b/src/Light.GuardClauses/Check.MustBePositiveOrInfinite.cs new file mode 100644 index 0000000..12d2551 --- /dev/null +++ b/src/Light.GuardClauses/Check.MustBePositiveOrInfinite.cs @@ -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 +{ + /// + /// Ensures that the specified is positive (greater than zero) or exactly equals + /// , or otherwise throws an + /// . + /// + /// The value to be checked. + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// + /// Thrown when is neither positive nor exactly equal to + /// . + /// + [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; + } + + /// + /// Ensures that the specified is positive (greater than zero) or exactly equals + /// , or otherwise throws your custom exception. + /// + /// The value to be checked. + /// + /// The delegate that creates your custom exception. is passed to this delegate. + /// + /// + /// Your custom exception thrown when is neither positive nor exactly equal to + /// . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("exceptionFactory:null => halt")] + public static TimeSpan MustBePositiveOrInfinite( + this TimeSpan parameter, + Func exceptionFactory + ) + { + if (!(parameter > TimeSpan.Zero || parameter == Timeout.InfiniteTimeSpan)) + { + Throw.CustomException(exceptionFactory, parameter); + } + + return parameter; + } +} diff --git a/src/Light.GuardClauses/ExceptionFactory/Throw.MustBePositiveOrInfinite.cs b/src/Light.GuardClauses/ExceptionFactory/Throw.MustBePositiveOrInfinite.cs new file mode 100644 index 0000000..4035b6f --- /dev/null +++ b/src/Light.GuardClauses/ExceptionFactory/Throw.MustBePositiveOrInfinite.cs @@ -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 +{ + /// + /// Throws the default indicating that a must + /// be positive or equal to , using the optional parameter + /// name and message. + /// + [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}." + ); +} diff --git a/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs b/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs index df02168..a5e6315 100644 --- a/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs +++ b/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs @@ -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 exceptionFactory)" + ); + sourceCode.Should().Contain("public static void CustomException("); + sourceCode.Should().NotContain("public static TimeSpan MustBePositive("); + sourceCode.Should().NotContain("public static void MustBePositive("); + } + + [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 exceptionFactory)" + ); + sourceCode.Should().NotContain("public static void CustomException("); + } + [Fact] public static void MustContainKeyWhitelistExportsGuardWithExceptionAndThrowHelper() { diff --git a/tests/Light.GuardClauses.Tests/ComparableAssertions/MustBePositiveOrInfiniteTests.cs b/tests/Light.GuardClauses.Tests/ComparableAssertions/MustBePositiveOrInfiniteTests.cs new file mode 100644 index 0000000..8830c49 --- /dev/null +++ b/tests/Light.GuardClauses.Tests/ComparableAssertions/MustBePositiveOrInfiniteTests.cs @@ -0,0 +1,95 @@ +using System; +using System.Threading; +using FluentAssertions; +using Xunit; + +namespace Light.GuardClauses.Tests.ComparableAssertions; + +public static class MustBePositiveOrInfiniteTests +{ + [Fact] + public static void PositiveBoundaryValuesAreAcceptedAndReturned() + { + TimeSpan.FromTicks(1).MustBePositiveOrInfinite().Should().Be(TimeSpan.FromTicks(1)); + TimeSpan.MaxValue.MustBePositiveOrInfinite().Should().Be(TimeSpan.MaxValue); + } + + [Fact] + public static void InfiniteTimeoutIsAcceptedAndReturned() + { + Timeout.InfiniteTimeSpan.MustBePositiveOrInfinite().Should().Be(Timeout.InfiniteTimeSpan); + TimeSpan.FromMilliseconds(-1).MustBePositiveOrInfinite().Should().Be(Timeout.InfiniteTimeSpan); + } + + [Theory] + [MemberData(nameof(InvalidValues))] + public static void ZeroAndNegativeValuesOtherThanInfiniteTimeoutAreRejected(TimeSpan invalidValue) + { + var act = () => invalidValue.MustBePositiveOrInfinite(); + + act.Should().Throw() + .WithParameterName(nameof(invalidValue)) + .WithMessage( + $"*must be positive or equal to Timeout.InfiniteTimeSpan, but it actually is {invalidValue}*" + ); + } + + public static TheoryData InvalidValues => + new () + { + TimeSpan.Zero, + TimeSpan.FromTicks(Timeout.InfiniteTimeSpan.Ticks - 1), + TimeSpan.FromTicks(Timeout.InfiniteTimeSpan.Ticks + 1), + TimeSpan.FromSeconds(-1), + TimeSpan.MinValue, + }; + + [Fact] + public static void DefaultExceptionCapturesExpressionAndValue() + { + var invalidTimeout = TimeSpan.FromTicks(-1); + + var act = () => invalidTimeout.MustBePositiveOrInfinite(); + + act.Should().Throw() + .WithParameterName(nameof(invalidTimeout)) + .WithMessage( + $"*invalidTimeout must be positive or equal to Timeout.InfiniteTimeSpan, but it actually is {invalidTimeout}*" + ); + } + + [Fact] + public static void CustomMessage() => + Test.CustomMessage( + message => TimeSpan.Zero.MustBePositiveOrInfinite(message: message) + ); + + [Fact] + public static void CustomFactoryReceivesOriginalValue() => + Test.CustomException( + TimeSpan.FromTicks(Timeout.InfiniteTimeSpan.Ticks + 1), + (value, factory) => value.MustBePositiveOrInfinite(factory) + ); + + [Fact] + public static void CustomFactoriesAreNotInvokedForAcceptedValues() + { + TimeSpan.FromTicks(1) + .MustBePositiveOrInfinite(_ => FactoryMustNotBeInvoked()) + .Should().Be(TimeSpan.FromTicks(1)); + Timeout.InfiniteTimeSpan + .MustBePositiveOrInfinite(_ => FactoryMustNotBeInvoked()) + .Should().Be(Timeout.InfiniteTimeSpan); + } + + [Fact] + public static void NullFactoryThrowsArgumentNullExceptionForInvalidValue() + { + var act = () => TimeSpan.Zero.MustBePositiveOrInfinite(null!); + + act.Should().Throw().WithParameterName("exceptionFactory"); + } + + private static Exception FactoryMustNotBeInvoked() => + throw new InvalidOperationException("The exception factory must not be invoked for a valid value."); +} diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs index 9c53a87..55f0d82 100644 --- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs +++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs @@ -157,6 +157,8 @@ public sealed class AssertionWhitelist public AssertionEntry MustBePositive { get; init; } = new(); + public AssertionEntry MustBePositiveOrInfinite { get; init; } = new(); + public AssertionEntry MustBeReadable { get; init; } = new(); public AssertionEntry MustBeRelativeUri { get; init; } = new(); diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs index 55ea5f3..12e8acb 100644 --- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs +++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs @@ -76,6 +76,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE using System.Runtime.Serialization; using System.Text; using System.Text.RegularExpressions; +using System.Threading; {(options.TargetFramework == SourceTargetFramework.Net10_0 ? "using System.Buffers.Text;" + Environment.NewLine + "using System.Numerics;" + Environment.NewLine : string.Empty)}{(options.IncludeJetBrainsAnnotationsUsing ? "using JetBrains.Annotations;" + Environment.NewLine : string.Empty)}using {options.BaseNamespace}.Exceptions; using {options.BaseNamespace}.ExceptionFactory; using {options.BaseNamespace}.FrameworkExtensions; diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json index 6bb5dd9..8e0fc27 100644 --- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json +++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json @@ -92,6 +92,7 @@ "MustBeOfType": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeOneOf": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBePositive": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBePositiveOrInfinite": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeReadable": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeRelativeUri": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeSeekable": { "Include": true, "IncludeExceptionFactoryOverload": true },