From 0d487df61be7762abdbcb72ae56cdee8f2ebbf51 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 19:15:10 +0000 Subject: [PATCH] Add IProgress support for compression operations with tests Co-authored-by: adamhathcock <527620+adamhathcock@users.noreply.github.com> --- .../IO/ProgressReportingStream.cs | 100 +++++++----- src/SharpCompress/Writers/AbstractWriter.cs | 8 +- src/SharpCompress/Writers/GZip/GZipWriter.cs | 3 +- src/SharpCompress/Writers/Tar/TarWriter.cs | 6 +- src/SharpCompress/Writers/Zip/ZipWriter.cs | 3 +- .../CompressionProgressTests.cs | 143 ++++++++++++++++++ 6 files changed, 223 insertions(+), 40 deletions(-) create mode 100644 tests/SharpCompress.Test/CompressionProgressTests.cs diff --git a/src/SharpCompress/IO/ProgressReportingStream.cs b/src/SharpCompress/IO/ProgressReportingStream.cs index 9192f9ca..8bb45092 100644 --- a/src/SharpCompress/IO/ProgressReportingStream.cs +++ b/src/SharpCompress/IO/ProgressReportingStream.cs @@ -7,7 +7,8 @@ using SharpCompress.Common; namespace SharpCompress.IO; /// -/// A stream wrapper that reports progress as data is written. +/// A stream wrapper that reports progress as data is read from the source. +/// Used to track compression progress by wrapping the source stream. /// internal sealed class ProgressReportingStream : Stream { @@ -15,19 +16,22 @@ internal sealed class ProgressReportingStream : Stream private readonly IProgress _progress; private readonly string _entryPath; private readonly long? _totalBytes; - private long _bytesWritten; + private long _bytesRead; + private readonly bool _leaveOpen; public ProgressReportingStream( Stream baseStream, IProgress progress, string entryPath, - long? totalBytes + long? totalBytes, + bool leaveOpen = false ) { _baseStream = baseStream; _progress = progress; _entryPath = entryPath; _totalBytes = totalBytes; + _leaveOpen = leaveOpen; } public override bool CanRead => _baseStream.CanRead; @@ -46,65 +50,88 @@ internal sealed class ProgressReportingStream : Stream public override void Flush() => _baseStream.Flush(); - public override int Read(byte[] buffer, int offset, int count) => - _baseStream.Read(buffer, offset, count); - - public override long Seek(long offset, SeekOrigin origin) => - _baseStream.Seek(offset, origin); - - public override void SetLength(long value) => _baseStream.SetLength(value); - - public override void Write(byte[] buffer, int offset, int count) + public override int Read(byte[] buffer, int offset, int count) { - _baseStream.Write(buffer, offset, count); - _bytesWritten += count; - ReportProgress(); + var bytesRead = _baseStream.Read(buffer, offset, count); + if (bytesRead > 0) + { + _bytesRead += bytesRead; + ReportProgress(); + } + return bytesRead; } - public override void Write(ReadOnlySpan buffer) + public override int Read(Span buffer) { - _baseStream.Write(buffer); - _bytesWritten += buffer.Length; - ReportProgress(); + var bytesRead = _baseStream.Read(buffer); + if (bytesRead > 0) + { + _bytesRead += bytesRead; + ReportProgress(); + } + return bytesRead; } - public override async Task WriteAsync( + public override async Task ReadAsync( byte[] buffer, int offset, int count, CancellationToken cancellationToken ) { - await _baseStream.WriteAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false); - _bytesWritten += count; - ReportProgress(); + var bytesRead = await _baseStream + .ReadAsync(buffer, offset, count, cancellationToken) + .ConfigureAwait(false); + if (bytesRead > 0) + { + _bytesRead += bytesRead; + ReportProgress(); + } + return bytesRead; } - public override async ValueTask WriteAsync( - ReadOnlyMemory buffer, + public override async ValueTask ReadAsync( + Memory buffer, CancellationToken cancellationToken = default ) { - await _baseStream.WriteAsync(buffer, cancellationToken).ConfigureAwait(false); - _bytesWritten += buffer.Length; - ReportProgress(); + var bytesRead = await _baseStream + .ReadAsync(buffer, cancellationToken) + .ConfigureAwait(false); + if (bytesRead > 0) + { + _bytesRead += bytesRead; + ReportProgress(); + } + return bytesRead; } - public override void WriteByte(byte value) + public override int ReadByte() { - _baseStream.WriteByte(value); - _bytesWritten++; - ReportProgress(); + var value = _baseStream.ReadByte(); + if (value != -1) + { + _bytesRead++; + ReportProgress(); + } + return value; } + public override long Seek(long offset, SeekOrigin origin) => _baseStream.Seek(offset, origin); + + public override void SetLength(long value) => _baseStream.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) => + _baseStream.Write(buffer, offset, count); + private void ReportProgress() { - _progress.Report(new CompressionProgress(_entryPath, _bytesWritten, _totalBytes)); + _progress.Report(new CompressionProgress(_entryPath, _bytesRead, _totalBytes)); } protected override void Dispose(bool disposing) { - if (disposing) + if (disposing && !_leaveOpen) { _baseStream.Dispose(); } @@ -113,7 +140,10 @@ internal sealed class ProgressReportingStream : Stream public override async ValueTask DisposeAsync() { - await _baseStream.DisposeAsync().ConfigureAwait(false); + if (!_leaveOpen) + { + await _baseStream.DisposeAsync().ConfigureAwait(false); + } await base.DisposeAsync().ConfigureAwait(false); } } diff --git a/src/SharpCompress/Writers/AbstractWriter.cs b/src/SharpCompress/Writers/AbstractWriter.cs index 467fcb6b..d86ccc74 100644 --- a/src/SharpCompress/Writers/AbstractWriter.cs +++ b/src/SharpCompress/Writers/AbstractWriter.cs @@ -37,7 +37,13 @@ public abstract class AbstractWriter(ArchiveType type, WriterOptions writerOptio } long? totalBytes = source.CanSeek ? source.Length : null; - return new ProgressReportingStream(source, WriterOptions.Progress, entryPath, totalBytes); + return new ProgressReportingStream( + source, + WriterOptions.Progress, + entryPath, + totalBytes, + leaveOpen: true + ); } public abstract void Write(string filename, Stream source, DateTime? modificationTime); diff --git a/src/SharpCompress/Writers/GZip/GZipWriter.cs b/src/SharpCompress/Writers/GZip/GZipWriter.cs index 00100323..ad1d59d7 100644 --- a/src/SharpCompress/Writers/GZip/GZipWriter.cs +++ b/src/SharpCompress/Writers/GZip/GZipWriter.cs @@ -47,7 +47,8 @@ public sealed class GZipWriter : AbstractWriter var stream = (GZipStream)OutputStream; stream.FileName = filename; stream.LastModified = modificationTime; - source.CopyTo(stream); + var progressStream = WrapWithProgress(source, filename); + progressStream.CopyTo(stream); _wroteToStream = true; } diff --git a/src/SharpCompress/Writers/Tar/TarWriter.cs b/src/SharpCompress/Writers/Tar/TarWriter.cs index 96346e2b..7d17d55a 100644 --- a/src/SharpCompress/Writers/Tar/TarWriter.cs +++ b/src/SharpCompress/Writers/Tar/TarWriter.cs @@ -127,7 +127,8 @@ public class TarWriter : AbstractWriter header.Name = NormalizeFilename(filename); header.Size = realSize; header.Write(OutputStream); - size = source.TransferTo(OutputStream, realSize); + var progressStream = WrapWithProgress(source, filename); + size = progressStream.TransferTo(OutputStream, realSize); PadTo512(size.Value); } @@ -159,7 +160,8 @@ public class TarWriter : AbstractWriter header.Name = NormalizeFilename(filename); header.Size = realSize; header.Write(OutputStream); - var written = await source + var progressStream = WrapWithProgress(source, filename); + var written = await progressStream .TransferToAsync(OutputStream, realSize, cancellationToken) .ConfigureAwait(false); PadTo512(written); diff --git a/src/SharpCompress/Writers/Zip/ZipWriter.cs b/src/SharpCompress/Writers/Zip/ZipWriter.cs index f867c8a9..1d5b5fc8 100644 --- a/src/SharpCompress/Writers/Zip/ZipWriter.cs +++ b/src/SharpCompress/Writers/Zip/ZipWriter.cs @@ -85,7 +85,8 @@ public class ZipWriter : AbstractWriter public void Write(string entryPath, Stream source, ZipWriterEntryOptions zipWriterEntryOptions) { using var output = WriteToStream(entryPath, zipWriterEntryOptions); - source.CopyTo(output); + var progressStream = WrapWithProgress(source, entryPath); + progressStream.CopyTo(output); } public Stream WriteToStream(string entryPath, ZipWriterEntryOptions options) diff --git a/tests/SharpCompress.Test/CompressionProgressTests.cs b/tests/SharpCompress.Test/CompressionProgressTests.cs new file mode 100644 index 00000000..576a8dd0 --- /dev/null +++ b/tests/SharpCompress.Test/CompressionProgressTests.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using SharpCompress.Common; +using SharpCompress.Writers; +using SharpCompress.Writers.Tar; +using SharpCompress.Writers.Zip; +using Xunit; + +namespace SharpCompress.Test; + +/// +/// A synchronous progress implementation for testing. +/// Unlike Progress<T>, this captures reports immediately without SynchronizationContext. +/// +internal sealed class TestProgress : IProgress +{ + private readonly List _reports = new(); + + public IReadOnlyList Reports => _reports; + + public void Report(T value) => _reports.Add(value); +} + +public class CompressionProgressTests : TestBase +{ + [Fact] + public void Zip_Write_ReportsProgress() + { + var progress = new TestProgress(); + + using var archiveStream = new MemoryStream(); + var options = new ZipWriterOptions(CompressionType.Deflate) { Progress = progress }; + + using (var writer = new ZipWriter(archiveStream, options)) + { + var testData = new byte[10000]; + Array.Fill(testData, (byte)'A'); + using var sourceStream = new MemoryStream(testData); + writer.Write("test.txt", sourceStream, DateTime.Now); + } + + Assert.NotEmpty(progress.Reports); + Assert.All(progress.Reports, p => Assert.Equal("test.txt", p.EntryPath)); + Assert.All(progress.Reports, p => Assert.Equal(10000, p.TotalBytes)); + + var lastReport = progress.Reports[^1]; + Assert.Equal(10000, lastReport.BytesRead); + Assert.Equal(100.0, lastReport.PercentComplete); + } + + [Fact] + public void Tar_Write_ReportsProgress() + { + var progress = new TestProgress(); + + using var archiveStream = new MemoryStream(); + var options = new TarWriterOptions(CompressionType.None, true) { Progress = progress }; + + using (var writer = new TarWriter(archiveStream, options)) + { + var testData = new byte[10000]; + Array.Fill(testData, (byte)'A'); + using var sourceStream = new MemoryStream(testData); + writer.Write("test.txt", sourceStream, DateTime.Now); + } + + Assert.NotEmpty(progress.Reports); + Assert.All(progress.Reports, p => Assert.Equal("test.txt", p.EntryPath)); + Assert.All(progress.Reports, p => Assert.Equal(10000, p.TotalBytes)); + + var lastReport = progress.Reports[^1]; + Assert.Equal(10000, lastReport.BytesRead); + Assert.Equal(100.0, lastReport.PercentComplete); + } + + [Fact] + public void WriterOptions_WithoutProgress_DoesNotThrow() + { + using var archiveStream = new MemoryStream(); + var options = new ZipWriterOptions(CompressionType.Deflate); + Assert.Null(options.Progress); + + using (var writer = new ZipWriter(archiveStream, options)) + { + var testData = new byte[100]; + Array.Fill(testData, (byte)'A'); + using var sourceStream = new MemoryStream(testData); + writer.Write("test.txt", sourceStream, DateTime.Now); + } + + Assert.True(archiveStream.Length > 0); + } + + [Fact] + public void CompressionProgress_PercentComplete_WithUnknownTotalBytes_ReturnsNull() + { + var progress = new CompressionProgress("test.txt", 100, null); + Assert.Null(progress.PercentComplete); + } + + [Fact] + public void CompressionProgress_PercentComplete_WithZeroTotalBytes_ReturnsNull() + { + var progress = new CompressionProgress("test.txt", 0, 0); + Assert.Null(progress.PercentComplete); + } + + [Fact] + public void CompressionProgress_Properties_AreSetCorrectly() + { + var progress = new CompressionProgress("path/to/file.txt", 500, 1000); + + Assert.Equal("path/to/file.txt", progress.EntryPath); + Assert.Equal(500, progress.BytesRead); + Assert.Equal(1000, progress.TotalBytes); + Assert.Equal(50.0, progress.PercentComplete); + } + + [Fact] + public async Task Tar_WriteAsync_ReportsProgress() + { + var progress = new TestProgress(); + + using var archiveStream = new MemoryStream(); + var options = new TarWriterOptions(CompressionType.None, true) { Progress = progress }; + + using (var writer = new TarWriter(archiveStream, options)) + { + var testData = new byte[10000]; + Array.Fill(testData, (byte)'A'); + using var sourceStream = new MemoryStream(testData); + await writer.WriteAsync("test.txt", sourceStream, DateTime.Now); + } + + Assert.NotEmpty(progress.Reports); + Assert.All(progress.Reports, p => Assert.Equal("test.txt", p.EntryPath)); + + var lastReport = progress.Reports[^1]; + Assert.Equal(10000, lastReport.BytesRead); + } +}