diff --git a/PowerKit.Tests/MemoryReadStreamTests.cs b/PowerKit.Tests/MemoryReadStreamTests.cs new file mode 100644 index 0000000..de6cc4b --- /dev/null +++ b/PowerKit.Tests/MemoryReadStreamTests.cs @@ -0,0 +1,32 @@ +using System.IO; +using FluentAssertions; +using Xunit; + +namespace PowerKit.Tests; + +public class MemoryReadStreamTests +{ + [Fact] + public void MemoryReadStream_MakesUnseekableStreamSeekable_Test() + { + // Arrange — wrap a MemoryStream in a non-seekable facade + var data = new byte[] { 1, 2, 3, 4, 5 }; + using var source = new NonSeekableStream(new MemoryStream(data)); + source.CanSeek.Should().BeFalse(); + + // Act + using var result = new MemoryReadStream(source); + + // Assert — wrapper is always seekable and can re-read from the beginning + 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); + } +} diff --git a/PowerKit.Tests/MemoryWriteStreamTests.cs b/PowerKit.Tests/MemoryWriteStreamTests.cs new file mode 100644 index 0000000..f866180 --- /dev/null +++ b/PowerKit.Tests/MemoryWriteStreamTests.cs @@ -0,0 +1,32 @@ +using System.IO; +using FluentAssertions; +using Xunit; + +namespace PowerKit.Tests; + +public class MemoryWriteStreamTests +{ + [Fact] + public void MemoryWriteStream_MakesUnseekableStreamSeekable_Test() + { + // Arrange — wrap a MemoryStream in a non-seekable facade + var data = new byte[] { 1, 2, 3, 4, 5 }; + var inner = new MemoryStream(); + using var source = new NonSeekableStream(inner); + source.CanSeek.Should().BeFalse(); + + // Act — wrapper buffers writes in memory; Flush() commits them to the source + using var wrapper = new MemoryWriteStream(source); + wrapper.CanSeek.Should().BeTrue(); + + // Write all bytes then seek back and overwrite the first two + wrapper.Write(data, 0, data.Length); + wrapper.Seek(0, SeekOrigin.Begin); + wrapper.Write(new byte[] { 10, 20 }, 0, 2); + + wrapper.Flush(); + + // Assert — overwritten prefix is reflected in the underlying stream + inner.ToArray().Should().Equal(new byte[] { 10, 20, 3, 4, 5 }); + } +} diff --git a/PowerKit.Tests/NonSeekableStream.cs b/PowerKit.Tests/NonSeekableStream.cs new file mode 100644 index 0000000..78f9d12 --- /dev/null +++ b/PowerKit.Tests/NonSeekableStream.cs @@ -0,0 +1,36 @@ +using System; +using System.IO; + +namespace PowerKit.Tests; + +internal sealed class NonSeekableStream(Stream inner) : Stream +{ + public override bool CanRead => inner.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => inner.CanWrite; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => inner.Flush(); + + public override int Read(byte[] buffer, int offset, int count) => + inner.Read(buffer, offset, count); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => + inner.Write(buffer, offset, count); + + protected override void Dispose(bool disposing) + { + if (disposing) + inner.Dispose(); + base.Dispose(disposing); + } +} diff --git a/PowerKit/MemoryReadStream.cs b/PowerKit/MemoryReadStream.cs new file mode 100644 index 0000000..f2ebbde --- /dev/null +++ b/PowerKit/MemoryReadStream.cs @@ -0,0 +1,74 @@ +using System.IO; + +namespace PowerKit; + +/// +/// A wrapper that lazily loads a readable stream into an in-memory buffer, +/// making it fully seekable regardless of whether the underlying stream supports seeking. +/// +/// +/// +/// The underlying stream is read into memory on the first access to +/// , , , or . +/// Subsequent operations work directly against the in-memory buffer. +/// +/// +/// Content is buffered from the source stream's current position when the buffer is first +/// initialized. The buffer position always starts at 0. +/// +/// +public sealed class MemoryReadStream(Stream source) : Stream +{ + private MemoryStream? _buffer; + + private MemoryStream EnsureBuffer() + { + if (_buffer is not null) + return _buffer; + + var capacity = source.CanSeek ? (int)(source.Length - source.Position) : 0; + _buffer = new MemoryStream(capacity); + source.CopyTo(_buffer); + _buffer.Position = 0; + + return _buffer; + } + + /// + public override bool CanRead => true; + + /// + public override bool CanSeek => true; + + /// + public override bool CanWrite => false; + + /// + 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) => + EnsureBuffer().Read(buffer, offset, count); + + /// + public override long Seek(long offset, SeekOrigin origin) => + EnsureBuffer().Seek(offset, origin); + + /// + public override void SetLength(long value) => + throw new System.NotSupportedException("Stream does not support writing."); + + /// + public override void Write(byte[] buffer, int offset, int count) => + throw new System.NotSupportedException("Stream does not support writing."); +} diff --git a/PowerKit/MemoryWriteStream.cs b/PowerKit/MemoryWriteStream.cs new file mode 100644 index 0000000..134991d --- /dev/null +++ b/PowerKit/MemoryWriteStream.cs @@ -0,0 +1,64 @@ +using System.IO; + +namespace PowerKit; + +/// +/// A wrapper that buffers all writes in memory and flushes them to the +/// underlying stream on . +/// +/// +/// +/// Writes go to an in-memory buffer and do not touch the underlying stream until +/// is called. This makes the wrapper always seekable and allows writes to +/// be reordered freely before the final flush. +/// +/// +/// On , the entire in-memory buffer is written to the underlying stream +/// starting at its current position. +/// +/// +public sealed class MemoryWriteStream(Stream source) : Stream +{ + private readonly MemoryStream _buffer = new(); + + /// + public override bool CanRead => false; + + /// + public override bool CanSeek => true; + + /// + public override bool CanWrite => true; + + /// + public override long Length => _buffer.Length; + + /// + public override long Position + { + get => _buffer.Position; + set => _buffer.Position = value; + } + + /// + public override void Flush() + { + _buffer.Position = 0; + _buffer.CopyTo(source); + source.Flush(); + } + + /// + public override int Read(byte[] buffer, int offset, int count) => + throw new System.NotSupportedException("Stream does not support reading."); + + /// + public override long Seek(long offset, SeekOrigin origin) => _buffer.Seek(offset, origin); + + /// + public override void SetLength(long value) => _buffer.SetLength(value); + + /// + public override void Write(byte[] buffer, int offset, int count) => + _buffer.Write(buffer, offset, count); +}