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
163 changes: 163 additions & 0 deletions Light.GuardClauses.SingleFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
Expand Down Expand Up @@ -4420,6 +4421,53 @@ public static TimeSpan MustBePositive(this TimeSpan parameter, Func<TimeSpan, Ex
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.
/// </summary>
/// <param name = "parameter">The stream 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 = "ArgumentException">Thrown when <paramref name = "parameter"/> does not support reading.</exception>
/// <exception cref = "ArgumentNullException">Thrown when <paramref name = "parameter"/> is null.</exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")]
public static TStream MustBeReadable<TStream>([NotNull, ValidatedNotNull] this TStream? parameter, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null)
where TStream : Stream
{
if (parameter is null)
{
Throw.ArgumentNull(parameterName, message);
}

if (parameter.CanRead == false)
{
Throw.MustBeReadable(parameterName, message);
}

return parameter;
}

/// <summary>
/// Ensures that the specified stream supports reading and returns the original stream, or otherwise throws your
/// custom exception. Validation reads only <see cref = "Stream.CanRead"/> and performs no I/O.
/// </summary>
/// <param name = "parameter">The stream to be checked.</param>
/// <param name = "exceptionFactory">The delegate that creates the exception to be thrown. <paramref name = "parameter"/> is passed to this delegate.</param>
/// <exception cref = "Exception">Your custom exception thrown when <paramref name = "parameter"/> does not support reading, or when <paramref name = "parameter"/> is null.</exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")]
public static TStream MustBeReadable<TStream>([NotNull, ValidatedNotNull] this TStream? parameter, Func<TStream?, Exception> exceptionFactory)
where TStream : Stream
{
if (parameter is null || parameter.CanRead == false)
{
Throw.CustomException(exceptionFactory, parameter);
}

return parameter;
}

/// <summary>
/// Ensures that the specified URI is a relative one, or otherwise throws an <see cref = "AbsoluteUriException"/>.
/// </summary>
Expand Down Expand Up @@ -4458,6 +4506,53 @@ public static Uri MustBeRelativeUri([NotNull][ValidatedNotNull] this Uri? parame
return parameter;
}

/// <summary>
/// Ensures that the specified stream supports seeking and returns the original stream, or otherwise throws an
/// <see cref = "ArgumentException"/>. Validation reads only <see cref = "Stream.CanSeek"/> and performs no I/O.
/// </summary>
/// <param name = "parameter">The stream 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 = "ArgumentException">Thrown when <paramref name = "parameter"/> does not support seeking.</exception>
/// <exception cref = "ArgumentNullException">Thrown when <paramref name = "parameter"/> is null.</exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")]
public static TStream MustBeSeekable<TStream>([NotNull, ValidatedNotNull] this TStream? parameter, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null)
where TStream : Stream
{
if (parameter is null)
{
Throw.ArgumentNull(parameterName, message);
}

if (parameter.CanSeek == false)
{
Throw.MustBeSeekable(parameterName, message);
}

return parameter;
}

/// <summary>
/// Ensures that the specified stream supports seeking and returns the original stream, or otherwise throws your
/// custom exception. Validation reads only <see cref = "Stream.CanSeek"/> and performs no I/O.
/// </summary>
/// <param name = "parameter">The stream to be checked.</param>
/// <param name = "exceptionFactory">The delegate that creates the exception to be thrown. <paramref name = "parameter"/> is passed to this delegate.</param>
/// <exception cref = "Exception">Your custom exception thrown when <paramref name = "parameter"/> does not support seeking, or when <paramref name = "parameter"/> is null.</exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")]
public static TStream MustBeSeekable<TStream>([NotNull, ValidatedNotNull] this TStream? parameter, Func<TStream?, Exception> exceptionFactory)
where TStream : Stream
{
if (parameter is null || parameter.CanSeek == false)
{
Throw.CustomException(exceptionFactory, parameter);
}

return parameter;
}

/// <summary>
/// Ensures that the string is shorter than the specified length, or otherwise throws a <see cref = "StringLengthException"/>.
/// </summary>
Expand Down Expand Up @@ -5259,6 +5354,53 @@ public static T MustBeValidEnumValue<T>(this T parameter, Func<T, Exception> exc
return parameter;
}

/// <summary>
/// Ensures that the specified stream supports writing and returns the original stream, or otherwise throws an
/// <see cref = "ArgumentException"/>. Validation reads only <see cref = "Stream.CanWrite"/> and performs no I/O.
/// </summary>
/// <param name = "parameter">The stream 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 = "ArgumentException">Thrown when <paramref name = "parameter"/> does not support writing.</exception>
/// <exception cref = "ArgumentNullException">Thrown when <paramref name = "parameter"/> is null.</exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")]
public static TStream MustBeWritable<TStream>([NotNull, ValidatedNotNull] this TStream? parameter, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null)
where TStream : Stream
{
if (parameter is null)
{
Throw.ArgumentNull(parameterName, message);
}

if (parameter.CanWrite == false)
{
Throw.MustBeWritable(parameterName, message);
}

return parameter;
}

/// <summary>
/// Ensures that the specified stream supports writing and returns the original stream, or otherwise throws your
/// custom exception. Validation reads only <see cref = "Stream.CanWrite"/> and performs no I/O.
/// </summary>
/// <param name = "parameter">The stream to be checked.</param>
/// <param name = "exceptionFactory">The delegate that creates the exception to be thrown. <paramref name = "parameter"/> is passed to this delegate.</param>
/// <exception cref = "Exception">Your custom exception thrown when <paramref name = "parameter"/> does not support writing, or when <paramref name = "parameter"/> is null.</exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")]
public static TStream MustBeWritable<TStream>([NotNull, ValidatedNotNull] this TStream? parameter, Func<TStream?, Exception> exceptionFactory)
where TStream : Stream
{
if (parameter is null || parameter.CanWrite == false)
{
Throw.CustomException(exceptionFactory, parameter);
}

return parameter;
}

/// <summary>
/// Ensures that the collection contains the specified item, or otherwise throws a <see cref = "MissingItemException"/>.
/// </summary>
Expand Down Expand Up @@ -11676,6 +11818,27 @@ public static void SameObjectReference<T>(T? parameter, [CallerArgumentExpressio
[DoesNotReturn]
public static void SpanMustBeShorterThanOrEqualTo<T>(ReadOnlySpan<T> parameter, int length, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null) => throw new InvalidCollectionCountException(parameterName, message ?? $"{parameterName ?? "The span"} must be shorter than or equal to {length}, but it actually has length {parameter.Length}.");
/// <summary>
/// Throws the default <see cref = "ArgumentException"/> indicating that a stream does not support reading, using
/// the optional parameter name and message.
/// </summary>
[ContractAnnotation("=> halt")]
[DoesNotReturn]
public static void MustBeReadable(string? parameterName = null, string? message = null) => throw new ArgumentException(message ?? $"{parameterName ?? "The stream"} must be readable.", parameterName);
/// <summary>
/// Throws the default <see cref = "ArgumentException"/> indicating that a stream does not support writing, using
/// the optional parameter name and message.
/// </summary>
[ContractAnnotation("=> halt")]
[DoesNotReturn]
public static void MustBeWritable(string? parameterName = null, string? message = null) => throw new ArgumentException(message ?? $"{parameterName ?? "The stream"} must be writable.", parameterName);
/// <summary>
/// Throws the default <see cref = "ArgumentException"/> indicating that a stream does not support seeking, using
/// the optional parameter name and message.
/// </summary>
[ContractAnnotation("=> halt")]
[DoesNotReturn]
public static void MustBeSeekable(string? parameterName = null, string? message = null) => throw new ArgumentException(message ?? $"{parameterName ?? "The stream"} must be seekable.", parameterName);
/// <summary>
/// Throws the default <see cref = "SubstringException"/> indicating that a string does not contain another string as
/// a substring, using the optional parameter name and message.
/// </summary>
Expand Down
45 changes: 45 additions & 0 deletions ai-plans/0156-stream-guards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Stream Guards

## Rationale

Streams are commonly passed between upload, export, and document-generation layers, but an unsupported read, write, or seek operation may fail only after the stream has traveled far from the call site. Add dedicated `MustBeReadable`, `MustBeWritable`, and `MustBeSeekable` guards so callers can state these capability requirements at API boundaries.

The guards must be thin, allocation-free checks of the corresponding `Stream` capability properties, preserve the fluent API and concrete stream type, and participate in the customizable single-file source distribution.

## Acceptance Criteria

- [x] `MustBeReadable`, `MustBeWritable`, and `MustBeSeekable` are available for `Stream` instances and subclasses on .NET Standard 2.0, .NET Standard 2.1, and .NET 10 with identical semantics.
- [x] Each guard accepts a non-null stream exactly when its corresponding `CanRead`, `CanWrite`, or `CanSeek` property returns `true`; it does not inspect other capabilities, probe the stream with an I/O operation, change its position, or otherwise mutate it.
- [x] A null stream throws `ArgumentNullException` by default, while a stream lacking the required capability throws `ArgumentException` with the captured guarded expression and a capability-specific message; no new public exception type is introduced.
- [x] Every guard returns the original successfully validated instance in its concrete stream shape, accepts an optional custom message, and provides a custom-exception-factory overload consistent with the existing nullable reference APIs.
- [x] Successful default and custom-factory guards perform no heap allocation.
- [x] Automated tests cover all supported and unsupported capability states, null values, default exception contracts, custom messages, custom exception factories, caller argument expressions, preservation of the original instance and concrete type, and confirmation that each guard reads only its matching capability property.
- [x] The source-export whitelist catalog and committed settings contain all three assertion families, and focused source-export tests cover their guard, throw-helper, and exception-factory reachability.
- [x] The committed .NET Standard 2.0 single-file distribution is regenerated with the new stream guards, and generated source validates for both .NET Standard 2.0 and .NET 10.
- [x] XML documentation and the assertion overview describe the three guards, their null and failure behavior, fluent return values, and property-only validation semantics.
- [x] The complete solution restores and builds without warnings in Release configuration, and all automated tests pass on the pinned SDK.

## Technical Details

Implement one `Check.<Assertion>.cs` file per guard family. Use a generic stream subtype parameter so fluent chains retain types such as `FileStream` and `MemoryStream`. The following signatures illustrate the shared public shape; the writable and seekable families mirror it:

```csharp
public static TStream MustBeReadable<TStream>(
[NotNull, ValidatedNotNull] this TStream? parameter,
[CallerArgumentExpression("parameter")] string? parameterName = null,
string? message = null
) where TStream : Stream;

public static TStream MustBeReadable<TStream>(
[NotNull, ValidatedNotNull] this TStream? parameter,
Func<TStream?, Exception> exceptionFactory
) where TStream : Stream;
```

Apply the repository's established nullable-flow and JetBrains contract annotations. Default overloads should follow the `MustBeAbsoluteUri` null-check pattern: validate null first, inspect the relevant property once, and return the same reference. Factory overloads invoke the factory for either null or capability failure and pass the original nullable stream value to it.

The hot success path is only a null check, one virtual property read, one conditional branch, and the return. Keep it aggressively inlineable and route default failure construction through non-returning `Throw` helpers. Those helpers should create `ArgumentException` messages that name the missing capability without formatting the stream or calling its `ToString`; failures must not attempt an operation to discover why a capability is unavailable. Boolean `IsReadable`, `IsWritable`, and `IsSeekable` predicates are out of scope because `CanRead`, `CanWrite`, and `CanSeek` already expose the predicates directly.

Use small hand-written `Stream` test doubles to represent independent capability combinations and to record property access; do not assume that capabilities imply one another. Include disposed framework streams as representative property-defined failures, but preserve the `Stream` implementation's own behavior if a custom capability getter throws.

Add `MustBeReadable`, `MustBeWritable`, and `MustBeSeekable` to `AssertionWhitelist` and `settings.json`, cover trimming/reachability of their default and factory paths, regenerate `Light.GuardClauses.SingleFile.cs` as the .NET Standard 2.0 artifact, and add a stream assertion section to `docs/assertion-overview.md`.
13 changes: 13 additions & 0 deletions docs/assertion-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,19 @@ Base64 inspection validates the standard `A`-`Z`, `a`-`z`, `0`-`9`, `+`, and `/`
| `MustBeLocal` | Requires `DateTimeKind.Local` |
| `MustBeUnspecified` | Requires `DateTimeKind.Unspecified` |

## Stream assertions

| Assertion | Behavior |
| --- | --- |
| `MustBeReadable` | Requires a non-null stream whose `CanRead` property is `true` |
| `MustBeWritable` | Requires a non-null stream whose `CanWrite` property is `true` |
| `MustBeSeekable` | Requires a non-null stream whose `CanSeek` property is `true` |

Each stream guard reads only its matching capability property: it performs no I/O, does not inspect the other
capabilities, and does not change the stream position or any other state. A null stream throws
`ArgumentNullException`; a missing capability throws `ArgumentException`. Successful guards return the same instance
while preserving its concrete stream type, and all three guards support custom messages and exception factories.

## Type-relation assertions

| Assertion | Behavior |
Expand Down
Loading
Loading