Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
80b1a53
Add ToMemoryStream and ToMemoryStreamAsync extensions with tests
Copilot Jul 20, 2026
9319ccf
Fix test coverage and clarify docs for ToMemoryStream fast-path
Copilot Jul 20, 2026
c066a9c
Address code review feedback: remarks section and explicit CopyToAsyn…
Copilot Jul 20, 2026
107ffcd
Re-architect stream materialization as a lazy MemoryBackedStream wrapper
Copilot Jul 20, 2026
98c2118
Address code review: clamp position, document Flush no-op, truncate o…
Copilot Jul 20, 2026
fa8f4c4
Merge remote-tracking branch 'origin/prime' into copilot/add-to-memor…
Copilot Jul 20, 2026
e0a459b
Simplify EnsureBuffer, delegate Flush, replace stream wrappers with f…
Copilot Jul 21, 2026
e2caac0
Merge branch 'prime' into copilot/add-to-memory-stream-extension
Tyrrrz Jul 22, 2026
c1451ea
Make MemoryBackedStream a public class instead of an extension method
Copilot Jul 22, 2026
974d6e6
Retain source's initial position in buffer when buffering a seekable …
Copilot Jul 22, 2026
16270b3
Treat all source streams as unseekable in MemoryBackedStream
Copilot Jul 23, 2026
1b4499e
Document trailing-data behavior and add write-back position test
Copilot Jul 23, 2026
4f0c450
Split MemoryBackedStream into MemoryReadStream and MemoryWriteStream
Copilot Jul 23, 2026
1c0883f
MemoryWriteStream: flush to source on Flush() instead of Dispose()
Copilot Jul 23, 2026
a064ed6
Consolidate stream tests to one focused test each
Copilot Jul 23, 2026
3804c2e
Fix MemoryReadStream test to use a genuinely non-seekable source
Copilot Jul 23, 2026
4dc8071
Extract NonSeekableStream as shared test utility; use in both read an…
Copilot Jul 23, 2026
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
102 changes: 102 additions & 0 deletions PowerKit.Tests/Extensions/StreamExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,108 @@ namespace PowerKit.Tests.Extensions;

public class StreamExtensionsTests
{
[Fact]
public void ToMemoryStream_ReadableFile_ReadsCorrectly_Test()
{
// Arrange
var data = new byte[] { 1, 2, 3, 4, 5 };
using var tempFile = TempFile.Create();
File.WriteAllBytes(tempFile.Path, data);
using var source = File.OpenRead(tempFile.Path);

// Act
using var result = source.ToMemoryStream();

// Assert
result.CanRead.Should().BeTrue();
var buffer = new byte[data.Length];
result.ReadExactly(buffer);
buffer.Should().Equal(data);
}

[Fact]
public void ToMemoryStream_ReadableFile_IsSeekable_Test()
{
// Arrange
var data = new byte[] { 1, 2, 3, 4, 5 };
using var tempFile = TempFile.Create();
File.WriteAllBytes(tempFile.Path, data);
using var source = File.OpenRead(tempFile.Path);

// Act
using var result = source.ToMemoryStream();

// Assert — wrapper is seekable
result.CanSeek.Should().BeTrue();

var partial = new byte[2];
result.ReadExactly(partial);

result.Seek(0, SeekOrigin.Begin);

var full = new byte[data.Length];
result.ReadExactly(full);
full.Should().Equal(data);
}

[Fact]
public void ToMemoryStream_MemoryStream_ReturnsNoOp_Test()
{
// Arrange
var data = new byte[] { 1, 2, 3, 4, 5 };
using var source = new MemoryStream(data);

// Act
var result = source.ToMemoryStream();

// Assert — MemoryStream source is returned as-is
result.Should().BeSameAs(source);
}

[Fact]
public void ToMemoryStream_WritableFile_WriteBackOnDispose_Test()
{
// Arrange
var data = new byte[] { 1, 2, 3, 4, 5 };
using var tempFile = TempFile.Create();

// Act — writes go to the in-memory buffer; dispose flushes them to the file
using (var source = File.OpenWrite(tempFile.Path))
using (var wrapper = source.ToMemoryStream())
{
wrapper.Write(data, 0, data.Length);
}

// Assert
File.ReadAllBytes(tempFile.Path).Should().Equal(data);
}

[Fact]
public void ToMemoryStream_ReadWriteFile_LoadsFullStreamAndWritesBack_Test()
{
// Arrange
var initial = new byte[] { 1, 2, 3, 4, 5 };
using var tempFile = TempFile.Create();
File.WriteAllBytes(tempFile.Path, initial);

// Act
using (var source = File.Open(tempFile.Path, FileMode.Open, FileAccess.ReadWrite))
{
using (var wrapper = source.ToMemoryStream())
{
// Full stream is loaded from position 0
wrapper.Length.Should().Be(5);

// Overwrite the first three bytes
wrapper.Position = 0;
wrapper.Write(new byte[] { 10, 20, 30 }, 0, 3);
} // dispose writes the entire buffer back to source from position 0
} // source FileStream is flushed and closed

// Assert
File.ReadAllBytes(tempFile.Path).Should().Equal(new byte[] { 10, 20, 30, 4, 5 });
}

[Fact]
public async Task CopyToAsync_AutoFlush_Test()
{
Expand Down
111 changes: 111 additions & 0 deletions PowerKit/Extensions/StreamExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,86 @@

namespace PowerKit.Extensions;

file sealed class MemoryBackedStream(Stream source) : Stream
{
private MemoryStream? _buffer;

private MemoryStream EnsureBuffer()
{
if (_buffer is not null)
return _buffer;

_buffer = new MemoryStream();

if (source.CanRead)
{
if (source.CanSeek)
source.Seek(0, SeekOrigin.Begin);

source.CopyTo(_buffer);
_buffer.Position = 0;
}

return _buffer;
}

public override bool CanRead => source.CanRead;
public override bool CanSeek => true;
public override bool CanWrite => source.CanWrite;

public override long Length => EnsureBuffer().Length;

public override long Position
{
get => EnsureBuffer().Position;
set => EnsureBuffer().Position = value;
}

public override void Flush() => _buffer?.Flush();

public override int Read(byte[] buffer, int offset, int count)
{
if (!source.CanRead)
throw new NotSupportedException("Stream does not support reading.");

return EnsureBuffer().Read(buffer, offset, count);
}

public override long Seek(long offset, SeekOrigin origin) =>
EnsureBuffer().Seek(offset, origin);

public override void SetLength(long value) => EnsureBuffer().SetLength(value);

public override void Write(byte[] buffer, int offset, int count)
{
if (!source.CanWrite)
throw new NotSupportedException("Stream does not support writing.");

EnsureBuffer().Write(buffer, offset, count);
}

protected override void Dispose(bool disposing)
{
if (disposing && _buffer is not null && source.CanWrite)
{
_buffer.Position = 0;

// If the full stream was loaded (readable + seekable), truncate the source
// to the buffer's length and seek to the beginning so the write-back
// completely replaces the original content without leaving trailing data.
if (source.CanRead && source.CanSeek)
{
source.SetLength(_buffer.Length);
source.Seek(0, SeekOrigin.Begin);
}

_buffer.CopyTo(source);
}

base.Dispose(disposing);
}
}

/// <summary>
/// Extensions for <see cref="Stream" />.
/// </summary>
Expand All @@ -23,6 +103,37 @@ public static class StreamExtensions
/// </summary>
public StreamPortal CreatePortal() => source.CreatePortal(source.Position);

/// <summary>
/// Returns a <see cref="Stream" /> backed by a <see cref="MemoryStream" />.
/// </summary>
Comment thread
Tyrrrz marked this conversation as resolved.
/// <remarks>
/// <para>
/// The first read or write lazily loads the underlying stream into memory.
/// Subsequent reads and writes operate directly against the in-memory buffer,
/// making the returned stream always seekable.
/// </para>
/// <para>
/// On readable and seekable streams, the entire content is loaded from the
/// beginning. On non-seekable readable streams, content is loaded from the
/// current position. In both cases the buffer position starts at 0.
/// </para>
/// <para>
/// Writes go to the in-memory buffer. When the wrapper is disposed, the buffer
/// is written back to the underlying stream. On readable and seekable streams
/// the underlying stream is seeked to the beginning before the write-back.
/// </para>
/// <para>
/// If the stream is already a <see cref="MemoryStream" />, it is returned as-is.
/// </para>
/// </remarks>
public Stream ToMemoryStream()
{
if (source is MemoryStream)
return source;

return new MemoryBackedStream(source);
}

#if NET40_OR_GREATER || NETSTANDARD || NET
/// <summary>
/// Copies the contents of the stream to the destination stream, optionally flushing after each write.
Expand Down