diff --git a/Directory.Build.props b/Directory.Build.props index 7567f66..67b0cf3 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 15.0.0 + 15.1.0 14 Kenny Pflug Kenny Pflug diff --git a/Light.GuardClauses.SingleFile.cs b/Light.GuardClauses.SingleFile.cs index 05890bf..031f93b 100644 --- a/Light.GuardClauses.SingleFile.cs +++ b/Light.GuardClauses.SingleFile.cs @@ -10134,6 +10134,55 @@ public static string MustStartWith([NotNull, ValidatedNotNull] this string? para return parameter; } + + /// + /// Checks if the specified is true and throws an in this case. + /// + /// The condition to be checked. The exception is thrown when it is true. + /// The name of the disposed object (optional). + /// The message that will be passed to the (optional). + /// Thrown when is true. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ObjectDisposed(bool condition, string? objectName = null, string? message = null) + { + if (condition) + { + Throw.ObjectDisposed(objectName, message); + } + } + + /// + /// Checks if the specified is true and throws your custom exception in this case. + /// + /// The condition to be checked. The exception is thrown when it is true. + /// The delegate that creates your custom exception. + /// Your custom exception thrown when is true. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("exceptionFactory:null => halt")] + public static void ObjectDisposed(bool condition, Func exceptionFactory) + { + if (condition) + { + Throw.CustomException(exceptionFactory); + } + } + + /// + /// Checks if the specified is true and throws your custom exception in this case. + /// + /// The condition to be checked. The exception is thrown when it is true. + /// The value that is checked in the . This value is passed to the . + /// The delegate that creates your custom exception. The is passed to this delegate. + /// Your custom exception thrown when is true. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("exceptionFactory:null => halt")] + public static void ObjectDisposed(bool condition, T parameter, Func exceptionFactory) + { + if (condition) + { + Throw.CustomException(exceptionFactory, parameter); + } + } } /// @@ -11839,6 +11888,12 @@ public static void MustNotBeLessThanOrEqualTo(T parameter, T boundary, [Calle [DoesNotReturn] public static void NullableHasNoValue(string? parameterName = null, string? message = null) => throw new NullableHasNoValueException(parameterName, message ?? $"{parameterName ?? "The nullable"} must have a value, but it actually is null."); /// + /// Throws an using the optional object name and message. + /// + [ContractAnnotation("=> halt")] + [DoesNotReturn] + public static void ObjectDisposed(string? objectName = null, string? message = null) => throw new ObjectDisposedException(objectName, message); + /// /// Throws the default indicating that a value is not within a specified /// range, using the optional parameter name and message. /// diff --git a/ai-plans/0163-object-disposed.md b/ai-plans/0163-object-disposed.md new file mode 100644 index 0000000..bcebe2b --- /dev/null +++ b/ai-plans/0163-object-disposed.md @@ -0,0 +1,33 @@ +# Object-Disposed Condition Guard + +## Rationale + +Parent issue #162 identifies disposal guards as a recurring need (five call sites in BrilliantMessaging alone) that currently must be expressed as a generic boolean assertion with an exception factory, obscuring intent. Add `Check.ObjectDisposed` as a condition guard analogous to `Check.InvalidArgument`/`Check.InvalidOperation` that throws `ObjectDisposedException` when the condition is true, so disposal checks read explicitly and remain usable on `netstandard2.0`. + +## Acceptance Criteria + +- [x] `Check.ObjectDisposed(condition, objectName, message)` throws `ObjectDisposedException` only when `condition` is true, identically on .NET Standard 2.0, .NET Standard 2.1, and .NET 10; the supplied object name is exposed via `ObjectName`, the optional message via `Message`, and omitted arguments fall back to the BCL default message. +- [x] Two custom-exception-factory overloads follow the `InvalidArgument` convention (plain `Func` and generic `Func` receiving a caller-supplied parameter); they throw the factory's exception only when the condition is true, never invoke the factory otherwise, and a null factory on a true condition throws `ArgumentNullException` via the existing `Throw.CustomException` convention. +- [x] Automated tests cover: condition true/false, object-name and message propagation, the BCL default message, both factory overloads (including concrete custom exception instances and the passed parameter), factories not invoked on a false condition, and null-factory behavior. +- [x] The source-export whitelist catalog and committed settings contain `ObjectDisposed`, and focused source-export tests cover retention of the guard and its `Throw.ObjectDisposed` helper as well as trimming of the exception-factory overloads when configured. +- [x] The committed .NET Standard 2.0 single-file distribution is regenerated with the new guard and validates for both supported source-export targets. +- [x] The "Condition and state assertions" table in `docs/assertion-overview.md` lists `ObjectDisposed`, and the 15.0.0 package release notes in `src/Directory.Build.props` mention the new guard. +- [x] The complete solution restores and builds without warnings in Release configuration, and all automated tests pass on the pinned SDK. + +## Technical Details + +Add the guard as a new `Check.ObjectDisposed.cs` modeled directly on `Check.InvalidArgument.cs` (aggressive inlining, JetBrains contract annotations, XML documentation style), plus a non-returning `Throw.ObjectDisposed` helper in `ExceptionFactory/Throw.ObjectDisposed.cs` modeled on `Throw.InvalidOperation.cs` (`[ContractAnnotation("=> halt")]`, `[DoesNotReturn]`) whose single expression throws `new ObjectDisposedException(objectName, message)`. The exact public shape is: + +```csharp +public static void ObjectDisposed(bool condition, string? objectName = null, string? message = null); + +public static void ObjectDisposed(bool condition, Func exceptionFactory); + +public static void ObjectDisposed(bool condition, T parameter, Func exceptionFactory); +``` + +The generic factory overload mirrors `InvalidArgument`'s `Func` form: callers pass arbitrary failure context (typically the disposed object or its name) without closure allocation. No caller-argument-expression capture applies because condition guards take a pre-evaluated `bool`, matching the other condition checks. + +`ObjectDisposedException(string, string)` exists on all library targets and supplies the "Cannot access a disposed object." default when the message is null, so no custom exception type or message composition is introduced. No microbenchmarks: the guard is a trivial inlined condition check like `InvalidOperation`. + +Append an `ObjectDisposed` entry to `AssertionWhitelist` and `settings.json` (after `MustStartWith`, preserving alphabetical position), add focused facts to `SourceFileMergerWhitelistTests` for dependency retention and factory-overload trimming, add `ObjectDisposedTests` under `tests/Light.GuardClauses.Tests/CommonAssertions/` following `InvalidOperationTests`/`InvalidArgumentTests` conventions (FluentAssertions, `DefaultVariablesData` where useful), and regenerate `Light.GuardClauses.SingleFile.cs` via the source-export project's committed settings. diff --git a/docs/assertion-overview.md b/docs/assertion-overview.md index 1e6d0e2..f654545 100644 --- a/docs/assertion-overview.md +++ b/docs/assertion-overview.md @@ -4,7 +4,7 @@ This page groups the current public assertion families defined by the `Check.*.cs` files. Exact overloads, generic constraints, return annotations, and exception contracts are documented in the source XML comments and appear in IntelliSense. -Names beginning with `Must` validate or throw and return the successfully validated value. `InvalidArgument`, `InvalidOperation`, and `InvalidState` are throwing condition checks. Other names return `bool`. Most throwing families offer a default-exception overload and an exception-factory overload; see [Structuring precondition checks](structuring-precondition-checks.md). +Names beginning with `Must` validate or throw and return the successfully validated value. `InvalidArgument`, `InvalidOperation`, `InvalidState`, and `ObjectDisposed` are throwing condition checks. Other names return `bool`. Most throwing families offer a default-exception overload and an exception-factory overload; see [Structuring precondition checks](structuring-precondition-checks.md). ## Target-specific API @@ -52,6 +52,7 @@ UUIDv7 validation checks the version-7 nibble and RFC/IETF `10xx` variant bits d | `InvalidArgument` | Throws `ArgumentException` when the condition is true | | `InvalidOperation` | Throws `InvalidOperationException` when the condition is true | | `InvalidState` | Throws `InvalidStateException` when the condition is true | +| `ObjectDisposed` | Throws `ObjectDisposedException` when the condition is true | ## Comparable, range, and approximate assertions diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 2210502..c10db19 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -17,18 +17,10 @@ true README.md - Light.GuardClauses 15.0.0 + Light.GuardClauses 15.1.0 -------------------------------- - - new assertions: IsUuidVersion7, MustBeUuidVersion7, IsFinite, MustBeFinite, IsAscii, MustBeAscii, and MustHaveCountIn - - new numeric sign guards: MustBePositive, MustBeNegative, MustNotBePositive, MustNotBeNegative, and MustNotBeZero - - new dictionary key guards: MustContainKey and MustNotContainKey - - new collection content guards: MustNotContainNull and MustNotContainNullOrWhiteSpace - - new string inspection assertions: ContainsOnlyDigits, MustContainOnlyDigits, ContainsOnlyLettersOrDigits, MustContainOnlyLettersOrDigits, IsUpperCase, MustBeUpperCase, IsLowerCase, MustBeLowerCase, IsBase64, MustBeBase64, IsHexadecimal, MustBeHexadecimal, and MustNotContainWhiteSpace - - new stream capability guards: MustBeReadable, MustBeWritable, and MustBeSeekable - - new collection count guard: MustHaveSameCountAs - - expanded span and memory support for MustNotBeEmpty, MustHaveLength, MustHaveLengthIn, and MustNotBeEmptyOrWhiteSpace, plus DateTimeOffset support for MustBeUtc - - breaking: the modern package target was updated from .NET 8 to .NET 10; .NET Standard 2.0 and 2.1 remain supported + - new assertions: ObjectDisposed diff --git a/src/Light.GuardClauses/Check.ObjectDisposed.cs b/src/Light.GuardClauses/Check.ObjectDisposed.cs new file mode 100644 index 0000000..fe9e271 --- /dev/null +++ b/src/Light.GuardClauses/Check.ObjectDisposed.cs @@ -0,0 +1,58 @@ +using System; +using System.Runtime.CompilerServices; +using JetBrains.Annotations; +using Light.GuardClauses.ExceptionFactory; + +namespace Light.GuardClauses; + +public static partial class Check +{ + /// + /// Checks if the specified is true and throws an in this case. + /// + /// The condition to be checked. The exception is thrown when it is true. + /// The name of the disposed object (optional). + /// The message that will be passed to the (optional). + /// Thrown when is true. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ObjectDisposed(bool condition, string? objectName = null, string? message = null) + { + if (condition) + { + Throw.ObjectDisposed(objectName, message); + } + } + + /// + /// Checks if the specified is true and throws your custom exception in this case. + /// + /// The condition to be checked. The exception is thrown when it is true. + /// The delegate that creates your custom exception. + /// Your custom exception thrown when is true. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("exceptionFactory:null => halt")] + public static void ObjectDisposed(bool condition, Func exceptionFactory) + { + if (condition) + { + Throw.CustomException(exceptionFactory); + } + } + + /// + /// Checks if the specified is true and throws your custom exception in this case. + /// + /// The condition to be checked. The exception is thrown when it is true. + /// The value that is checked in the . This value is passed to the . + /// The delegate that creates your custom exception. The is passed to this delegate. + /// Your custom exception thrown when is true. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("exceptionFactory:null => halt")] + public static void ObjectDisposed(bool condition, T parameter, Func exceptionFactory) + { + if (condition) + { + Throw.CustomException(exceptionFactory, parameter); + } + } +} diff --git a/src/Light.GuardClauses/ExceptionFactory/Throw.ObjectDisposed.cs b/src/Light.GuardClauses/ExceptionFactory/Throw.ObjectDisposed.cs new file mode 100644 index 0000000..a82b917 --- /dev/null +++ b/src/Light.GuardClauses/ExceptionFactory/Throw.ObjectDisposed.cs @@ -0,0 +1,16 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using JetBrains.Annotations; + +namespace Light.GuardClauses.ExceptionFactory; + +public static partial class Throw +{ + /// + /// Throws an using the optional object name and message. + /// + [ContractAnnotation("=> halt")] + [DoesNotReturn] + public static void ObjectDisposed(string? objectName = null, string? message = null) => + throw new ObjectDisposedException(objectName, message); +} diff --git a/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs b/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs index db0783d..8dd30cb 100644 --- a/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs +++ b/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs @@ -504,6 +504,51 @@ public static void StreamGuardWhitelistTrimsFactoryAndUnrelatedStreamThrowHelper sourceCode.Should().NotContain("public static void MustBeSeekable("); } + [Fact] + public static void ObjectDisposedWhitelistExportsGuardThrowHelperAndExceptionFactories() + { + using var temporaryDirectory = new TemporaryDirectory(); + var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "ObjectDisposed.cs"); + + SourceFileMerger.CreateSingleSourceFile( + CreateOptions( + targetFile, + CreateWhitelist( + includedAssertions: [new ("ObjectDisposed", true)] + ) + ) + ); + var sourceCode = File.ReadAllText(targetFile); + + sourceCode.Should().Contain("public static void ObjectDisposed(bool condition, string? objectName = null, string? message = null)"); + sourceCode.Should().Contain("public static void ObjectDisposed(bool condition, Func exceptionFactory)"); + sourceCode.Should().Contain("public static void ObjectDisposed(bool condition, T parameter, Func exceptionFactory)"); + sourceCode.Should().Contain("public static void ObjectDisposed(string? objectName = null, string? message = null) => throw new ObjectDisposedException(objectName, message);"); + sourceCode.Should().Contain("public static void CustomException(Func exceptionFactory)"); + sourceCode.Should().NotContain("public static void InvalidOperation("); + } + + [Fact] + public static void ObjectDisposedWhitelistTrimsExceptionFactoryOverloads() + { + using var temporaryDirectory = new TemporaryDirectory(); + var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "ObjectDisposedWithoutFactories.cs"); + + SourceFileMerger.CreateSingleSourceFile( + CreateOptions( + targetFile, + CreateWhitelist(includedAssertions: [new ("ObjectDisposed", false)]) + ) + ); + var sourceCode = File.ReadAllText(targetFile); + + sourceCode.Should().Contain("public static void ObjectDisposed(bool condition, string? objectName = null, string? message = null)"); + sourceCode.Should().Contain("public static void ObjectDisposed(string? objectName = null, string? message = null) => throw new ObjectDisposedException(objectName, message);"); + sourceCode.Should().NotContain("Func exceptionFactory"); + sourceCode.Should().NotContain("ObjectDisposed"); + sourceCode.Should().NotContain("public static void CustomException("); + } + private static SourceFileMergeOptions CreateOptions( string targetFile, AssertionWhitelist assertionWhitelist = null, diff --git a/tests/Light.GuardClauses.Tests/CommonAssertions/ObjectDisposedTests.cs b/tests/Light.GuardClauses.Tests/CommonAssertions/ObjectDisposedTests.cs new file mode 100644 index 0000000..0c0d27b --- /dev/null +++ b/tests/Light.GuardClauses.Tests/CommonAssertions/ObjectDisposedTests.cs @@ -0,0 +1,87 @@ +using System; +using FluentAssertions; +using Xunit; + +namespace Light.GuardClauses.Tests.CommonAssertions; + +public static class ObjectDisposedTests +{ + [Theory] + [DefaultVariablesData] + public static void ConditionTrue(string objectName) + { + var act = () => Check.ObjectDisposed(true, objectName); + + var exceptionAssertion = act.Should().Throw().And; + exceptionAssertion.ObjectName.Should().BeSameAs(objectName); + exceptionAssertion.Message.Should().Contain($"Object name: '{objectName}'."); + } + + [Theory] + [DefaultVariablesData] + public static void CustomMessagePropagated(string objectName) + { + const string message = "The connection has already been closed."; + + var act = () => Check.ObjectDisposed(true, objectName, message); + + var exceptionAssertion = act.Should().Throw().And; + exceptionAssertion.ObjectName.Should().BeSameAs(objectName); + exceptionAssertion.Message.Should().Contain(message); + exceptionAssertion.Message.Should().Contain($"Object name: '{objectName}'."); + } + + [Fact] + public static void ConditionFalse() + { + Check.ObjectDisposed(false); + } + + [Fact] + public static void ConditionFalseCustomException() + { + Check.ObjectDisposed(false, () => new Exception()); + } + + [Fact] + public static void ConditionFalseCustomExceptionWithParameter() + { + Check.ObjectDisposed(false, new object(), _ => new Exception()); + } + + [Fact] + public static void CustomException() => + Test.CustomException(exceptionFactory => Check.ObjectDisposed(true, exceptionFactory)); + + [Fact] + public static void CustomExceptionWithParameter() => + Test.CustomException(new object(), + (parameter, exceptionFactory) => Check.ObjectDisposed(true, parameter, exceptionFactory)); + + [Fact] + public static void NullFactory() + { + var act = () => Check.ObjectDisposed(true, (Func) null!); + + act.Should().Throw() + .And.ParamName.Should().Be("exceptionFactory"); + } + + [Fact] + public static void NullFactoryWithParameter() + { + var act = () => Check.ObjectDisposed(true, new object(), (Func) null!); + + act.Should().Throw() + .And.ParamName.Should().Be("exceptionFactory"); + } + + [Fact] + public static void DefaultMessage() + { + var act = () => Check.ObjectDisposed(true); + + act.Should().Throw() + .And.Message.Should().Be("Cannot access a disposed object."); + } +} diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs index ce70ed6..107cf52 100644 --- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs +++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs @@ -277,6 +277,8 @@ public sealed class AssertionWhitelist public AssertionEntry MustStartWith { get; init; } = new(); + public AssertionEntry ObjectDisposed { get; init; } = new(); + internal IReadOnlyDictionary GetEntriesByAssertionName() => GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public) .Where(property => property.PropertyType == typeof(AssertionEntry)) diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json index 81c8f17..e769b2f 100644 --- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json +++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json @@ -151,6 +151,7 @@ "MustNotContainWhiteSpace": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustNotEndWith": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustNotStartWith": { "Include": true, "IncludeExceptionFactoryOverload": true }, - "MustStartWith": { "Include": true, "IncludeExceptionFactoryOverload": true } + "MustStartWith": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "ObjectDisposed": { "Include": true, "IncludeExceptionFactoryOverload": true } } }