Skip to content
30 changes: 26 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,34 @@ SharpCompress is a pure C# compression library supporting multiple archive forma

- Use CSharpier for code formatting to ensure consistent style across the project
- CSharpier is configured as a local tool in `.config/dotnet-tools.json`
- **To format code, run the task: `format` task (which runs `dotnet csharpier .` from project root)**
- Restore tools with: `dotnet tool restore`
- Format files from the project root with: `dotnet csharpier .`
- Configure your IDE to format on save using CSharpier for the best experience

### Commands

1. **Restore tools** (first time only):
```bash
dotnet tool restore
```

2. **Check if files are formatted correctly** (doesn't modify files):
```bash
dotnet csharpier check .
```
- Exit code 0: All files are properly formatted
- Exit code 1: Some files need formatting (will show which files and differences)

3. **Format files** (modifies files):
```bash
dotnet csharpier format .
```
- Formats all files in the project to match CSharpier style
- Run from project root directory

4. **Configure your IDE** to format on save using CSharpier for the best experience

### Additional Notes
- The project also uses `.editorconfig` for editor settings (indentation, encoding, etc.)
- Let CSharpier handle code style while `.editorconfig` handles editor behavior
- Always run `dotnet csharpier check .` before committing to verify formatting

## Project Setup and Structure

Expand Down
195 changes: 139 additions & 56 deletions src/SharpCompress/Archives/IArchiveExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,85 +1,168 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using SharpCompress.Common;
using SharpCompress.Readers;

namespace SharpCompress.Archives;

public static class IArchiveExtensions
{
/// <summary>
/// Extract to specific directory, retaining filename
/// </summary>
public static void WriteToDirectory(
this IArchive archive,
string destinationDirectory,
ExtractionOptions? options = null
)
{
using var reader = archive.ExtractAllEntries();
reader.WriteAllToDirectory(destinationDirectory, options);
}

/// <summary>
/// Extracts the archive to the destination directory. Directories will be created as needed.
/// </summary>
/// <param name="archive">The archive to extract.</param>
/// <param name="destination">The folder to extract into.</param>
/// <param name="progressReport">Optional progress report callback.</param>
/// <param name="cancellationToken">Optional cancellation token.</param>
public static void ExtractToDirectory(
this IArchive archive,
string destination,
Action<double>? progressReport = null,
CancellationToken cancellationToken = default
)
extension(IArchive archive)
{
// Prepare for progress reporting
var totalBytes = archive.TotalUncompressSize;
var bytesRead = 0L;

// Tracking for created directories.
var seenDirectories = new HashSet<string>();
/// <summary>
/// Extract to specific directory with progress reporting
/// </summary>
/// <param name="destinationDirectory">The folder to extract into.</param>
/// <param name="options">Extraction options.</param>
/// <param name="progress">Optional progress reporter for tracking extraction progress.</param>
public void WriteToDirectory(
string destinationDirectory,
ExtractionOptions? options = null,
IProgress<ProgressReport>? progress = null
)
{
// For solid archives (Rar, 7Zip), use the optimized reader-based approach
if (archive.IsSolid || archive.Type == ArchiveType.SevenZip)
{
using var reader = archive.ExtractAllEntries();
reader.WriteAllToDirectory(destinationDirectory, options);
}
else
{
// For non-solid archives, extract entries directly
archive.WriteToDirectoryInternal(destinationDirectory, options, progress);
}
}

// Extract
foreach (var entry in archive.Entries)
private void WriteToDirectoryInternal(
string destinationDirectory,
ExtractionOptions? options,
IProgress<ProgressReport>? progress
)
{
cancellationToken.ThrowIfCancellationRequested();
// Prepare for progress reporting
var totalBytes = archive.TotalUncompressSize;
var bytesRead = 0L;

// Tracking for created directories.
var seenDirectories = new HashSet<string>();

if (entry.IsDirectory)
// Extract
foreach (var entry in archive.Entries)
{
var dirPath = Path.Combine(destination, entry.Key.NotNull("Entry Key is null"));
if (
Path.GetDirectoryName(dirPath + "/") is { } emptyDirectory
&& seenDirectories.Add(dirPath)
)
if (entry.IsDirectory)
{
Directory.CreateDirectory(emptyDirectory);
var dirPath = Path.Combine(
destinationDirectory,
entry.Key.NotNull("Entry Key is null")
);
if (
Path.GetDirectoryName(dirPath + "/") is { } parentDirectory
&& seenDirectories.Add(dirPath)
)
{
Directory.CreateDirectory(parentDirectory);
}
continue;
}
continue;

// Use the entry's WriteToDirectory method which respects ExtractionOptions
entry.WriteToDirectory(destinationDirectory, options);

// Update progress
bytesRead += entry.Size;
progress?.Report(
new ProgressReport(entry.Key ?? string.Empty, bytesRead, totalBytes)
);
}
}

// Create each directory if not already created
var path = Path.Combine(destination, entry.Key.NotNull("Entry Key is null"));
if (Path.GetDirectoryName(path) is { } directory)
/// <summary>
/// Extract to specific directory asynchronously with progress reporting and cancellation support
/// </summary>
/// <param name="destinationDirectory">The folder to extract into.</param>
/// <param name="options">Extraction options.</param>
/// <param name="progress">Optional progress reporter for tracking extraction progress.</param>
/// <param name="cancellationToken">Optional cancellation token.</param>
public async Task WriteToDirectoryAsync(
string destinationDirectory,
ExtractionOptions? options = null,
IProgress<ProgressReport>? progress = null,
CancellationToken cancellationToken = default
)
{
// For solid archives (Rar, 7Zip), use the optimized reader-based approach
if (archive.IsSolid || archive.Type == ArchiveType.SevenZip)
{
using var reader = archive.ExtractAllEntries();
await reader.WriteAllToDirectoryAsync(
destinationDirectory,
options,
cancellationToken
);
}
else
{
if (!Directory.Exists(directory) && !seenDirectories.Contains(directory))
// For non-solid archives, extract entries directly
await archive.WriteToDirectoryAsyncInternal(
destinationDirectory,
options,
progress,
cancellationToken
);
}
}

private async Task WriteToDirectoryAsyncInternal(
string destinationDirectory,
ExtractionOptions? options,
IProgress<ProgressReport>? progress,
CancellationToken cancellationToken
)
{
// Prepare for progress reporting
var totalBytes = archive.TotalUncompressSize;
var bytesRead = 0L;

// Tracking for created directories.
var seenDirectories = new HashSet<string>();

// Extract
foreach (var entry in archive.Entries)
{
cancellationToken.ThrowIfCancellationRequested();

if (entry.IsDirectory)
{
Directory.CreateDirectory(directory);
seenDirectories.Add(directory);
var dirPath = Path.Combine(
destinationDirectory,
entry.Key.NotNull("Entry Key is null")
);
if (
Path.GetDirectoryName(dirPath + "/") is { } parentDirectory
&& seenDirectories.Add(dirPath)
)
{
Directory.CreateDirectory(parentDirectory);
}
continue;
}
}

// Write file
using var fs = File.OpenWrite(path);
entry.WriteTo(fs);
// Use the entry's WriteToDirectoryAsync method which respects ExtractionOptions
await entry
.WriteToDirectoryAsync(destinationDirectory, options, cancellationToken)
.ConfigureAwait(false);

// Update progress
bytesRead += entry.Size;
progressReport?.Invoke(bytesRead / (double)totalBytes);
// Update progress
bytesRead += entry.Size;
progress?.Report(
new ProgressReport(entry.Key ?? string.Empty, bytesRead, totalBytes)
);
}
}
}
}
2 changes: 1 addition & 1 deletion tests/SharpCompress.Test/ArchiveTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ protected void ArchiveExtractToDirectory(
testArchive = Path.Combine(TEST_ARCHIVES_PATH, testArchive);
using (var archive = ArchiveFactory.Open(new FileInfo(testArchive), readerOptions))
{
archive.ExtractToDirectory(SCRATCH_FILES_PATH);
archive.WriteToDirectory(SCRATCH_FILES_PATH);
}
VerifyFiles();
}
Expand Down
34 changes: 34 additions & 0 deletions tests/SharpCompress.Test/Zip/ZipArchiveAsyncTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -194,4 +194,38 @@ await entry.WriteToDirectoryAsync(
}
VerifyFiles();
}

[Fact]
public async Task Zip_Deflate_Archive_WriteToDirectoryAsync()
{
using (Stream stream = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.zip")))
using (var archive = ZipArchive.Open(stream))
{
await archive.WriteToDirectoryAsync(
SCRATCH_FILES_PATH,
new ExtractionOptions { ExtractFullPath = true, Overwrite = true }
);
}
VerifyFiles();
}

[Fact]
public async Task Zip_Deflate_Archive_WriteToDirectoryAsync_WithProgress()
{
var progressReports = new System.Collections.Generic.List<ProgressReport>();
var progress = new Progress<ProgressReport>(report => progressReports.Add(report));

using (Stream stream = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.zip")))
using (var archive = ZipArchive.Open(stream))
{
await archive.WriteToDirectoryAsync(
SCRATCH_FILES_PATH,
new ExtractionOptions { ExtractFullPath = true, Overwrite = true },
progress
);
}

VerifyFiles();
Assert.True(progressReports.Count > 0, "Progress reports should be generated");
}
}