diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8a24f346..99c80e18 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -144,6 +144,10 @@ Pure.DI is a private build-time dependency; consumers do not need a container or a new client API. The default graph reuses workflow services, not opened archives, readers, writers, or streams, which retain their existing ownership rules. Services are exposed directly as typed composition roots, each a singleton within its composition. +Roots and constructor dependencies use internal interfaces generated by Pure.DI's +`[GenerateInterface(asInternal: true)]`. `Services/ServiceContracts.cs` declares their identities +for composition binding and supplements the sync signatures produced by Zomp, since source +generators cannot inspect one another's generated members. Public extension classes remain beside their public abstractions. Simple overloads supply defaults locally; extraction and filesystem workflows delegate to `ArchiveExtractor` and `ArchiveFileWriter`, including reader extraction, writable-archive entry creation, and saving archives to files. diff --git a/src/SharpCompress/Archives/ArchiveFactory.cs b/src/SharpCompress/Archives/ArchiveFactory.cs index 425dbc87..5d1998c8 100644 --- a/src/SharpCompress/Archives/ArchiveFactory.cs +++ b/src/SharpCompress/Archives/ArchiveFactory.cs @@ -13,7 +13,7 @@ namespace SharpCompress.Archives; public static partial class ArchiveFactory { - private static ArchiveService Service => LibraryDefaults.Composition.Archives; + private static IArchiveService Service => LibraryDefaults.Composition.Archives; public static IArchive OpenArchive(Stream stream, ReaderOptions? readerOptions = null) => Service.OpenArchive(stream, readerOptions); diff --git a/src/SharpCompress/Archives/IArchiveEntryExtensions.cs b/src/SharpCompress/Archives/IArchiveEntryExtensions.cs index 8fd3bf66..d9529300 100644 --- a/src/SharpCompress/Archives/IArchiveEntryExtensions.cs +++ b/src/SharpCompress/Archives/IArchiveEntryExtensions.cs @@ -10,7 +10,7 @@ namespace SharpCompress.Archives; public static partial class IArchiveEntryExtensions { // Give the sync generator a known receiver type before Pure.DI generates the root property. - private static ArchiveExtractor Extractor => LibraryDefaults.Composition.Extractor; + private static IArchiveExtractor Extractor => LibraryDefaults.Composition.Extractor; /// The archive entry to extract. extension(IArchiveEntry archiveEntry) diff --git a/src/SharpCompress/Services/ArchiveExtractor.cs b/src/SharpCompress/Services/ArchiveExtractor.cs index 1c533dcb..e4f1cdce 100644 --- a/src/SharpCompress/Services/ArchiveExtractor.cs +++ b/src/SharpCompress/Services/ArchiveExtractor.cs @@ -2,6 +2,7 @@ using System; using System.IO; using System.Threading; using System.Threading.Tasks; +using Pure.DI; using SharpCompress.Archives; using SharpCompress.Common; using SharpCompress.IO; @@ -9,7 +10,8 @@ using SharpCompress.IO; namespace SharpCompress.Services; // Destination streams are borrowed, not owned by the reusable extraction service. -internal sealed partial class ArchiveExtractor +[GenerateInterface(asInternal: true)] +internal sealed partial class ArchiveExtractor : IArchiveExtractor { [Zomp.SyncMethodGenerator.CreateSyncVersion(PreserveProgress = true)] public async ValueTask WriteToAsync( diff --git a/src/SharpCompress/Services/ArchiveFileWriter.cs b/src/SharpCompress/Services/ArchiveFileWriter.cs index e2514b03..eaa31f49 100644 --- a/src/SharpCompress/Services/ArchiveFileWriter.cs +++ b/src/SharpCompress/Services/ArchiveFileWriter.cs @@ -3,11 +3,13 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Pure.DI; using SharpCompress.Writers; namespace SharpCompress.Services; -internal sealed partial class ArchiveFileWriter +[GenerateInterface(asInternal: true)] +internal sealed partial class ArchiveFileWriter : IArchiveFileWriter { public void Write(IWriter writer, string entryPath, FileInfo source) { diff --git a/src/SharpCompress/Services/ArchiveService.Detection.Async.cs b/src/SharpCompress/Services/ArchiveService.Detection.Async.cs index 8f06d657..f7a91a20 100644 --- a/src/SharpCompress/Services/ArchiveService.Detection.Async.cs +++ b/src/SharpCompress/Services/ArchiveService.Detection.Async.cs @@ -88,7 +88,7 @@ internal sealed partial class ArchiveService .ConfigureAwait(false); } - internal ValueTask FindFactoryAsync( + public ValueTask FindFactoryAsync( string filePath, CancellationToken cancellationToken = default ) @@ -98,7 +98,7 @@ internal sealed partial class ArchiveService return FindFactoryAsync(new FileInfo(filePath), cancellationToken); } - internal async ValueTask FindFactoryAsync( + public async ValueTask FindFactoryAsync( FileInfo fileInfo, CancellationToken cancellationToken = default ) @@ -123,7 +123,7 @@ internal sealed partial class ArchiveService .ConfigureAwait(false); } - internal async ValueTask FindFactoryAsync( + public async ValueTask FindFactoryAsync( Stream stream, CancellationToken cancellationToken = default ) @@ -181,7 +181,8 @@ internal sealed partial class ArchiveService try { - foreach (var factory in catalog.Factories) + // The explicit type lets Zomp resolve probe overloads before Pure.DI emits catalog members. + foreach (IFactory factory in catalog.Factories) { stream.Seek(startPosition, SeekOrigin.Begin); var isArchive = await factory @@ -279,7 +280,7 @@ internal sealed partial class ArchiveService try { - foreach (var factory in catalog.Factories) + foreach (IFactory factory in catalog.Factories) { stream.Seek(startPosition, SeekOrigin.Begin); if ( @@ -334,7 +335,7 @@ internal sealed partial class ArchiveService CancellationToken cancellationToken ) { - foreach (var wrapper in catalog.Wrappers) + foreach (TarWrapper wrapper in catalog.Wrappers) { #if !SYNC_ONLY cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/SharpCompress/Services/ArchiveService.cs b/src/SharpCompress/Services/ArchiveService.cs index 6c7ee717..3c8093e6 100644 --- a/src/SharpCompress/Services/ArchiveService.cs +++ b/src/SharpCompress/Services/ArchiveService.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Pure.DI; using SharpCompress.Archives; using SharpCompress.Common; using SharpCompress.Common.Options; @@ -12,11 +13,12 @@ using SharpCompress.Readers; namespace SharpCompress.Services; +[GenerateInterface(asInternal: true)] internal sealed partial class ArchiveService( - FactoryCatalog catalog, - ReaderService readers, - ArchiveExtractor extractor -) + IFactoryCatalog catalog, + IReaderService readers, + IArchiveExtractor extractor +) : IArchiveService { public IArchive OpenArchive(Stream stream, ReaderOptions? readerOptions = null) { diff --git a/src/SharpCompress/Services/FactoryCatalog.cs b/src/SharpCompress/Services/FactoryCatalog.cs index bc8aee83..89016a0d 100644 --- a/src/SharpCompress/Services/FactoryCatalog.cs +++ b/src/SharpCompress/Services/FactoryCatalog.cs @@ -1,14 +1,16 @@ using System; using System.Collections.Generic; +using Pure.DI; using SharpCompress.Factories; namespace SharpCompress.Services; // Read through on each operation: legacy registrations made after composition must remain visible. +[GenerateInterface(asInternal: true)] internal sealed class FactoryCatalog( Func> factories, Func> wrappers -) +) : IFactoryCatalog { public IEnumerable Factories => factories(); public IEnumerable Wrappers => wrappers(); diff --git a/src/SharpCompress/Services/ReaderService.cs b/src/SharpCompress/Services/ReaderService.cs index 7d07208b..9194e4a8 100644 --- a/src/SharpCompress/Services/ReaderService.cs +++ b/src/SharpCompress/Services/ReaderService.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Linq; +using Pure.DI; using SharpCompress.Common; using SharpCompress.Factories; using SharpCompress.IO; @@ -8,7 +9,8 @@ using SharpCompress.Readers; namespace SharpCompress.Services; -internal sealed partial class ReaderService(FactoryCatalog catalog) +[GenerateInterface(asInternal: true)] +internal sealed partial class ReaderService(IFactoryCatalog catalog) : IReaderService { public IReader OpenReader(string filePath, ReaderOptions? options = null) { diff --git a/src/SharpCompress/Services/ServiceComposition.cs b/src/SharpCompress/Services/ServiceComposition.cs index 5b55e6c1..2042160f 100644 --- a/src/SharpCompress/Services/ServiceComposition.cs +++ b/src/SharpCompress/Services/ServiceComposition.cs @@ -9,27 +9,27 @@ internal partial class ServiceComposition private static void Setup() => DI.Setup() .Hint(Hint.Resolve, "Off") - .Arg("catalog") - .Bind() + .Arg("catalog") + .Bind() .As(Lifetime.Singleton) .To() - .Bind() + .Bind() .As(Lifetime.Singleton) .To() - .Bind() + .Bind() .As(Lifetime.Singleton) .To() - .Bind() + .Bind() .As(Lifetime.Singleton) .To() - .Bind() + .Bind() .As(Lifetime.Singleton) .To() - .Root("Archives") - .Root("Readers") - .Root("Writers") - .Root("Extractor") - .Root("FileWriter"); + .Root("Archives") + .Root("Readers") + .Root("Writers") + .Root("Extractor") + .Root("FileWriter"); } internal static class LibraryDefaults diff --git a/src/SharpCompress/Services/ServiceContracts.cs b/src/SharpCompress/Services/ServiceContracts.cs new file mode 100644 index 00000000..98c4b9d0 --- /dev/null +++ b/src/SharpCompress/Services/ServiceContracts.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.IO; +using SharpCompress.Archives; +using SharpCompress.Common; +using SharpCompress.Detection; +using SharpCompress.Readers; + +namespace SharpCompress.Services; + +// Declare the contract identities up front so the composition can bind them in the same +// generator pass that fills in their members. +internal partial interface IFactoryCatalog; + +internal partial interface IReaderService; + +internal partial interface IWriterService; + +internal partial interface IArchiveFileWriter; + +// Pure.DI cannot see methods emitted by another source generator. Only the sync signatures +// generated by Zomp are declared here; Pure.DI generates the rest from the implementations. +internal partial interface IArchiveService +{ + ArchiveDetection? DetectArchive(string filePath); + ArchiveDetection? DetectArchive(string filePath, ReaderOptions? readerOptions); + ArchiveDetection? DetectArchive(Stream stream); + ArchiveDetection? DetectArchive(Stream stream, ReaderOptions? readerOptions); + ArchiveInformation? InspectArchive(string filePath); + ArchiveInformation? InspectArchive(string filePath, ReaderOptions? readerOptions); + ArchiveInformation? InspectArchive(Stream stream); + ArchiveInformation? InspectArchive(Stream stream, ReaderOptions? readerOptions); + ArchiveInformation? InspectArchive( + IReadOnlyList fileInfos, + ReaderOptions? readerOptions = null + ); + ArchiveInformation? InspectArchive( + IReadOnlyList streams, + ReaderOptions? readerOptions = null + ); +} + +internal partial interface IArchiveExtractor +{ + void WriteTo( + IArchiveEntry archiveEntry, + Stream streamToWriteTo, + int? bufferSize, + ExtractionOptions? options = null, + IProgress? progress = null + ); + void WriteToDirectory( + IArchiveEntry entry, + string destinationDirectory, + ExtractionOptions? options = null + ); + void WriteToFile( + IArchiveEntry entry, + string destinationFileName, + ExtractionOptions? options = null + ); +} diff --git a/src/SharpCompress/Services/WriterService.cs b/src/SharpCompress/Services/WriterService.cs index 41c1f69f..8fe8c025 100644 --- a/src/SharpCompress/Services/WriterService.cs +++ b/src/SharpCompress/Services/WriterService.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Pure.DI; using SharpCompress.Common; using SharpCompress.Common.Options; using SharpCompress.Factories; @@ -10,7 +11,8 @@ using SharpCompress.Writers; namespace SharpCompress.Services; -internal sealed class WriterService(FactoryCatalog catalog) +[GenerateInterface(asInternal: true)] +internal sealed class WriterService(IFactoryCatalog catalog) : IWriterService { public IWriter OpenWriter( string filePath, diff --git a/tests/SharpCompress.Test/ServiceCompositionTests.cs b/tests/SharpCompress.Test/ServiceCompositionTests.cs index 71313179..ca1da906 100644 --- a/tests/SharpCompress.Test/ServiceCompositionTests.cs +++ b/tests/SharpCompress.Test/ServiceCompositionTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -36,6 +37,12 @@ public class ServiceCompositionTests : TestBase typeof(WriterService), typeof(ArchiveExtractor), typeof(ArchiveFileWriter), + typeof(IFactoryCatalog), + typeof(IArchiveService), + typeof(IReaderService), + typeof(IWriterService), + typeof(IArchiveExtractor), + typeof(IArchiveFileWriter), ]; Assert.All( serviceTypes, @@ -52,6 +59,71 @@ public class ServiceCompositionTests : TestBase Assert.IsType(TarWrapper.Wrappers); } + [Fact] + public void ServiceContracts_IncludeGeneratedSyncMethods() + { + (Type Contract, Type Implementation)[] services = + [ + (typeof(IFactoryCatalog), typeof(FactoryCatalog)), + (typeof(IArchiveService), typeof(ArchiveService)), + (typeof(IReaderService), typeof(ReaderService)), + (typeof(IWriterService), typeof(WriterService)), + (typeof(IArchiveExtractor), typeof(ArchiveExtractor)), + (typeof(IArchiveFileWriter), typeof(ArchiveFileWriter)), + ]; + foreach (var (contract, implementation) in services) + { + Assert.True(contract.IsAssignableFrom(implementation)); + foreach ( + var method in implementation.GetMethods( + BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly + ) + ) + { + Assert.Contains( + contract.GetMethods(), + candidate => candidate.ToString() == method.ToString() + ); + } + } + Assert.Equal( + typeof(IArchiveService), + typeof(ServiceComposition).GetProperty("Archives")!.PropertyType + ); + Assert.Equal( + typeof(IReaderService), + typeof(ServiceComposition).GetProperty("Readers")!.PropertyType + ); + Assert.Equal( + typeof(IWriterService), + typeof(ServiceComposition).GetProperty("Writers")!.PropertyType + ); + Assert.Equal( + typeof(IArchiveExtractor), + typeof(ServiceComposition).GetProperty("Extractor")!.PropertyType + ); + Assert.Equal( + typeof(IArchiveFileWriter), + typeof(ServiceComposition).GetProperty("FileWriter")!.PropertyType + ); + } + + [Fact] + public void Composition_AcceptsInterfaceOnlyCatalog() + { + IFactoryCatalog catalog = new TestFactoryCatalog(); + var composition = new ServiceComposition(catalog); + using var stream = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.zip")); + Assert.Equal(ArchiveType.Zip, composition.Archives.DetectArchive(stream)?.ContainerType); + Assert.Equal(0, stream.Position); + } + + private sealed class TestFactoryCatalog : IFactoryCatalog + { + public IEnumerable Factories { get; } = [new ZipFactory()]; + public IEnumerable Wrappers => TarWrapper.Wrappers; + } + [Fact] public async Task Composition_ReusesServiceRootsDuringConcurrentAccess() {